diff --git a/public/components/CreateForecasterButtons/CreateForecasterButtons.tsx b/public/components/CreateForecasterButtons/CreateForecasterButtons.tsx
new file mode 100644
index 000000000..72b814b02
--- /dev/null
+++ b/public/components/CreateForecasterButtons/CreateForecasterButtons.tsx
@@ -0,0 +1,40 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import { EuiFlexGroup, EuiFlexItem, EuiSmallButton } from '@elastic/eui';
+import React from 'react';
+import { APP_PATH, FORECASTING_FEATURE_NAME } from '../../utils/constants';
+import { useLocation } from 'react-router-dom';
+import { constructHrefWithDataSourceId, getDataSourceFromURL } from '../../pages/utils/helpers';
+
+export const CreateForecasterButtons = () => {
+ const location = useLocation();
+ const MDSQueryParams = getDataSourceFromURL(location);
+ const dataSourceId = MDSQueryParams.dataSourceId;
+
+ const createForecasterUrl = `${FORECASTING_FEATURE_NAME}#` + constructHrefWithDataSourceId(`${APP_PATH.CREATE_FORECASTER}`, dataSourceId, false);
+
+ return (
+
+
+
+ Create forecaster
+
+
+
+ );
+};
diff --git a/public/components/FormattedFormRow/FormattedFormRow.tsx b/public/components/FormattedFormRow/FormattedFormRow.tsx
index 6c5bb25a6..7500b83cd 100644
--- a/public/components/FormattedFormRow/FormattedFormRow.tsx
+++ b/public/components/FormattedFormRow/FormattedFormRow.tsx
@@ -16,7 +16,7 @@ type FormattedFormRowProps = {
title?: string;
formattedTitle?: ReactNode;
children: ReactElement;
- hint?: string | string[];
+ hint?: string | string[] | ReactNode | ReactNode[];
isInvalid?: boolean;
error?: ReactNode | ReactNode[];
fullWidth?: boolean;
@@ -42,7 +42,8 @@ export const FormattedFormRow = (props: FormattedFormRowProps) => {
))
: null;
- const { formattedTitle, linkToolTip, ...euiFormRowProps } = props;
+ // Extract hintLink to avoid passing it to EuiCompressedFormRow as an unknown prop
+ const { formattedTitle, hintLink, linkToolTip, ...euiFormRowProps } = props;
return (
{
+ const location = useLocation();
+ const MDSQueryParams = getDataSourceFromURL(location);
+ const dataSourceId = MDSQueryParams.dataSourceId;
+
+ const refreshForecastersUrl = `${FORECASTING_FEATURE_NAME}#` + constructHrefWithDataSourceId(`${APP_PATH.LIST_FORECASTERS}`, dataSourceId, false);
+
+ return (
+
+
+
+ Refresh
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/public/components/RefreshForecastersButton/__tests__/RefreshForecastersButton.test.tsx b/public/components/RefreshForecastersButton/__tests__/RefreshForecastersButton.test.tsx
new file mode 100644
index 000000000..027db835e
--- /dev/null
+++ b/public/components/RefreshForecastersButton/__tests__/RefreshForecastersButton.test.tsx
@@ -0,0 +1,56 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { Router } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
+import { RefreshForecastersButton } from '../RefreshForecastersButton';
+import { getDataSourceEnabled, } from '../../../services';
+
+// Mock the module
+jest.mock('../../../services', () => ({
+ ...jest.requireActual('../../../services'),
+ getDataSourceEnabled: jest.fn(),
+}));
+
+describe(' spec', () => {
+ beforeAll(() => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: 'http://test.com',
+ pathname: '/',
+ search: '',
+ hash: '',
+ },
+ writable: true
+ });
+ });
+
+ beforeEach(() => {
+ // Mock the return value
+ (getDataSourceEnabled as jest.Mock).mockReturnValue({
+ enabled: false, // or true, depending on what you want to test
+ });
+ });
+
+ test('renders component', () => {
+ const history = createMemoryHistory();
+ const { container, getByText } = render(
+
+
+
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ expect(getByText('Refresh')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/public/components/RefreshForecastersButton/__tests__/__snapshots__/RefreshForecastersButton.test.tsx.snap b/public/components/RefreshForecastersButton/__tests__/__snapshots__/RefreshForecastersButton.test.tsx.snap
new file mode 100644
index 000000000..ee880e13b
--- /dev/null
+++ b/public/components/RefreshForecastersButton/__tests__/__snapshots__/RefreshForecastersButton.test.tsx.snap
@@ -0,0 +1,43 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` spec renders component 1`] = `
+
+`;
diff --git a/public/forecasting_app.tsx b/public/forecasting_app.tsx
new file mode 100644
index 000000000..4a5713bb0
--- /dev/null
+++ b/public/forecasting_app.tsx
@@ -0,0 +1,53 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import { CoreStart, AppMountParameters } from '../../../src/core/public';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { HashRouter as Router, Route } from 'react-router-dom';
+import { ForecastMain } from './pages/main/ForecastMain';
+import { Provider } from 'react-redux';
+import configureStore from './redux/configureStore';
+import { CoreServicesContext } from './components/CoreServices/CoreServices';
+
+export function renderApp(coreStart: CoreStart, params: AppMountParameters, landingPage: string | undefined, hideInAppSideNavBar: boolean) {
+ const http = coreStart.http;
+ const store = configureStore(http);
+
+ // Load Chart's dark mode CSS (if applicable)
+ const isDarkMode = coreStart.uiSettings.get('theme:darkMode') || false;
+ if (isDarkMode) {
+ require('@elastic/charts/dist/theme_only_dark.css');
+ } else {
+ require('@elastic/charts/dist/theme_only_light.css');
+ }
+
+ ReactDOM.render(
+
+
+ (
+
+
+
+ )}
+ />
+
+ ,
+ params.element
+ );
+ return () => ReactDOM.unmountComponentAtNode(params.element);
+}
diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts
index 74a0a1e69..09896fbf4 100644
--- a/public/models/interfaces.ts
+++ b/public/models/interfaces.ts
@@ -11,7 +11,7 @@
import { InitProgress } from '../../server/models/interfaces';
import { DATA_TYPES } from '../utils/constants';
-import { DETECTOR_STATE } from '../../server/utils/constants';
+import { DETECTOR_STATE, FORECASTER_STATE } from '../../server/utils/constants';
import { Duration } from 'moment';
import moment from 'moment';
import { MDSQueryParams } from '../../server/models/types';
@@ -231,6 +231,56 @@ export type DetectorListItem = {
detectorType?: string;
};
+export type Forecaster = {
+ primaryTerm: number;
+ seqNo: number;
+ id: string;
+ name: string;
+ description: string;
+ timeField: string;
+ indices: string[];
+ resultIndex?: string;
+ resultIndexMinAge?: number;
+ resultIndexMinSize?: number;
+ resultIndexTtl?: number;
+ flattenCustomResultIndex?: boolean;
+ filterQuery: { [key: string]: any };
+ featureAttributes: FeatureAttributes[];
+ windowDelay: { period: Schedule };
+ forecastInterval: { period: Schedule };
+ shingleSize: number;
+ uiMetadata: UiMetaData;
+ lastUpdateTime: number;
+ enabled?: boolean;
+ enabledTime?: number;
+ disabledTime?: number;
+ curState: FORECASTER_STATE; // combined state of realTime and runOnce
+ stateError: string; // combined error of realTime and runOnce
+ initProgress?: InitProgress; // realTime
+ categoryField?: string[];
+ taskId?: string; // runOnce
+ taskState?: FORECASTER_STATE; // runOnce
+ taskProgress?: number; // runOnce
+ taskError?: string; // runOnce
+ lastStateUpdateTime?: number;
+ imputationOption?: ImputationOption;
+ suggestedSeasonality?: number;
+ recencyEmphasis?: number;
+ horizon?: number;
+ history?: number;
+ lastUiBreakingChangeTime?: number;
+};
+
+export type ForecasterListItem = {
+ id: string;
+ name: string;
+ indices: string[];
+ curState: FORECASTER_STATE;
+ realTimeLastUpdateTime: number;
+ runOnceLastUpdateTime: number;
+ stateError: string;
+};
+
export type EntityData = {
name: string;
value: string;
@@ -342,3 +392,31 @@ export interface MDSStates {
queryParams: MDSQueryParams;
selectedDataSourceId: string | undefined;
}
+
+/* export type ForecastData = {
+ dataQuality: number;
+ forecasterId?: string;
+ endTime: number;
+ startTime: number;
+ plotTime?: number;
+ entity?: EntityData[];
+ features?: { [key: string]: ForecastFeatureAggregationData };
+ aggInterval?: string;
+}; */
+
+export type ForecastFeatureAggregationData = {
+ data: number;
+ name?: string;
+ endTime: number;
+ startTime: number;
+ plotTime?: number;
+ lowerBound: number[];
+ upperBound: number[];
+ forecastValue: number[];
+ forecastStartTime: number[];
+ forecastEndTime: number[];
+};
+
+/* export type ForecastResult = {
+ forecastResult: ForecastData[];
+}; */
diff --git a/public/pages/ConfigureForecastModel/components/AdvancedSettings/AdvancedSettings.tsx b/public/pages/ConfigureForecastModel/components/AdvancedSettings/AdvancedSettings.tsx
new file mode 100644
index 000000000..f66f234ee
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/AdvancedSettings/AdvancedSettings.tsx
@@ -0,0 +1,345 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import { useEffect, useState } from 'react';
+import {
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiText,
+ EuiTitle,
+ EuiCompressedFieldNumber,
+ EuiSpacer,
+ EuiCompressedSelect,
+ EuiSmallButtonIcon,
+ EuiCompressedFieldText,
+ EuiToolTip,
+ EuiIcon,
+ EuiBadge,
+} from '@elastic/eui';
+import { Field, FieldProps, FieldArray } from 'formik';
+import ContentPanel from '../../../../components/ContentPanel/ContentPanel';
+import {
+ FORECASTER_DOCS_LINK,
+ FIELD_MAX_WIDTH,
+ INPUT_SLIDER_WIDTH,
+} from '../../../../utils/constants';
+import {
+ isInvalid,
+ getError,
+ validatePositiveInteger,
+ validatePositiveDecimal,
+ validateEmptyOrPositiveInteger,
+} from '../../../../utils/utils';
+import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow';
+import { SparseDataOptionValue } from '../../utils/constants';
+import '../../index.scss';
+import React, { ReactElement } from 'react';
+import { toNumberOrEmpty } from '../../utils/helpers';
+
+interface AdvancedSettingsProps {
+ isEditable?: boolean;
+}
+
+export function AdvancedSettings({ isEditable = true }: AdvancedSettingsProps) {
+ const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
+
+ const sparseDataOptions = [
+ { value: SparseDataOptionValue.IGNORE, text: 'Ignore missing value' },
+ { value: SparseDataOptionValue.PREVIOUS_VALUE, text: 'Previous value' },
+ { value: SparseDataOptionValue.SET_TO_ZERO, text: 'Set to zero' },
+ { value: SparseDataOptionValue.CUSTOM_VALUE, text: 'Custom value' },
+ ];
+
+ return (
+
+
+ {
+ setShowAdvancedSettings(!showAdvancedSettings);
+ }}
+ style={{ cursor: isEditable ? 'pointer' : 'not-allowed' }}
+ />
+
+
+ Advanced model parameters
+
+
+ }
+ hideBody={!showAdvancedSettings}
+ bodyStyles={{ marginTop: '-16px' }}
+ >
+ {showAdvancedSettings ? : null}
+ {showAdvancedSettings ? (
+ <>
+ {/* --------------------- Shingle size --------------------- */}
+
+ {({ field, form }: FieldProps) => (
+
+ {/* Wrap the number input + badge in a single FlexGroup */}
+
+
+
+ {
+ form.setFieldValue(field.name, toNumberOrEmpty(e.target.value));
+ }}
+ />
+
+
+
+
+ intervals
+
+
+
+
+ )}
+
+
+ {/* ----------------- Suggested seasonality ----------------- */}
+
+ {({ field, form }: FieldProps) => (
+
+
+
+
+ {
+ form.setFieldValue(field.name, toNumberOrEmpty(e.target.value));
+ }}
+ />
+
+
+
+
+ intervals
+
+
+
+
+ )}
+
+
+ {/* ------------------- Recency emphasis ------------------- */}
+
+ {({ field, form }: FieldProps) => (
+
+ Recency emphasis is like the "window size" in a classic
+ moving average, except that it gradually stops considering older
+ data rather than dropping it all at once. In a fixed moving average of
+ size{' '}W, each data point stays in the sample for exactly
+ {' '}W steps, then is removed entirely. By contrast, a higher
+ recency emphasis on average retains a data point in the sample
+ for more steps, but with an exponential decay—recent data
+ gets the most weight, and older data slowly fades rather than abruptly
+ dropping.
+
+
+ Mathematically, the "lifetime" of each data point (how many steps it stays
+ influential) follows an approximate exponential distribution. The
+ recency emphasis value is the mean of that distribution, i.e., the
+ average number of steps a point remains in the sample. A bigger emphasis
+ makes forecasts react more slowly to recent changes (like having a larger
+ window size), while a smaller emphasis adapts faster but risks overreacting
+ to short-term noise. The default is 2560, and you must have
+ at least 1.
+ ,
+ ]}
+ hintLink={FORECASTER_DOCS_LINK}
+ isInvalid={isInvalid(field.name, form)}
+ error={getError(field.name, form)}
+ >
+
+
+
+ {
+ form.setFieldValue(field.name, toNumberOrEmpty(e.target.value));
+ }}
+ />
+
+
+
+
+ intervals
+
+
+
+
+ )}
+
+
+ {/* ---------------- Sparse data handling ---------------- */}
+
+ {({ field, form }: FieldProps) => {
+ // If "Custom value" is selected, ensure at least one row is present
+ useEffect(() => {
+ if (
+ field.value === SparseDataOptionValue.CUSTOM_VALUE &&
+ (!form.values.imputationOption?.custom_value ||
+ form.values.imputationOption.custom_value.length === 0)
+ ) {
+ form.setFieldValue('imputationOption.custom_value', [
+ { featureName: '', value: undefined },
+ ]);
+ }
+ }, [field.value, form]);
+
+ return (
+ <>
+
+
+
+
+
+
+ {field.value === SparseDataOptionValue.CUSTOM_VALUE && (
+
+
+
+ Custom value
+
+
+
+ {(arrayHelpers) => (
+ <>
+ {form.values.imputationOption.custom_value?.map(
+ (_, index) => (
+
+
+
+ {({ field }: FieldProps) => (
+
+ {
+ form.setFieldValue(field.name, toNumberOrEmpty(e.target.value));
+ }}
+ value={field.value || ''}
+ />
+
+ )}
+
+
+
+ )
+ )}
+ >
+ )}
+
+
+ )}
+ >
+ );
+ }}
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/public/pages/ConfigureForecastModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx b/public/pages/ConfigureForecastModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx
new file mode 100644
index 000000000..e052608e8
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx
@@ -0,0 +1,364 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Formik } from 'formik';
+import { AdvancedSettings } from '../AdvancedSettings';
+import { SparseDataOptionValue } from '../../../utils/constants';
+
+const defaultInitialValues = {
+ shingleSize: '',
+ suggestedSeasonality: '',
+ recencyEmphasis: '',
+ imputationOption: {
+ imputationMethod: SparseDataOptionValue.IGNORE,
+ custom_value: [],
+ },
+};
+
+const renderWithFormik = (initialValues = defaultInitialValues, props = {}) => {
+ return render(
+
+ {() => }
+
+ );
+};
+
+const expandPanel = () => {
+ // Find the arrow icon - it could have cursor: pointer (editable) or cursor: not-allowed (non-editable)
+ const icons = screen.getAllByRole('img', { hidden: true });
+ const arrowIcon = icons.find(icon => {
+ const style = window.getComputedStyle(icon);
+ // Look for the icon that has either cursor pointer or not-allowed (both are the arrow icon)
+ return style.cursor === 'pointer' || style.cursor === 'not-allowed';
+ });
+
+ if (arrowIcon) {
+ fireEvent.click(arrowIcon);
+ } else {
+ // Fallback to clicking the first icon if we can't find the right one
+ fireEvent.click(icons[0]);
+ }
+};
+
+describe('AdvancedSettings Component', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Component rendering and collapsibility', () => {
+ test('renders collapsed by default', () => {
+ renderWithFormik();
+
+ expect(screen.getByText('Advanced model parameters')).toBeInTheDocument();
+ expect(screen.queryByTestId('shingleSize')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('suggestedSeasonality')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('recencyEmphasis')).not.toBeInTheDocument();
+ });
+
+ test('expands when arrow icon is clicked', () => {
+ renderWithFormik();
+
+ expandPanel();
+
+ expect(screen.getByTestId('shingleSize')).toBeInTheDocument();
+ expect(screen.getByTestId('suggestedSeasonality')).toBeInTheDocument();
+ expect(screen.getByTestId('recencyEmphasis')).toBeInTheDocument();
+ });
+
+ test('icon is clickable even when not editable', () => {
+ renderWithFormik(defaultInitialValues, { isEditable: false });
+
+ expandPanel();
+
+ // Panel should expand even when not editable
+ expect(screen.getByTestId('shingleSize')).toBeInTheDocument();
+ expect(screen.getByTestId('suggestedSeasonality')).toBeInTheDocument();
+ expect(screen.getByTestId('recencyEmphasis')).toBeInTheDocument();
+ });
+ });
+
+ describe('Shingle size field', () => {
+ beforeEach(() => {
+ renderWithFormik();
+ expandPanel();
+ });
+
+ test('renders shingle size field with correct attributes', () => {
+ const shingleSizeInput = screen.getByTestId('shingleSize');
+
+ expect(shingleSizeInput).toBeInTheDocument();
+ expect(shingleSizeInput).toHaveAttribute('min', '4');
+ expect(shingleSizeInput).toHaveAttribute('max', '128');
+ expect(shingleSizeInput).toHaveAttribute('placeholder', 'Shingle size');
+
+ // Check that at least one intervals badge exists
+ const intervalsBadges = screen.getAllByText('intervals');
+ expect(intervalsBadges.length).toBeGreaterThan(0);
+ });
+
+ test('displays help text for shingle size', () => {
+ expect(screen.getByText(/Set the number of past forecast intervals/)).toBeInTheDocument();
+ expect(screen.getByText(/shingle size to be in the range of 4 and 128/)).toBeInTheDocument();
+ });
+
+ test('accepts valid shingle size values', async () => {
+ const shingleSizeInput = screen.getByTestId('shingleSize');
+
+ fireEvent.change(shingleSizeInput, { target: { value: '16' } });
+ expect((shingleSizeInput as HTMLInputElement).value).toBe('16');
+ });
+ });
+
+ describe('Non-editable mode', () => {
+ test('shingle size is disabled when not editable', () => {
+ renderWithFormik(defaultInitialValues, { isEditable: false });
+ expandPanel();
+
+ const shingleSizeInput = screen.getByTestId('shingleSize');
+ expect(shingleSizeInput).toBeDisabled();
+ });
+ });
+
+ describe('Suggested seasonality field', () => {
+ beforeEach(() => {
+ renderWithFormik();
+ expandPanel();
+ });
+
+ test('renders suggested seasonality field with correct attributes', () => {
+ const seasonalityInput = screen.getByTestId('suggestedSeasonality');
+
+ expect(seasonalityInput).toBeInTheDocument();
+ expect(seasonalityInput).toHaveAttribute('min', '8');
+ expect(seasonalityInput).toHaveAttribute('max', '256');
+ });
+
+ test('displays help text for suggested seasonality', () => {
+ expect(screen.getByText(/consistent seasonal variation of the data/)).toBeInTheDocument();
+ expect(screen.getByText(/suggested seasonality to be in the range of 8 and 256/)).toBeInTheDocument();
+ });
+
+ test('accepts valid seasonality values', async () => {
+ const seasonalityInput = screen.getByTestId('suggestedSeasonality');
+
+ fireEvent.change(seasonalityInput, { target: { value: '24' } });
+ expect((seasonalityInput as HTMLInputElement).value).toBe('24');
+ });
+ });
+
+ describe('Recency emphasis field', () => {
+ beforeEach(() => {
+ renderWithFormik();
+ expandPanel();
+ });
+
+ test('renders recency emphasis field with correct attributes', () => {
+ const recencyInput = screen.getByTestId('recencyEmphasis');
+
+ expect(recencyInput).toBeInTheDocument();
+ expect(recencyInput).toHaveAttribute('min', '1');
+ });
+
+ test('displays detailed help text for recency emphasis', () => {
+ expect(screen.getByText(/window size.*in a classic moving average/)).toBeInTheDocument();
+ expect(screen.getByText(/exponential decay/)).toBeInTheDocument();
+ // Test for the parts separately since they're in different elements
+ expect(screen.getByText(/The default is/)).toBeInTheDocument();
+ expect(screen.getByText('2560')).toBeInTheDocument();
+ });
+
+ test('accepts valid recency emphasis values', async () => {
+ const recencyInput = screen.getByTestId('recencyEmphasis');
+
+ fireEvent.change(recencyInput, { target: { value: '2560' } });
+ expect((recencyInput as HTMLInputElement).value).toBe('2560');
+ });
+ });
+
+ describe('Sparse data handling', () => {
+ beforeEach(() => {
+ renderWithFormik();
+ expandPanel();
+ });
+
+ test('renders sparse data handling dropdown with all options', () => {
+ const dropdown = screen.getByDisplayValue('Ignore missing value');
+
+ expect(dropdown).toBeInTheDocument();
+ expect(screen.getByText('Choose how to handle missing data points.')).toBeInTheDocument();
+ });
+
+ test('shows all sparse data options', () => {
+ const dropdown = screen.getByDisplayValue('Ignore missing value');
+
+ // Check that all options are available in the select
+ expect(dropdown.querySelector('option[value="ignore"]')).toBeInTheDocument();
+ expect(dropdown.querySelector('option[value="previous_value"]')).toBeInTheDocument();
+ expect(dropdown.querySelector('option[value="set_to_zero"]')).toBeInTheDocument();
+ expect(dropdown.querySelector('option[value="custom_value"]')).toBeInTheDocument();
+ });
+
+ test('changes sparse data handling option', () => {
+ const dropdown = screen.getByDisplayValue('Ignore missing value');
+
+ fireEvent.change(dropdown, { target: { value: SparseDataOptionValue.PREVIOUS_VALUE } });
+ expect(dropdown).toHaveValue(SparseDataOptionValue.PREVIOUS_VALUE);
+ });
+
+ test('shows custom value section when custom value is selected', () => {
+ const dropdown = screen.getByDisplayValue('Ignore missing value');
+
+ fireEvent.change(dropdown, { target: { value: SparseDataOptionValue.CUSTOM_VALUE } });
+
+ expect(screen.getByRole('heading', { level: 5, name: 'Custom value' })).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Custom value')).toBeInTheDocument();
+ });
+
+ test('hides custom value section when other options are selected', () => {
+ // First select custom value
+ const dropdown = screen.getByDisplayValue('Ignore missing value');
+ fireEvent.change(dropdown, { target: { value: SparseDataOptionValue.CUSTOM_VALUE } });
+ expect(screen.getByRole('heading', { level: 5, name: 'Custom value' })).toBeInTheDocument();
+
+ // Then select a different option
+ fireEvent.change(dropdown, { target: { value: SparseDataOptionValue.SET_TO_ZERO } });
+ expect(screen.queryByRole('heading', { level: 5, name: 'Custom value' })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Custom value functionality', () => {
+ test('automatically adds custom value field when custom value option is selected', () => {
+ const initialValues = {
+ ...defaultInitialValues,
+ imputationOption: {
+ imputationMethod: SparseDataOptionValue.CUSTOM_VALUE,
+ custom_value: [],
+ },
+ };
+
+ renderWithFormik(initialValues);
+ expandPanel();
+
+ expect(screen.getByRole('heading', { level: 5, name: 'Custom value' })).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Custom value')).toBeInTheDocument();
+ });
+
+ test('custom value input accepts numeric values', async () => {
+ const initialValues = {
+ ...defaultInitialValues,
+ imputationOption: {
+ imputationMethod: SparseDataOptionValue.CUSTOM_VALUE,
+ custom_value: [{ data: '' }],
+ },
+ };
+
+ renderWithFormik(initialValues);
+ expandPanel();
+
+ const customValueInput = screen.getByPlaceholderText('Custom value');
+ fireEvent.change(customValueInput, { target: { value: '42.5' } });
+
+ expect((customValueInput as HTMLInputElement).value).toBe('42.5');
+ });
+
+ test('custom value field is disabled when not editable', () => {
+ const initialValues = {
+ ...defaultInitialValues,
+ imputationOption: {
+ imputationMethod: SparseDataOptionValue.CUSTOM_VALUE,
+ custom_value: [{ data: '' }],
+ },
+ };
+
+ renderWithFormik(initialValues, { isEditable: false });
+ expandPanel();
+
+ const customValueInput = screen.getByPlaceholderText('Custom value');
+ expect(customValueInput).toBeDisabled();
+ });
+ });
+
+ describe('Form integration', () => {
+ test('handles empty string values for numeric fields', () => {
+ renderWithFormik();
+ expandPanel();
+
+ const shingleSizeInput = screen.getByTestId('shingleSize');
+ fireEvent.change(shingleSizeInput, { target: { value: '' } });
+
+ expect((shingleSizeInput as HTMLInputElement).value).toBe('');
+ });
+
+ test('converts string input to numbers for numeric fields', () => {
+ renderWithFormik();
+ expandPanel();
+
+ const shingleSizeInput = screen.getByTestId('shingleSize');
+ fireEvent.change(shingleSizeInput, { target: { value: '16' } });
+
+ expect((shingleSizeInput as HTMLInputElement).value).toBe('16');
+ });
+
+ test('displays initial values correctly', () => {
+ const initialValues = {
+ shingleSize: 16,
+ suggestedSeasonality: 24,
+ recencyEmphasis: 2560,
+ imputationOption: {
+ imputationMethod: SparseDataOptionValue.PREVIOUS_VALUE,
+ custom_value: [],
+ },
+ };
+
+ renderWithFormik(initialValues);
+ expandPanel();
+
+ expect((screen.getByTestId('shingleSize') as HTMLInputElement).value).toBe('16');
+ expect((screen.getByTestId('suggestedSeasonality') as HTMLInputElement).value).toBe('24');
+ expect((screen.getByTestId('recencyEmphasis') as HTMLInputElement).value).toBe('2560');
+ expect(screen.getByDisplayValue('Previous value')).toBeInTheDocument();
+ });
+ });
+
+ describe('Accessibility and UX', () => {
+ test('has proper labels and help text', () => {
+ renderWithFormik();
+ expandPanel();
+
+ // Use getAllByText and target the first occurrence (which should be the field label)
+ expect(screen.getByText('Shingle size')).toBeInTheDocument();
+ expect(screen.getByText('Suggested seasonality')).toBeInTheDocument();
+ expect(screen.getAllByText('Recency emphasis')[0]).toBeInTheDocument();
+ expect(screen.getByText('Sparse data handling')).toBeInTheDocument();
+ });
+
+ test('shows unit badges for numeric fields', () => {
+ renderWithFormik();
+ expandPanel();
+
+ const intervalsBadges = screen.getAllByText('intervals');
+ expect(intervalsBadges).toHaveLength(3); // shingle size, seasonality, recency emphasis
+ });
+
+ test('all fields are properly disabled in non-editable mode', () => {
+ renderWithFormik(defaultInitialValues, { isEditable: false });
+ expandPanel();
+
+ expect(screen.getByTestId('shingleSize')).toBeDisabled();
+ expect(screen.getByTestId('suggestedSeasonality')).toBeDisabled();
+ expect(screen.getByTestId('recencyEmphasis')).toBeDisabled();
+ expect(screen.getByDisplayValue('Ignore missing value')).toBeDisabled();
+ });
+ });
+});
diff --git a/public/pages/ConfigureForecastModel/components/AdvancedSettings/index.ts b/public/pages/ConfigureForecastModel/components/AdvancedSettings/index.ts
new file mode 100644
index 000000000..0e6e7a49e
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/AdvancedSettings/index.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+export { AdvancedSettings } from './AdvancedSettings';
diff --git a/public/pages/ConfigureForecastModel/components/CustomResultIndex/CustomResultIndex.tsx b/public/pages/ConfigureForecastModel/components/CustomResultIndex/CustomResultIndex.tsx
new file mode 100644
index 000000000..e5533c83c
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/CustomResultIndex/CustomResultIndex.tsx
@@ -0,0 +1,334 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import {
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiText,
+ EuiLink,
+ EuiTitle,
+ EuiCompressedFieldText,
+ EuiCallOut,
+ EuiSpacer,
+ EuiCompressedFormRow,
+ EuiCompressedCheckbox,
+ EuiIcon,
+ EuiCompressedFieldNumber,
+} from '@elastic/eui';
+import { Field, FieldProps, FormikProps, useFormikContext } from 'formik';
+import React, { useEffect, useState } from 'react';
+import ContentPanel from '../../../../components/ContentPanel/ContentPanel';
+import { CUSTOM_FORECASTER_RESULT_INDEX_PREFIX } from '../../../../../server/utils/constants';
+import { BASE_DOCS_LINK, FORECASTER_DOCS_LINK } from '../../../../utils/constants';
+import {
+ isInvalid,
+ getError,
+ validateCustomResultIndex,
+ validateEmptyOrPositiveInteger,
+} from '../../../../utils/utils';
+import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow';
+import { ModelConfigurationFormikValues } from '../../models/interfaces';
+import { DetailsFormikValues } from '../../../ForecastDetail/models/interface';
+import { get } from 'lodash';
+
+interface CustomResultIndexProps {
+ useDefaultResultIndex?: boolean;
+ resultIndex?: string;
+ formikProps: FormikProps | FormikProps;
+ readOnly?: boolean;
+}
+
+function CustomResultIndex(props: CustomResultIndexProps) {
+ const [enabled, setEnabled] = useState(!!props.resultIndex);
+ const [customResultIndexConditionsEnabled, setCustomResultIndexConditionsEnabled] = useState(true);
+ const customResultIndexMinAge = get(props.formikProps, 'values.resultIndexMinAge');
+ const customResultIndexMinSize = get(props.formikProps, 'values.resultIndexMinSize');
+ const customResultIndexTTL = get(props.formikProps, 'values.resultIndexTtl');
+ const { setFieldValue } = useFormikContext();
+
+ useEffect(() => {
+ if (customResultIndexMinAge === undefined && customResultIndexMinSize === undefined && customResultIndexTTL === undefined) {
+ setCustomResultIndexConditionsEnabled(false);
+ }
+
+ if (!customResultIndexConditionsEnabled) {
+ setFieldValue('resultIndexMinAge', '');
+ setFieldValue('resultIndexMinSize', '');
+ setFieldValue('resultIndexTtl', '');
+ }
+ },[customResultIndexConditionsEnabled])
+
+ const hintTextStyle = {
+ color: '#69707d',
+ fontSize: '12px',
+ lineHeight: '16px',
+ fontWeight: 'normal',
+ fontFamily: 'Helvetica, sans-serif',
+ textAlign: 'left',
+ // Ensures long text wraps within the container and doesn't overflow
+ wordWrap: 'break-word',
+ maxWidth: '400px',
+};
+
+ return (
+
+ Custom result index
+
+ }
+ subTitle={
+
+ Store forecaster results to your own index.{' '}
+
+ Learn more
+
+
+ }
+ >
+
+ {({ field, form }: FieldProps) => (
+
+
+ {
+ if (!props.readOnly) {
+ if (enabled) {
+ form.setFieldValue('resultIndex', undefined);
+ }
+ setEnabled(!enabled);
+ }
+ }}
+ />
+
+
+
+ {/* Unlike anomaly detection, forecasting allows editing of all fields after creation */}
+
+ {enabled ? (
+
+
+ {
+ const userInput = e.target.value;
+ const fullValue = CUSTOM_FORECASTER_RESULT_INDEX_PREFIX + userInput;
+ form.setFieldValue(field.name, fullValue);
+ }}
+ />
+
+
+ ) : null}
+
+ )}
+
+
+
+
+ { enabled ? (
+
+ {({ field, form }: FieldProps) => (
+
+
+
+ Flattening the custom result index will make it easier to query them on the dashboard. It also allows you to perform term aggregations on categorical fields.
+
+
+ )}
+ ) : null}
+
+
+ {enabled ? (
+
+ {
+ if (!props.readOnly) {
+ setCustomResultIndexConditionsEnabled(!customResultIndexConditionsEnabled);
+ }
+ }}
+ />
+
+ ) : null}
+
+
+
+ { (enabled && customResultIndexConditionsEnabled) ? (
+ {({ field, form }: FieldProps) => (
+
+
+
+ Min Index Age - optional
+
+ }
+ hint={[
+ `This setting would define a specific threshold for the age of an index. When this threshold is surpassed, a rollover will be triggered automatically.`,
+ ]}
+ isInvalid={isInvalid(field.name, form)}
+ error={getError(field.name, form)}
+ >
+
+
+
+
+
+
+ days
+
+
+
+
+
+
+ )}
+ ) : null}
+
+ {(enabled && customResultIndexConditionsEnabled) ? (
+ {({ field, form }: FieldProps) => (
+
+
+
+ Min Index Size - optional
+
+ }
+ hint={[
+ `This setting would define a specific threshold for the size of an index. When this threshold is surpassed, a rollover will be triggered automatically.`,
+ ]}
+ isInvalid={isInvalid(field.name, form)}
+ error={getError(field.name, form)}
+ >
+
+
+
+
+
+
+ MB
+
+
+
+
+
+
+ )}
+ ) : null}
+
+ {(enabled && customResultIndexConditionsEnabled) ? (
+ {({ field, form }: FieldProps) => (
+
+
+
+ Index TTL - optional
+
+ }
+ hint={[
+ `This setting would define the duration after which an index is considered expired and eligible for deletion.`,
+ ]}
+ isInvalid={isInvalid(field.name, form)}
+ error={getError(field.name, form)}
+ >
+
+
+
+
+
+
+ days
+
+
+
+
+
+
+ )}
+ ) : null}
+
+ );
+}
+
+export default CustomResultIndex;
diff --git a/public/pages/ConfigureForecastModel/components/CustomResultIndex/index.ts b/public/pages/ConfigureForecastModel/components/CustomResultIndex/index.ts
new file mode 100644
index 000000000..0183cb7ab
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/CustomResultIndex/index.ts
@@ -0,0 +1,14 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import CustomResultIndex from './CustomResultIndex';
+
+export { CustomResultIndex };
diff --git a/public/pages/ConfigureForecastModel/components/Settings/Settings.tsx b/public/pages/ConfigureForecastModel/components/Settings/Settings.tsx
new file mode 100644
index 000000000..0fb6e9238
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/Settings/Settings.tsx
@@ -0,0 +1,338 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import React, { Fragment, useEffect, useRef, useState } from 'react';
+import { Field, FieldProps, FormikProps } from 'formik';
+import {
+ EuiSpacer,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiBadge,
+ EuiCompressedFieldNumber,
+ EuiRange,
+ EuiText,
+ EuiPanel,
+} from '@elastic/eui';
+import {
+ isInvalid,
+ getError,
+ validatePositiveInteger,
+ validateNonNegativeInteger,
+} from '../../../../utils/utils';
+import { FORECASTER_DOCS_LINK, FIELD_MAX_WIDTH, INPUT_SLIDER_WIDTH } from '../../../../utils/constants';
+import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow';
+import '../../index.scss';
+import { toNumberOrEmpty } from '../../utils/helpers';
+
+interface SettingsProps {
+ isEditable?: boolean;
+}
+
+export const Settings = ({ isEditable = true }: SettingsProps) => {
+ return (
+
+ {/* FORECASTING INTERVAL */}
+
+ {({ field, form }: FieldProps) => {
+ const value = field.value ?? '';
+ return (
+
+
+
+
+ {
+ form.setFieldValue(field.name, toNumberOrEmpty(e.target.value));
+ }}
+ />
+
+
+
+
+ minutes
+
+
+
+
+ );
+ }}
+
+
+
+
+ {/* WINDOW DELAY */}
+
+ {({ field, form }: FieldProps) => {
+ const value = field.value ?? '';
+ return (
+
+
+ {/* grow={false} in both the numeric field and unitprevents horizontal expansion,
+ keeping the numeric field at its natural width and allows the unit badge to sit directly next to it.
+ Otherwise, there is a gap between the numeric field and the unit badge. */}
+
+
+ {
+ form.setFieldValue(field.name, toNumberOrEmpty(e.target.value));
+ }}
+ />
+
+
+
+
+ minutes
+
+
+
+
+ );
+ }}
+
+
+
+
+
+ {({ field, form }: FieldProps) => {
+ const [showHorizonDetails, setShowHorizonDetails] = useState(false);
+
+ const interval = (form as FormikProps).values.interval;
+ const horizon = field.value ?? 0;
+ const value = field.value ?? '';
+
+ // Ref to the SLIDER area; if a click happens outside this ref, we close the slider
+ const sliderPanelRef = useRef(null);
+
+ // UseEffect that only attaches the "outside click" listener when the slider is open
+ useEffect(() => {
+ if (!showHorizonDetails) return;
+
+ function handleOutsideClick(e: MouseEvent) {
+ // If the click is not inside sliderPanelRef => close slider
+ if (
+ sliderPanelRef.current &&
+ !sliderPanelRef.current.contains(e.target as Node)
+ ) {
+ setShowHorizonDetails(false);
+ }
+ }
+
+ // Note we use capture = true to catch the event early. Could also omit if it works.
+ document.addEventListener('mousedown', handleOutsideClick, true);
+ return () => {
+ document.removeEventListener('mousedown', handleOutsideClick, true);
+ };
+ }, [showHorizonDetails]);
+
+ // 2) If user moves slider => update field
+ function onSliderChange(newVal: number) {
+ form.setFieldValue(field.name, newVal);
+ }
+
+ // For help text
+ function getHorizonHelpText(h: number, i: number) {
+ if (!i || !h) return 'A valid horizon is between 1 and 180.';
+ const totalMinutes = h * i;
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ return (
+ `${h} intervals = ${hours} hour${hours === 1 ? '' : 's'}${minutes ? ` ${minutes} minute${minutes === 1 ? '' : 's'}` : ''
+ } if the forecasting interval is ${i} minutes. A valid horizon is between 1 and 180.`
+ );
+ }
+
+ return (
+
+ {/*
+ Constrain the entire row for consistency with other fields.
+ The numeric field + badge is outside the slider ref.
+ */}
+
+
+
+ {/* 3) onMouseDown to open the slider, with e.stopPropagation() */}
+
+ {
+ // Prevent this click from bubbling to outside-click handler
+ e.stopPropagation();
+ setShowHorizonDetails(true);
+ }}
+ onChange={(e) => {
+ form.setFieldValue(field.name, toNumberOrEmpty(e.target.value));
+ }}
+ />
+
+
+
+
+ intervals
+
+
+
+
+ {/*
+ Conditionally render the slider in a ref-wrapped div
+ so that clicks inside don't close the slider.
+ */}
+ {showHorizonDetails && (
+ <>
+
+
+
+ onSliderChange(Number(e.currentTarget.value))}
+ showLabels
+ showRange
+ levels={[
+ { min: 1, max: 40, color: 'success' },
+ { min: 40, max: 180, color: 'warning' },
+ ]}
+ aria-label="Horizon slider"
+ />
+
+
+ Recommended horizon is up to 40 intervals.
+
+
+
+ >
+ )}
+
+
+ );
+ }}
+
+
+
+
+ {/* HISTORY */}
+
+ {({ field, form }: FieldProps) => {
+ const interval = (form as FormikProps).values.interval;
+ const value = field.value ?? '';
+
+ function getHistoryHelpText(history: number, intv: number) {
+ if (!intv || !history) return 'Minimum history: 40 intervals.';
+ const totalMinutes = history * intv;
+ const hours = Math.floor(totalMinutes / 60);
+ const mins = totalMinutes % 60;
+ return `${history} intervals = ${hours} hour${
+ hours === 1 ? '' : 's'
+ }${
+ mins ? ` ${mins} minute${mins === 1 ? '' : 's'}` : ''
+ } if the forecasting interval is ${intv} minutes. Minimum history: 40 intervals.`;
+ }
+
+ return (
+
+ {/* gutterSize="none" ensures there's no default spacing between flex items */}
+
+
+
+
+ {
+ form.setFieldValue(field.name, toNumberOrEmpty(e.target.value));
+ }}
+ />
+
+
+
+
+ intervals
+
+
+
+
+ );
+ }}
+
+
+ );
+};
diff --git a/public/pages/ConfigureForecastModel/components/Settings/__tests__/Settings.test.tsx b/public/pages/ConfigureForecastModel/components/Settings/__tests__/Settings.test.tsx
new file mode 100644
index 000000000..e5a18028a
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/Settings/__tests__/Settings.test.tsx
@@ -0,0 +1,117 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import React from 'react';
+import { render, fireEvent, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Settings } from '../Settings';
+
+import { Formik } from 'formik';
+
+describe(' spec', () => {
+ test('renders the component', () => {
+ const { container } = render(
+
+ {() => (
+
+
+
+ )}
+
+ );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+ test('shows error for empty interval when toggling focus/blur', async () => {
+ const { queryByText, findByText } = render(
+
+ {() => (
+
+
+
+ )}
+
+ );
+ expect(queryByText('Required')).toBeNull();
+ const intervalRow = screen.getByTitle('Forecasting interval');
+ const intervalInput = within(intervalRow).getByRole('spinbutton');
+ fireEvent.focus(intervalInput);
+ fireEvent.blur(intervalInput);
+ expect(findByText('Required')).not.toBeNull();
+ });
+ test('shows error for invalid interval when toggling focus/blur', async () => {
+ const { queryByText, findByText } = render(
+
+ {() => (
+
+
+
+ )}
+
+ );
+ expect(queryByText('Required')).toBeNull();
+ const intervalRow = screen.getByTitle('Forecasting interval');
+ const intervalInput = within(intervalRow).getByRole('spinbutton');
+ userEvent.type(intervalInput, '-1');
+ fireEvent.blur(intervalInput);
+ expect(findByText('Must be a positive integer')).not.toBeNull();
+ });
+ test('shows error for interval of 0 when toggling focus/blur', async () => {
+ const { queryByText, findByText } = render(
+
+ {() => (
+
+
+
+ )}
+
+ );
+ expect(queryByText('Required')).toBeNull();
+ const intervalRow = screen.getByTitle('Forecasting interval');
+ const intervalInput = within(intervalRow).getByRole('spinbutton');
+ userEvent.type(intervalInput, '0');
+ fireEvent.blur(intervalInput);
+ expect(findByText('Must be a positive integer')).not.toBeNull();
+ });
+ test('shows error for empty window delay when toggling focus/blur', async () => {
+ const { queryByText, findByText } = render(
+
+ {() => (
+
+
+
+ )}
+
+ );
+ expect(queryByText('Required')).toBeNull();
+ const windowDelayRow = screen.getByTitle('Window delay');
+ const windowDelayInput = within(windowDelayRow).getByRole('spinbutton');
+ fireEvent.focus(windowDelayInput);
+ fireEvent.blur(windowDelayInput);
+ expect(findByText('Required')).not.toBeNull();
+ });
+ test('shows error for invalid window delay when toggling focus/blur', async () => {
+ const { queryByText, findByText } = render(
+
+ {() => (
+
+
+
+ )}
+
+ );
+ expect(queryByText('Required')).toBeNull();
+ const windowDelayRow = screen.getByTitle('Window delay');
+ const windowDelayInput = within(windowDelayRow).getByRole('spinbutton');
+ userEvent.type(windowDelayInput, '-1');
+ fireEvent.blur(windowDelayInput);
+ expect(findByText('Must be a non-negative integer')).not.toBeNull();
+ });
+});
diff --git a/public/pages/ConfigureForecastModel/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap b/public/pages/ConfigureForecastModel/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap
new file mode 100644
index 000000000..52cffd092
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap
@@ -0,0 +1,494 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` spec renders the component 1`] = `
+
+`;
diff --git a/public/pages/ConfigureForecastModel/components/Settings/index.ts b/public/pages/ConfigureForecastModel/components/Settings/index.ts
new file mode 100644
index 000000000..210e06abb
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/Settings/index.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+export { Settings } from './Settings';
diff --git a/public/pages/ConfigureForecastModel/components/StorageSettings/StorageSettings.tsx b/public/pages/ConfigureForecastModel/components/StorageSettings/StorageSettings.tsx
new file mode 100644
index 000000000..9fc83af9f
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/StorageSettings/StorageSettings.tsx
@@ -0,0 +1,129 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+import React from 'react';
+import {
+ EuiPanel,
+ EuiRadio,
+ EuiText,
+ EuiSpacer,
+ EuiFlexGroup,
+ EuiFlexItem,
+} from '@elastic/eui';
+import { FormikProps } from 'formik';
+import { get } from 'lodash';
+import { CustomResultIndex } from '../CustomResultIndex';
+import { ModelConfigurationFormikValues } from '../../models/interfaces';
+import { DetailsFormikValues } from '../../../ForecastDetail/models/interface';
+
+interface StorageSettingsProps {
+ formikProps: FormikProps | FormikProps;
+ isEditable: boolean;
+ omitTitle?: boolean;
+}
+
+export function StorageSettings(props: StorageSettingsProps) {
+ const { formikProps, isEditable, omitTitle = false } = props;
+ const { values, setFieldValue } = formikProps;
+
+ // If resultIndex is empty, user is on "Default index"; otherwise "Custom index."
+ const selected = values.resultIndex ? 'custom' : 'default';
+
+ /**
+ * Let the user toggle between default and custom by setting/clearing
+ * resultIndex. If you leave resultIndex empty when user picks custom,
+ * you'll never switch off "Default index."
+ */
+ const onChangeRadio = (option: 'default' | 'custom') => {
+ if (option === 'default') {
+ // Clear out resultIndex => "Default index" is selected
+ // Setting resultIndex to empty string would cause backend validation error
+ // as the index name must start with a predefined prefix. Use undefined to indicate
+ // default index selection.
+ setFieldValue('resultIndex', undefined);
+ } else {
+ // Set a placeholder if needed => "Custom index" is selected
+ // The user can then override it in the field
+ if (!values.resultIndex) {
+ setFieldValue('resultIndex', 'my_custom_forecast_index');
+ }
+ }
+ };
+
+ return (
+
+ {!omitTitle && (
+ <>
+
+ Storage
+
+ Define how to store and manage forecasting results.
+
+
+
+ >
+ )}
+
+ {/* Two panels side by side for Default vs. Custom */}
+
+
+
+ Default index}
+ checked={selected === 'default'}
+ onChange={() => onChangeRadio('default')}
+ // FIXME: EuiRadio doesn't support readOnly prop. readOnly would be preferred as disabled makes the radio look gray.
+ // Consider creating a custom radio component that supports readOnly if this styling is important.
+ disabled={!isEditable}
+ />
+
+
+
+ The forecasting results are retained automatically
+ for at least 30 days.
+
+
+
+
+
+
+
+ Custom index}
+ checked={selected === 'custom'}
+ onChange={() => onChangeRadio('custom')}
+ // FIXME: EuiRadio doesn't support readOnly prop. readOnly would be preferred as disabled makes the radio look gray.
+ // Consider creating a custom radio component that supports readOnly if this styling is important.
+ disabled={!isEditable}
+ />
+
+
+
+ Route forecast results to your custom index. In a custom index,
+ you set the retention period and resource allocation.
+
+
+
+
+
+
+
+
+ {/* If resultIndex is non-empty => user selected "Custom index," so show the custom index field */}
+ {values.resultIndex && (
+
+ )}
+
+ );
+}
diff --git a/public/pages/ConfigureForecastModel/components/StorageSettings/index.ts b/public/pages/ConfigureForecastModel/components/StorageSettings/index.ts
new file mode 100644
index 000000000..db1ebe72d
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/StorageSettings/index.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+export { StorageSettings } from './StorageSettings';
\ No newline at end of file
diff --git a/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/SuggestParametersDialog.tsx b/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/SuggestParametersDialog.tsx
new file mode 100644
index 000000000..eb083c4c0
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/SuggestParametersDialog.tsx
@@ -0,0 +1,311 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import React, { useState } from 'react';
+import {
+ EuiModal,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiSmallButton,
+ EuiSmallButtonEmpty,
+ EuiFormRow,
+ EuiFieldNumber,
+ EuiRadioGroup,
+ EuiAccordion,
+ EuiText,
+ EuiCallOut,
+ EuiLoadingSpinner,
+ EuiSpacer,
+} from '@elastic/eui';
+import { useDispatch } from 'react-redux';
+import { FormikProps } from 'formik';
+
+import { suggestForecaster } from '../../../../redux/reducers/forecast';
+import { ForecasterDefinitionFormikValues } from '../../../DefineForecaster/models/interfaces';
+import { formikToForecasterForSuggestion } from '../../../DefineForecaster/utils/helpers';
+import { Forecaster } from '../../../../models/interfaces';
+import { ModelConfigurationFormikValues } from '../../models/interfaces';
+
+interface SuggestParametersDialogProps {
+ onClose: () => void;
+ dataSourceId: string;
+ forecasterDefinitionValues: ForecasterDefinitionFormikValues;
+ formikProps: FormikProps;
+}
+
+type DialogStep = 'config' | 'loading' | 'error' | 'result';
+
+export const SuggestParametersDialog: React.FC = ({
+ onClose,
+ dataSourceId,
+ forecasterDefinitionValues,
+ formikProps, // destructure the formik bag
+}) => {
+ const dispatch = useDispatch();
+
+ const [suggestMode, setSuggestMode] = useState<'all' | 'provided'>('all');
+ const [providedInterval, setProvidedInterval] = useState(15);
+ const [shingleSize, setShingleSize] = useState(8);
+ const [step, setStep] = useState('config');
+ const [errorMsg, setErrorMsg] = useState();
+
+ const [suggestedInterval, setSuggestedInterval] = useState();
+ const [suggestedHorizon, setSuggestedHorizon] = useState();
+ const [suggestedHistory, setSuggestedHistory] = useState();
+ const [suggestedWindowDelay, setSuggestedWindowDelay] = useState();
+
+ const radioOptions = [
+ { id: 'all', label: 'Suggest interval, window delay, horizon, and history' },
+ { id: 'provided', label: 'Suggest window delay, horizon, and history for the provided interval' },
+ ];
+
+ const onChangeRadio = (optionId: string) => {
+ setSuggestMode(optionId as 'all' | 'provided');
+ };
+
+ const onGenerateSuggestions = async () => {
+ setStep('loading');
+ setErrorMsg(undefined);
+
+ const intervalForForecaster =
+ suggestMode === 'all' ? undefined : providedInterval;
+
+ const forecasterBody: Forecaster = formikToForecasterForSuggestion(
+ forecasterDefinitionValues,
+ intervalForForecaster,
+ shingleSize
+ );
+
+ const suggestionParams =
+ suggestMode === 'all'
+ ? 'forecast_interval,history,horizon,window_delay'
+ : 'history,horizon,window_delay';
+
+ try {
+ const resp: any = await dispatch(
+ suggestForecaster(forecasterBody, suggestionParams, dataSourceId) as any
+ );
+
+ if (!resp || !resp.response) {
+ setErrorMsg('Empty response from suggestForecaster.');
+ setStep('error');
+ } else if (resp.response.exception) {
+ setErrorMsg(resp.response.exception);
+ setStep('error');
+ } else {
+ // Success: store suggested values in local state
+ const intervalVal = resp.response?.interval?.period?.interval;
+ setSuggestedInterval(typeof intervalVal === 'number' ? intervalVal : undefined);
+ setSuggestedHorizon(resp.response.horizon);
+ setSuggestedHistory(resp.response.history);
+ const wDelay = resp.response.windowDelay?.period?.interval;
+ setSuggestedWindowDelay(wDelay);
+
+ setStep('result');
+ }
+ } catch (err: any) {
+ setErrorMsg(err?.message || 'Error occurred while suggesting parameters');
+ setStep('error');
+ }
+ };
+
+ // The key part: update form fields
+ const onUseSuggestedParams = () => {
+ if (suggestedInterval != null) {
+ formikProps.setFieldValue('interval', suggestedInterval);
+ }
+ if (suggestedHorizon != null) {
+ formikProps.setFieldValue('horizon', suggestedHorizon);
+ }
+ if (suggestedHistory != null) {
+ formikProps.setFieldValue('history', suggestedHistory);
+ }
+ if (suggestedWindowDelay != null) {
+ formikProps.setFieldValue('windowDelay', suggestedWindowDelay);
+ }
+ onClose();
+ };
+
+ const onBackToConfig = () => {
+ setStep('config');
+ };
+
+ const isConfig = step === 'config';
+ const isLoading = step === 'loading';
+ const isError = step === 'error';
+ const isResult = step === 'result';
+
+ return (
+
+
+
+ Suggest parameters
+
+
+
+
+ {isConfig && (
+ <>
+
+
+ Based on your data source and advanced parameters, OpenSearch can
+ recommend core parameters for the model.
+
+
+
+
+
+
+
+
+ {suggestMode === 'provided' && (
+
+ setProvidedInterval(Number(e.target.value))}
+ append="minutes"
+ />
+
+ )}
+
+
+
+
+
+ setShingleSize(Number(e.target.value))}
+ min={1}
+ max={128}
+ />
+
+
+ >
+ )}
+
+ {isLoading && (
+ <>
+
+
+ Calculating model parameters...
+ The calculation might take a few minutes. Do not close this window.
+
+ >
+ )}
+
+ {isError && (
+
+ {errorMsg}
+
+ )}
+
+ {isResult && (
+ <>
+
+ Based on your inputs, the suggested parameters are:
+
+ {suggestedInterval !== undefined ? (
+ • Interval: {suggestedInterval} minutes
+ ) : (
+ • Interval: Unable to determine a suitable interval
+ )}
+
+ {suggestedHorizon !== undefined ? (
+
+ • Horizon: {suggestedHorizon} intervals
+ {suggestedInterval && (
+ <> (
+ {Math.floor((suggestedHorizon * suggestedInterval) / 60)} hours{' '}
+ {(suggestedHorizon * suggestedInterval) % 60} minutes)
+ >
+ )}
+
+
+ ) : (
+ • Horizon: Unable to determine a suitable horizon
+ )}
+
+ {suggestedHistory !== undefined ? (
+
+ • History: {suggestedHistory} intervals
+ {suggestedInterval && (
+ <> (
+ {Math.floor((suggestedHistory * suggestedInterval) / 60)} hours{' '}
+ {(suggestedHistory * suggestedInterval) % 60} minutes)
+ >
+ )}
+
+
+ ) : (
+ • History: Unable to determine a suitable history window
+ )}
+
+ {suggestedWindowDelay !== undefined ? (
+ • Window delay: {suggestedWindowDelay} minutes
+ ) : (
+ • Window delay: Unable to determine a suitable delay
+ )}
+
+
+ >
+ )}
+
+
+
+ Cancel
+
+ {isConfig && (
+
+ Generate suggestions
+
+ )}
+
+ {(isLoading || isError || isResult) && (
+
+ Back
+
+ )}
+
+ {isResult && (
+
+ Use suggested parameters
+
+ )}
+
+
+ );
+};
diff --git a/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/index.ts b/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/index.ts
new file mode 100644
index 000000000..22e96ee97
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/index.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+export { SuggestParametersDialog } from './SuggestParametersDialog';
\ No newline at end of file
diff --git a/public/pages/ConfigureForecastModel/components/ValidationCallout/ValidationCallout.tsx b/public/pages/ConfigureForecastModel/components/ValidationCallout/ValidationCallout.tsx
new file mode 100644
index 000000000..5c88206ff
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/ValidationCallout/ValidationCallout.tsx
@@ -0,0 +1,41 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import React from 'react';
+import {
+ EuiCallOut,
+} from '@elastic/eui';
+
+interface ValidationCalloutProps {
+ isLoading?: boolean;
+ validationResponse?: string;
+}
+
+export const ValidationCallout = ({
+ isLoading,
+ validationResponse,
+}: ValidationCalloutProps) => {
+ if (validationResponse && !isLoading) {
+ return (
+
+ {validationResponse}
+
+ );
+ }
+
+ return null;
+};
\ No newline at end of file
diff --git a/public/pages/ConfigureForecastModel/components/ValidationCallout/index.ts b/public/pages/ConfigureForecastModel/components/ValidationCallout/index.ts
new file mode 100644
index 000000000..2bc32a8f1
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/components/ValidationCallout/index.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+export { ValidationCallout } from './ValidationCallout';
\ No newline at end of file
diff --git a/public/pages/ConfigureForecastModel/containers/ConfigureForecastModel.tsx b/public/pages/ConfigureForecastModel/containers/ConfigureForecastModel.tsx
new file mode 100644
index 000000000..5f00cfe17
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/containers/ConfigureForecastModel.tsx
@@ -0,0 +1,528 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import {
+ EuiPageBody,
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiPage,
+ EuiSmallButton,
+ EuiSmallButtonEmpty,
+ EuiSpacer,
+ EuiText,
+ EuiBottomBar,
+} from '@elastic/eui';
+import { Formik, FormikHelpers } from 'formik';
+import { get, isEmpty } from 'lodash';
+import React, { Fragment, useState, useEffect, ReactElement, useRef } from 'react';
+import { useDispatch } from 'react-redux';
+import { RouteComponentProps, useLocation } from 'react-router-dom';
+import { useFetchForecasterInfo } from '../../CreateForecasterSteps/hooks/useFetchForecasterInfo';
+import {
+ BREADCRUMBS,
+ MDS_BREADCRUMBS,
+ FORECASTER_INSUFFICIENT_DATA_MESSAGE,
+ FORECASTER_VALIDATION_ERROR_MESSAGE,
+ FORECASTER_EMPTY_DATA_IDENTIFIER,
+} from '../../../utils/constants';
+import { useHideSideNavBar } from '../../main/hooks/useHideSideNavBar';
+import { suggestForecaster, updateForecaster, createForecaster, getForecasterCount, testForecaster, getForecaster } from '../../../redux/reducers/forecast';
+import {
+ modelConfigurationToFormik,
+ focusOnImputationOption,
+} from '../utils/helpers';
+import { formikToModelConfiguration } from '../utils/helpers';
+import { AdvancedSettings } from '../components/AdvancedSettings';
+import { CoreStart, MountPoint } from '../../../../../../src/core/public';
+import { CoreServicesContext } from '../../../components/CoreServices/CoreServices';
+import { prettifyErrorMessage } from '../../../../server/utils/helpers';
+import {
+ ModelConfigurationFormikValues,
+} from '../models/interfaces';
+import { isActiveState, MAX_FORECASTER } from '../../../../server/utils/constants';
+import { getErrorMessage } from '../../../utils/utils';
+import {
+ constructHrefWithDataSourceId,
+ getDataSourceFromURL,
+} from '../../utils/helpers';
+import {
+ getDataSourceManagementPlugin,
+ getDataSourceEnabled,
+ getNotifications,
+ getSavedObjectsClient,
+} from '../../../services';
+import { DataSourceViewConfig } from '../../../../../../src/plugins/data_source_management/public';
+import { SparseDataOptionValue } from '../utils/constants';
+import { Settings } from '../components/Settings';
+import { ForecasterDefinitionFormikValues } from '../../DefineForecaster/models/interfaces';
+import { StorageSettings } from '../components/StorageSettings';
+import { formikToForecaster, formikToForecasterDefinition } from '../../ReviewAndCreate/utils/helpers';
+import { SuggestParametersDialog } from '../components/SuggestParametersDialog/SuggestParametersDialog';
+import { ValidationCallout } from '../components/ValidationCallout/ValidationCallout';
+import { DEFAULT_SHINGLE_SIZE, DEFAULT_OUTPUT_AFTER } from '../../../utils/constants'
+
+
+interface ConfigureForecastModelRouterProps {
+ forecasterId?: string;
+}
+
+interface ConfigureForecastModelProps
+ extends RouteComponentProps {
+ setStep?(stepNumber: number): void;
+ initialValues?: ModelConfigurationFormikValues;
+ setInitialValues?(initialValues: ModelConfigurationFormikValues): void;
+ forecasterDefinitionValues: ForecasterDefinitionFormikValues;
+ setActionMenu: (menuMount: MountPoint | undefined) => void;
+}
+
+export function ConfigureForecastModel(props: ConfigureForecastModelProps) {
+ const core = React.useContext(CoreServicesContext) as CoreStart;
+ const dispatch = useDispatch();
+ const location = useLocation();
+ const MDSQueryParams = getDataSourceFromURL(location);
+ const dataSourceEnabled = getDataSourceEnabled().enabled;
+ const dataSourceId = MDSQueryParams.dataSourceId;
+
+ useHideSideNavBar(true, false);
+ const forecasterId = get(props, 'match.params.forecasterId', '');
+ const { forecaster, hasError } = useFetchForecasterInfo(forecasterId, dataSourceId)
+
+ // Jump to top of page on first load
+ useEffect(() => {
+ scroll(0, 0);
+ }, []);
+
+ useEffect(() => {
+ if (dataSourceEnabled) {
+ core.chrome.setBreadcrumbs([
+ MDS_BREADCRUMBS.FORECASTING(dataSourceId),
+ MDS_BREADCRUMBS.CREATE_FORECASTER,
+ ]);
+ } else {
+ core.chrome.setBreadcrumbs([
+ BREADCRUMBS.FORECASTING,
+ BREADCRUMBS.CREATE_FORECASTER,
+ ]);
+ }
+ }, [forecaster]);
+
+ // If there's an error fetching the forecaster while editing a forecaster
+ // (e.g., forecaster doesn't exist), redirect to the forecaster list page
+ useEffect(() => {
+ if (hasError) {
+ if (dataSourceEnabled) {
+ props.history.push(
+ constructHrefWithDataSourceId('/forecasters', dataSourceId, false)
+ );
+ } else {
+ props.history.push('/forecasters');
+ }
+ }
+ }, [hasError]);
+
+ // This variable indicates if validate API declared forecaster definition settings as valid
+ const [forecasterDefinitionMessageResponse, setForecasterDefinitionMessageResponse] =
+ useState('');
+
+ // This hook only gets called once as the page is rendered, sending a request to
+ // Forecast suggest API with the forecast values. This will either return suggest
+ // parameters or suggest has failed and callouts displaying what the issue is
+ // will be displayed instead.
+ useEffect(() => {
+ if (!props.forecasterDefinitionValues) {
+ return; // Early return if no values
+ }
+
+ // When suggest API fails to find an interval, it throws a runtime exception with message:
+ // {"error":{"root_cause":[{"type":"runtime_exception","reason":"Fail to suggest parameters for null Exceptions: [Empty data. Cannot find a good interval.]"}],"type":"runtime_exception","reason":"Fail to suggest parameters for null Exceptions: [Empty data. Cannot find a good interval.]"},"status":500}
+ dispatch(
+ suggestForecaster(
+ formikToForecasterDefinition(props.forecasterDefinitionValues),
+ 'forecast_interval',
+ dataSourceId
+ )
+ )
+ .then((resp: any) => {
+ if (isEmpty(Object.keys(resp.response)) || !resp.response.hasOwnProperty('interval')) {
+ setForecasterDefinitionMessageResponse(FORECASTER_INSUFFICIENT_DATA_MESSAGE);
+ }
+ })
+ .catch((err: any) => {
+ const errorMessage = getErrorMessage(err, FORECASTER_VALIDATION_ERROR_MESSAGE);
+
+ if (errorMessage.includes(FORECASTER_EMPTY_DATA_IDENTIFIER)) {
+ setForecasterDefinitionMessageResponse(FORECASTER_INSUFFICIENT_DATA_MESSAGE);
+ } else {
+ setForecasterDefinitionMessageResponse(errorMessage);
+ }
+ });
+ }, []);
+
+
+ // Using useRef instead of useState since useState updates state asynchronously,
+ // but we need the most up-to-date value immediately in handleCreateForecaster
+ // to determine whether to run the test after creation
+ const shouldRunOnceRef = useRef(false);
+
+ const validateImputationOption = (
+ formikValues: ModelConfigurationFormikValues,
+ errors: any
+ ) => {
+ const imputationOption = get(formikValues, 'imputationOption', null);
+
+
+ // Validate imputationOption when method is CUSTOM_VALUE
+ // No need to lidate that the custom value's feature name matches formikValues.featureName as we only have one feature
+ if (imputationOption && imputationOption.imputationMethod === SparseDataOptionValue.CUSTOM_VALUE) {
+ // Validate that the number of custom values matches the number of enabled features
+ if ((imputationOption.custom_value || []).length !== 1) {
+ errors.custom_value =
+ `The number of custom values (${(imputationOption.custom_value || []).length}) should be 1.`;
+ }
+ }
+ };
+
+ const handleFormValidation = async (
+ values: ModelConfigurationFormikValues,
+ formikHelpers: FormikHelpers,
+ ) => {
+ formikHelpers.setSubmitting(true);
+ formikHelpers.setFieldTouched('shingleSize');
+ formikHelpers.setFieldTouched('imputationOption');
+ formikHelpers.setFieldTouched('interval');
+ formikHelpers.setFieldTouched('windowDelay');
+ formikHelpers.setFieldTouched('suggestedSeasonality');
+ formikHelpers.setFieldTouched('recencyEmphasis');
+ formikHelpers.setFieldTouched('resultIndex');
+ formikHelpers.setFieldTouched('resultIndexMinAge');
+ formikHelpers.setFieldTouched('resultIndexMinSize');
+ formikHelpers.setFieldTouched('resultIndexTtl');
+ formikHelpers.setFieldTouched('flattenCustomResultIndex');
+
+ formikHelpers.validateForm().then((errors) => {
+ // Call the extracted validation method
+ validateImputationOption(values, errors);
+
+ if (isEmpty(errors)) {
+
+ handleCreateForecaster(values, formikHelpers);
+ } else {
+ const customValueError = get(errors, 'custom_value')
+ if (customValueError) {
+ core.notifications.toasts.addDanger(
+ customValueError
+ );
+ focusOnImputationOption();
+ return;
+ }
+
+ core.notifications.toasts.addDanger(
+ 'One or more input fields is invalid'
+ );
+ }
+ });
+
+ formikHelpers.setSubmitting(false);
+ };
+
+ const handleCreateForecaster = async (values: ModelConfigurationFormikValues,
+ formikHelpers: FormikHelpers,
+ ) => {
+ try {
+ const forecasterFormikValues = {
+ ...values,
+ ...props.forecasterDefinitionValues,
+ }
+ formikHelpers.setSubmitting(true);
+ const forecasterToCreate = formikToForecaster(forecasterFormikValues);
+
+ // interval is in minutes
+ const forecastInterval = values.interval || 0;
+ const history = values.history || 0;
+ let requiredConsecutiveIntervals;
+ if (forecastInterval > 0) {
+ const shingleSize = values.shingleSize ? values.shingleSize : DEFAULT_SHINGLE_SIZE;
+ requiredConsecutiveIntervals = history > 0 ? history : (DEFAULT_OUTPUT_AFTER + shingleSize);
+ }
+
+ // Decide subtitle text
+ const subTitle = shouldRunOnceRef.current
+ ? `The test is initializing with historical data. This may take approximately 1–2 minutes if your data covers each of the last ${requiredConsecutiveIntervals} consecutive intervals.`
+ : "Start forecasting or run the test first.";
+
+ dispatch(createForecaster(forecasterToCreate, dataSourceId))
+ .then((response: any) => {
+ const forecasterId = response.response.id;
+ // if not running test, show success message and navigate
+ if (!shouldRunOnceRef.current) {
+
+ // Call getForecaster to refresh forecaster state after creation
+ // This ensures useFetchForecasterInfo gets correct forecaster state
+ // Otherwise, ForecasterDetail's useEffect won't work as it depends on forecaster.curState
+ dispatch(getForecaster(forecasterId, dataSourceId));
+ core.notifications.toasts.addSuccess({
+ title: `${forecasterToCreate.name} forecast has been created`,
+ text: subTitle,
+ });
+ props.history.push(
+ constructHrefWithDataSourceId(
+ `/forecasters/${forecasterId}/details`,
+ dataSourceId,
+ false
+ )
+ );
+ }
+ // Optionally run test
+ else {
+ dispatch(
+ //@ts-ignore
+ testForecaster(
+ forecasterId,
+ dataSourceId
+ )
+ )
+ .then((response: any) => {
+ // Call getForecaster to refresh forecaster state after creation
+ // This ensures useFetchForecasterInfo gets correct forecaster state
+ // Otherwise, ForecasterDetail's useEffect won't work as it depends on forecaster.curState
+ dispatch(getForecaster(forecasterId, dataSourceId));
+ shouldRunOnceRef.current = false; // Reset after success
+ core.notifications.toasts.addSuccess({
+ title: `${forecasterToCreate.name} forecast has been created`,
+ text: subTitle,
+ });
+ props.history.push(
+ constructHrefWithDataSourceId(
+ `/forecasters/${forecasterId}/details`,
+ dataSourceId,
+ false
+ )
+ );
+ })
+ .catch((err: any) => {
+ shouldRunOnceRef.current = false; // Reset after failure
+ core.notifications.toasts.addDanger(
+ prettifyErrorMessage(
+ getErrorMessage(
+ err,
+ 'There was a problem running the test.'
+ )
+ )
+ );
+ });
+ }
+ })
+ .catch((err: any) => {
+ dispatch(getForecasterCount(dataSourceId)).then((response: any) => {
+ const totalForecasters = get(response, 'response.count', 0);
+ if (totalForecasters === MAX_FORECASTER) {
+ core.notifications.toasts.addDanger(
+ 'Cannot create forecaster - limit of ' +
+ MAX_FORECASTER +
+ ' forecasters reached'
+ );
+ } else {
+ core.notifications.toasts.addDanger(
+ prettifyErrorMessage(
+ getErrorMessage(
+ err,
+ 'There was a problem creating the forecaster'
+ )
+ )
+ );
+ }
+ });
+ });
+ } catch (e) {
+ console.error('Failed to create forecaster:', e);
+ core.notifications.toasts.addDanger(
+ 'There was a problem creating the forecaster'
+ );
+ } finally {
+ formikHelpers.setSubmitting(false);
+ }
+ };
+
+ let renderDataSourceComponent: ReactElement | null = null;
+ if (dataSourceEnabled) {
+ const DataSourceMenu =
+ getDataSourceManagementPlugin()?.ui.getDataSourceMenu();
+ renderDataSourceComponent = (
+
+ );
+ }
+
+ const [showSuggestDialog, setShowSuggestDialog] = useState(false);
+
+ return (
+
+ {(formikProps) => (
+
+ {dataSourceEnabled && renderDataSourceComponent}
+
+
+
+
+
+ {
+ 'Create forecaster'}{' '}
+
+
+
+
+ Add model parameters
+
+
+
+
+
+ Core parameters
+
+ Define how often the forecast will generate the next value based on historical data and how far to
+ forecast into the future.
+
+
+
+
+
setShowSuggestDialog(true)}
+ >
+ Suggest parameters
+
+
+ {/* Conditionally render the SuggestParametersDialog */}
+ {showSuggestDialog && (
+
setShowSuggestDialog(false)}
+ dataSourceId={dataSourceId}
+ forecasterDefinitionValues={props.forecasterDefinitionValues!}
+ formikProps={formikProps}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ props.history.push(
+ constructHrefWithDataSourceId(
+ '/forecasters',
+ dataSourceId,
+ false
+ )
+ );
+
+ }}
+ >
+ Cancel
+
+
+ {
+
+ {
+ //@ts-ignore
+ props.setStep(1);
+ }}
+ >
+ Previous
+
+
+ }
+
+ {
+ shouldRunOnceRef.current = false;
+ formikProps.handleSubmit();
+ }}
+ >
+ Create
+
+
+
+ {
+ shouldRunOnceRef.current = true;
+ formikProps.handleSubmit();
+ }}
+ >
+ Create and test
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/public/pages/ConfigureForecastModel/containers/__tests__/ConfigureForecastModel.test.tsx b/public/pages/ConfigureForecastModel/containers/__tests__/ConfigureForecastModel.test.tsx
new file mode 100644
index 000000000..4534408e3
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/containers/__tests__/ConfigureForecastModel.test.tsx
@@ -0,0 +1,284 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import React from 'react';
+import { Provider } from 'react-redux';
+import {
+ Router,
+ RouteComponentProps,
+ Route,
+ Switch,
+} from 'react-router-dom';
+import { render, fireEvent, waitFor, screen } from '@testing-library/react';
+import { ConfigureForecastModel } from '../ConfigureForecastModel';
+import configureStore from '../../../../redux/configureStore';
+import { httpClientMock, coreServicesMock } from '../../../../../test/mocks';
+import { CoreServicesContext } from '../../../../components/CoreServices/CoreServices';
+import { INITIAL_MODEL_CONFIGURATION_VALUES } from '../../utils/constants';
+import { createMemoryHistory } from 'history';
+
+// Mock services
+jest.mock('../../../../services', () => ({
+ ...jest.requireActual('../../../../services'),
+ getDataSourceEnabled: () => ({ enabled: false }),
+ getDataSourceManagementPlugin: () => undefined,
+ getSavedObjectsClient: () => ({}),
+ getNotifications: () => ({}),
+}));
+
+// Mock the hooks
+jest.mock('../../../CreateForecasterSteps/hooks/useFetchForecasterInfo', () => ({
+ useFetchForecasterInfo: () => ({
+ forecaster: undefined,
+ hasError: false,
+ }),
+}));
+
+jest.mock('../../../main/hooks/useHideSideNavBar', () => ({
+ useHideSideNavBar: jest.fn(),
+}));
+
+// Mock helper functions to avoid data structure issues
+jest.mock('../../../ReviewAndCreate/utils/helpers', () => ({
+ ...jest.requireActual('../../../ReviewAndCreate/utils/helpers'),
+ formikToForecasterDefinition: jest.fn(() => ({
+ name: 'Test Forecaster',
+ description: 'Test Description',
+ indices: ['test-index'],
+ timeField: 'timestamp',
+ })),
+}));
+
+// Mock redux actions
+jest.mock('../../../../redux/reducers/forecast', () => ({
+ suggestForecaster: jest.fn(() => Promise.resolve({ response: { interval: 10 } })),
+ createForecaster: jest.fn(() => Promise.resolve({ response: { id: 'test-forecaster-id' } })),
+ testForecaster: jest.fn(() => Promise.resolve({ response: {} })),
+ getForecaster: jest.fn(() => Promise.resolve({ response: {} })),
+ getForecasterCount: jest.fn(() => Promise.resolve({ response: { count: 0 } })),
+ updateForecaster: jest.fn(() => Promise.resolve({ response: {} })),
+}));
+
+// Mock the constructHrefWithDataSourceId function
+jest.mock('../../../utils/helpers', () => ({
+ ...jest.requireActual('../../../utils/helpers'),
+ constructHrefWithDataSourceId: jest.fn((href) => href),
+}));
+
+const mockForecasterDefinitionValues = {
+ name: 'Test Forecaster',
+ description: 'Test Description',
+ index: [{ label: 'test-index' }],
+ timeField: 'timestamp',
+ filters: [],
+ filterQuery: '',
+ featureList: [{
+ featureId: 'test-feature-id',
+ featureName: 'test-feature',
+ featureType: 'simple_aggs',
+ featureEnabled: true,
+ aggregationMethod: 'sum',
+ aggregationField: 'value',
+ aggregationQuery: ''
+ }],
+ categoryFieldEnabled: false,
+ categoryField: []
+};
+
+const mockSetStep = jest.fn();
+const mockSetActionMenu = jest.fn();
+
+const renderWithRouter = (props = {}) => {
+ const history = createMemoryHistory();
+ const defaultProps = {
+ setStep: mockSetStep,
+ setActionMenu: mockSetActionMenu,
+ forecasterDefinitionValues: mockForecasterDefinitionValues,
+ initialValues: INITIAL_MODEL_CONFIGURATION_VALUES,
+ ...props,
+ };
+
+ return render(
+
+
+
+ (
+
+
+
+ )}
+ />
+
+
+
+ );
+};
+
+describe(' spec', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ console.error = jest.fn();
+ console.warn = jest.fn();
+ });
+
+ describe('Component rendering', () => {
+ test('renders the component with correct title', () => {
+ renderWithRouter();
+
+ expect(screen.getByText('Create forecaster')).toBeInTheDocument();
+ expect(screen.getByText('Add model parameters')).toBeInTheDocument();
+ expect(screen.getByText(/Core parameters/)).toBeInTheDocument();
+ });
+
+ test('renders suggest parameters button', () => {
+ renderWithRouter();
+
+ expect(screen.getByTestId('suggestParametersButton')).toBeInTheDocument();
+ expect(screen.getByText('Suggest parameters')).toBeInTheDocument();
+ });
+
+ test('renders all main components', () => {
+ renderWithRouter();
+
+ // Check for main sections
+ expect(screen.getByText('Advanced model parameters')).toBeInTheDocument();
+ expect(screen.getByText('Storage')).toBeInTheDocument();
+ });
+
+ test('renders action buttons', () => {
+ renderWithRouter();
+
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ expect(screen.getByText('Previous')).toBeInTheDocument();
+ expect(screen.getByText('Create')).toBeInTheDocument();
+ expect(screen.getByText('Create and test')).toBeInTheDocument();
+ });
+ });
+
+ describe('Suggest parameters functionality', () => {
+ test('opens suggest parameters dialog when button is clicked', async () => {
+ renderWithRouter();
+
+ const suggestButton = screen.getByTestId('suggestParametersButton');
+ fireEvent.click(suggestButton);
+
+ // Dialog should open - this might be in a portal, so use a more flexible query
+ await waitFor(() => {
+ // Look for dialog content that might be rendered
+ expect(screen.getByText('Suggest parameters', { selector: 'h2' })).toBeInTheDocument();
+ }, { timeout: 3000 });
+ });
+ });
+
+ describe('Navigation', () => {
+ test('navigates to previous step when Previous button is clicked', () => {
+ renderWithRouter();
+
+ const previousButton = screen.getByTestId('configureModelPreviousButton');
+ fireEvent.click(previousButton);
+
+ expect(mockSetStep).toHaveBeenCalledWith(1);
+ });
+
+ test('navigates to forecasters list when Cancel button is clicked', () => {
+ const mockHistory = createMemoryHistory();
+ const mockHistoryPush = jest.spyOn(mockHistory, 'push');
+
+ render(
+
+
+
+ (
+
+
+
+ )}
+ />
+
+
+
+ );
+
+ const cancelButton = screen.getByText('Cancel');
+ fireEvent.click(cancelButton);
+
+ expect(mockHistoryPush).toHaveBeenCalledWith('/forecasters');
+ });
+ });
+
+ describe('Breadcrumb navigation', () => {
+ test('sets correct breadcrumbs on mount', () => {
+ renderWithRouter();
+
+ expect(coreServicesMock.chrome.setBreadcrumbs).toHaveBeenCalledWith([
+ expect.objectContaining({ text: 'Forecasting' }),
+ expect.objectContaining({ text: 'Create forecaster' }),
+ ]);
+ });
+ });
+
+ describe('Component integration', () => {
+ test('renders Settings component', () => {
+ renderWithRouter();
+
+ // Settings component should render its content
+ expect(screen.getByText('Forecasting interval')).toBeInTheDocument();
+ });
+
+ test('renders AdvancedSettings component', () => {
+ renderWithRouter();
+
+ expect(screen.getByText('Advanced model parameters')).toBeInTheDocument();
+ });
+
+ test('renders StorageSettings component', () => {
+ renderWithRouter();
+
+ expect(screen.getByText('Storage')).toBeInTheDocument();
+ });
+ });
+
+ describe('Form state management', () => {
+ test('initializes with provided initial values', () => {
+ const customInitialValues = {
+ ...INITIAL_MODEL_CONFIGURATION_VALUES,
+ interval: 15,
+ shingleSize: 16,
+ };
+
+ renderWithRouter({
+ initialValues: customInitialValues,
+ });
+
+ // Component should render without errors with custom initial values
+ expect(screen.getByText('Create forecaster')).toBeInTheDocument();
+ });
+
+ test('form is properly configured', () => {
+ renderWithRouter();
+
+ // Formik should be configured with enableReinitialize=true
+ // This ensures form updates when initialValues change
+ expect(screen.getByText('Create forecaster')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/public/pages/ConfigureForecastModel/index.scss b/public/pages/ConfigureForecastModel/index.scss
new file mode 100644
index 000000000..ed21caa4f
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/index.scss
@@ -0,0 +1,5 @@
+.unit-badge {
+ height:32px;
+ line-height: 30px;
+ padding: 0 8px;
+}
\ No newline at end of file
diff --git a/public/pages/ConfigureForecastModel/models/interfaces.ts b/public/pages/ConfigureForecastModel/models/interfaces.ts
new file mode 100644
index 000000000..afdea0f96
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/models/interfaces.ts
@@ -0,0 +1,38 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+// Formik values used when creating the model configuration
+export interface ModelConfigurationFormikValues {
+ shingleSize: number;
+ imputationOption?: ImputationFormikValues;
+ interval: number | undefined;
+ windowDelay: number | undefined;
+ suggestedSeasonality?: number;
+ recencyEmphasis?: number;
+ resultIndexMinAge?: number | string;
+ resultIndexMinSize?: number | string;
+ resultIndexTtl?:number | string;
+ flattenCustomResultIndex?: boolean;
+ resultIndex?: string;
+ horizon?: number;
+ history?: number;
+}
+
+export interface ImputationFormikValues {
+ imputationMethod?: string;
+ custom_value?: CustomValueFormikValues[];
+}
+
+export interface CustomValueFormikValues {
+ // forecasting has only one feature and we don't need to specify the feature name
+ featureName?: string;
+ data: number;
+}
diff --git a/public/pages/ConfigureForecastModel/utils/__tests__/helpers.test.tsx b/public/pages/ConfigureForecastModel/utils/__tests__/helpers.test.tsx
new file mode 100644
index 000000000..b626eab92
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/utils/__tests__/helpers.test.tsx
@@ -0,0 +1,218 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import {
+ Forecaster,
+ UNITS,
+} from '../../../../models/interfaces';
+import { ImputationMethod, ImputationOption } from '../../../../models/types';
+import {
+ ModelConfigurationFormikValues
+} from '../../models/interfaces';
+import {
+ clearModelConfiguration,
+ createImputationFormikValues,
+ focusOnCategoryField,
+ focusOnImputationOption,
+ formikToImputationMethod,
+ formikToImputationOption,
+ formikToModelConfiguration,
+ getCustomValueStrArray,
+ getShingleSizeFromObject,
+ imputationMethodToFormik,
+ modelConfigurationToFormik,
+} from '../helpers';
+import { SparseDataOptionValue } from '../constants';
+import { DEFAULT_SHINGLE_SIZE } from '../../../../utils/constants';
+
+// Helper to generate a forecaster object for tests
+const getRandomForecaster = (
+ imputation?: ImputationMethod,
+ hasDefaultFill: boolean = true
+): Forecaster => {
+ let imputationOption: ImputationOption | undefined;
+ if (imputation) {
+ imputationOption = { method: imputation };
+ if (imputation === ImputationMethod.FIXED_VALUES && hasDefaultFill) {
+ imputationOption.defaultFill = [
+ { featureName: 'test-feature', data: 42 },
+ ];
+ }
+ }
+
+ return {
+ id: 'test-forecaster-id',
+ name: 'test-forecaster',
+ description: 'Test forecaster description',
+ indices: ['test-index'],
+ uiMetadata: { features: { 'test-id': { featureType: 'simple_aggs', aggregationBy: 'sum', aggregationOf: 'value' }} },
+ featureAttributes: [{ featureId: 'test-id', featureName: 'test-feature', featureEnabled: true, importance: 1, aggregationQuery: {} }],
+ forecastInterval: { period: { interval: 10, unit: UNITS.MINUTES } },
+ windowDelay: { period: { interval: 5, unit: UNITS.MINUTES } },
+ shingleSize: 8,
+ imputationOption: imputationOption,
+ };
+};
+
+describe('ConfigureForecastModel helpers', () => {
+ describe('focusOnCategoryField and focusOnImputationOption', () => {
+ const mockElement = { focus: jest.fn() };
+ const originalGetElementById = document.getElementById;
+
+ beforeEach(() => {
+ document.getElementById = jest.fn().mockReturnValue(mockElement);
+ mockElement.focus.mockClear();
+ });
+
+ afterAll(() => {
+ document.getElementById = originalGetElementById;
+ });
+
+ test('focusOnCategoryField should focus on the correct element', () => {
+ focusOnCategoryField();
+ expect(document.getElementById).toHaveBeenCalledWith(
+ 'categoryFieldCheckbox'
+ );
+ expect(mockElement.focus).toHaveBeenCalledTimes(1);
+ });
+
+ test('focusOnImputationOption should focus on the correct element', () => {
+ focusOnImputationOption();
+ expect(document.getElementById).toHaveBeenCalledWith('imputationOption');
+ expect(mockElement.focus).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('getShingleSizeFromObject', () => {
+ test('should return shingle size if it exists', () => {
+ expect(getShingleSizeFromObject({ shingleSize: 10 })).toBe(10);
+ });
+ test('should return default shingle size if it does not exist', () => {
+ expect(getShingleSizeFromObject({ otherProp: 'value' })).toBe(
+ DEFAULT_SHINGLE_SIZE
+ );
+ });
+ test('should return default shingle size for empty object', () => {
+ expect(getShingleSizeFromObject({})).toBe(DEFAULT_SHINGLE_SIZE);
+ });
+ });
+
+ describe('clearModelConfiguration', () => {
+ test('should clear relevant fields from the forecaster', () => {
+ const forecaster = getRandomForecaster();
+ forecaster.categoryField = ['some-field'];
+ const cleared = clearModelConfiguration(forecaster);
+ expect(cleared.featureAttributes).toEqual([]);
+ expect(cleared.uiMetadata.features).toEqual({});
+ expect(cleared.categoryField).toBeUndefined();
+ expect(cleared.shingleSize).toBe(DEFAULT_SHINGLE_SIZE);
+ });
+ });
+
+ describe('imputationMethodToFormik and createImputationFormikValues', () => {
+ test('should handle FIXED_VALUES with default fill', () => {
+ const forecaster = getRandomForecaster(ImputationMethod.FIXED_VALUES);
+ const formikValues = createImputationFormikValues(forecaster);
+ expect(formikValues.imputationMethod).toBe(SparseDataOptionValue.CUSTOM_VALUE);
+ expect(formikValues.custom_value).toEqual([{ featureName: 'test-feature', data: 42 }]);
+ });
+ test('should handle PREVIOUS_VALUE', () => {
+ const forecaster = getRandomForecaster(ImputationMethod.PREVIOUS);
+ const formikValues = createImputationFormikValues(forecaster);
+ expect(formikValues.imputationMethod).toBe(SparseDataOptionValue.PREVIOUS_VALUE);
+ expect(formikValues.custom_value).toBeUndefined();
+ });
+ test('should handle ZERO', () => {
+ const forecaster = getRandomForecaster(ImputationMethod.ZERO);
+ const formikValues = createImputationFormikValues(forecaster);
+ expect(formikValues.imputationMethod).toBe(SparseDataOptionValue.SET_TO_ZERO);
+ expect(formikValues.custom_value).toBeUndefined();
+ });
+ test('should handle undefined imputation method', () => {
+ const forecaster = getRandomForecaster(undefined);
+ const formikValues = createImputationFormikValues(forecaster);
+ expect(formikValues.imputationMethod).toBe(SparseDataOptionValue.IGNORE);
+ expect(formikValues.custom_value).toBeUndefined();
+ });
+ });
+
+ describe('modelConfigurationToFormik', () => {
+ test('should correctly convert a full forecaster to formik values', () => {
+ const forecaster = getRandomForecaster(ImputationMethod.FIXED_VALUES);
+ const formikValues = modelConfigurationToFormik(forecaster);
+ expect(formikValues.shingleSize).toBe(8);
+ expect(formikValues.interval).toBe(10);
+ expect(formikValues.windowDelay).toBe(5);
+ expect(formikValues.imputationOption?.imputationMethod).toBe(SparseDataOptionValue.CUSTOM_VALUE);
+ });
+ test('should handle empty forecaster object', () => {
+ const formikValues = modelConfigurationToFormik({} as Forecaster);
+ expect(formikValues.shingleSize).toBe(DEFAULT_SHINGLE_SIZE);
+ expect(formikValues.interval).toBe(undefined);
+ });
+ });
+
+ describe('formikToImputationMethod and formikToImputationOption', () => {
+ test('should correctly convert formik values to ImputationOption for CUSTOM_VALUE', () => {
+ const formikValues = { imputationMethod: SparseDataOptionValue.CUSTOM_VALUE, custom_value: [{ featureName: 'test-feature', data: 123 }]};
+ const option = formikToImputationOption('test-feature', formikValues);
+ expect(option?.method).toBe(ImputationMethod.FIXED_VALUES);
+ expect(option?.defaultFill).toEqual([{ featureName: 'test-feature', data: 123 }]);
+ });
+ test('should handle PREVIOUS_VALUE', () => {
+ const formikValues = { imputationMethod: SparseDataOptionValue.PREVIOUS_VALUE };
+ const option = formikToImputationOption('test-feature', formikValues);
+ expect(option?.method).toBe(ImputationMethod.PREVIOUS);
+ expect(option?.defaultFill).toBeUndefined();
+ });
+ test('should return undefined if featureName is missing', () => {
+ const formikValues = { imputationMethod: SparseDataOptionValue.CUSTOM_VALUE };
+ const option = formikToImputationOption(undefined, formikValues);
+ expect(option).toBeUndefined();
+ });
+ test('should return undefined for IGNORE', () => {
+ const formikValues = { imputationMethod: SparseDataOptionValue.IGNORE };
+ const option = formikToImputationOption('test-feature', formikValues);
+ expect(option).toBeUndefined();
+ });
+ });
+
+ describe('formikToModelConfiguration', () => {
+ test('should correctly convert formik values to a forecaster model', () => {
+ const formikValues: ModelConfigurationFormikValues = {
+ name: 'test',
+ description: '',
+ shingleSize: 4,
+ interval: 15,
+ windowDelay: 2,
+ imputationOption: { imputationMethod: SparseDataOptionValue.SET_TO_ZERO },
+ };
+ const forecaster = formikToModelConfiguration(formikValues, getRandomForecaster());
+ expect(forecaster.shingleSize).toBe(4);
+ expect(forecaster.forecastInterval.period.interval).toBe(15);
+ expect(forecaster.windowDelay.period.interval).toBe(2);
+ expect(forecaster.imputationOption?.method).toBe(ImputationMethod.ZERO);
+ });
+ });
+
+ describe('getCustomValueStrArray', () => {
+ test('should return formatted string array for custom values', () => {
+ const forecaster = getRandomForecaster(ImputationMethod.FIXED_VALUES);
+ const result = getCustomValueStrArray(SparseDataOptionValue.CUSTOM_VALUE, forecaster);
+ expect(result).toEqual(['test-feature: 42']);
+ });
+ test('should return empty array for non-custom methods', () => {
+ const forecaster = getRandomForecaster(ImputationMethod.ZERO);
+ const result = getCustomValueStrArray(SparseDataOptionValue.SET_TO_ZERO, forecaster);
+ expect(result).toEqual([]);
+ });
+ });
+});
diff --git a/public/pages/ConfigureForecastModel/utils/constants.tsx b/public/pages/ConfigureForecastModel/utils/constants.tsx
new file mode 100644
index 000000000..3020b8376
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/utils/constants.tsx
@@ -0,0 +1,40 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import {
+ ModelConfigurationFormikValues,
+} from '../../ConfigureForecastModel/models/interfaces';
+import { DEFAULT_SHINGLE_SIZE } from '../../../utils/constants';
+
+// FIXME: We intentionally leave some parameters undefined to encourage customers to click
+// the "Suggest parameters" button rather than providing default values like "10 minutes" for interval
+export const INITIAL_MODEL_CONFIGURATION_VALUES: ModelConfigurationFormikValues =
+ {
+ shingleSize: DEFAULT_SHINGLE_SIZE,
+ imputationOption: undefined,
+ interval: undefined,
+ windowDelay: undefined,
+ suggestedSeasonality: undefined,
+ recencyEmphasis: undefined,
+ resultIndex: undefined,
+ resultIndexMinAge: 7,
+ resultIndexMinSize: 51200,
+ resultIndexTtl: 60,
+ flattenCustomResultIndex: false,
+ };
+
+// an enum for the sparse data handling options
+export enum SparseDataOptionValue {
+ IGNORE = 'ignore',
+ PREVIOUS_VALUE = 'previous_value',
+ SET_TO_ZERO = 'set_to_zero',
+ CUSTOM_VALUE = 'custom_value',
+}
diff --git a/public/pages/ConfigureForecastModel/utils/helpers.ts b/public/pages/ConfigureForecastModel/utils/helpers.ts
new file mode 100644
index 000000000..d26391c76
--- /dev/null
+++ b/public/pages/ConfigureForecastModel/utils/helpers.ts
@@ -0,0 +1,207 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+import { DATA_TYPES, DEFAULT_SHINGLE_SIZE } from '../../../utils/constants';
+import {
+ Forecaster,
+ UNITS,
+} from '../../../models/interfaces';
+import { get, cloneDeep, isEmpty } from 'lodash';
+import {
+ ModelConfigurationFormikValues,
+ CustomValueFormikValues,
+ ImputationFormikValues,
+} from '../../ConfigureForecastModel/models/interfaces';
+import { INITIAL_MODEL_CONFIGURATION_VALUES } from '../../ConfigureForecastModel/utils/constants';
+import {
+ ImputationMethod,
+ ImputationOption,
+} from '../../../models/types';
+import {
+ SparseDataOptionValue
+} from './constants'
+
+export const focusOnCategoryField = () => {
+ const component = document.getElementById('categoryFieldCheckbox');
+ component?.focus();
+};
+
+export const focusOnImputationOption = () => {
+ const component = document.getElementById('imputationOption');
+ component?.focus();
+};
+
+export const getShingleSizeFromObject = (obj: object) => {
+ return get(obj, 'shingleSize', DEFAULT_SHINGLE_SIZE);
+};
+
+export function clearModelConfiguration(forecast: Forecaster): Forecaster {
+ return {
+ ...forecast,
+ featureAttributes: [],
+ uiMetadata: {
+ ...forecast.uiMetadata,
+ features: {},
+ },
+ categoryField: undefined,
+ shingleSize: DEFAULT_SHINGLE_SIZE,
+ };
+}
+
+export function createImputationFormikValues(
+ forecaster: Forecaster
+): ImputationFormikValues {
+ const imputationMethod = imputationMethodToFormik(forecaster);
+ let defaultFillArray: CustomValueFormikValues[] = [];
+
+ if (SparseDataOptionValue.CUSTOM_VALUE === imputationMethod) {
+ const defaultFill = get(forecaster, 'imputationOption.defaultFill', null) as Array<{ featureName: string; data: number }> | null;
+ defaultFillArray = defaultFill
+ ? defaultFill.map(({ featureName, data }) => ({
+ featureName,
+ data,
+ }))
+ : [];
+ }
+
+ return {
+ imputationMethod: imputationMethod,
+ custom_value: SparseDataOptionValue.CUSTOM_VALUE === imputationMethod ? defaultFillArray : undefined,
+ };
+}
+
+export function modelConfigurationToFormik(
+ forecaster: Forecaster
+): ModelConfigurationFormikValues {
+ const initialValues = cloneDeep(INITIAL_MODEL_CONFIGURATION_VALUES);
+ if (isEmpty(forecaster)) {
+ return initialValues;
+ }
+
+ const imputationFormikValues = createImputationFormikValues(forecaster);
+
+ return {
+ ...initialValues,
+ shingleSize: get(forecaster, 'shingleSize', DEFAULT_SHINGLE_SIZE),
+ imputationOption: imputationFormikValues,
+ interval: get(forecaster, 'forecastInterval.period.interval', 10),
+ windowDelay: get(forecaster, 'windowDelay.period.interval', 0),
+ suggestedSeasonality: get(forecaster, 'suggestedSeasonality', undefined),
+ recencyEmphasis: get(forecaster, 'recencyEmphasis', undefined),
+ resultIndexMinAge: get(forecaster, 'resultIndexMinAge', undefined),
+ resultIndexMinSize:get(forecaster, 'resultIndexMinSize', undefined),
+ resultIndexTtl: get(forecaster, 'resultIndexTtl', undefined),
+ flattenCustomResultIndex: get(forecaster, 'flattenCustomResultIndex', false),
+ resultIndex: forecaster.resultIndex,
+ horizon: get(forecaster, 'horizon', undefined),
+ history: get(forecaster, 'history', undefined),
+ };
+}
+
+export function formikToModelConfiguration(
+ values: ModelConfigurationFormikValues,
+ forecaster: Forecaster
+): Forecaster {
+ let forecasterBody = {
+ ...forecaster,
+ shingleSize: values.shingleSize,
+ imputationOption: formikToImputationOption(
+ forecaster.featureAttributes?.[0]?.featureName,
+ values.imputationOption,
+ ),
+ forecastInterval: {
+ period: { interval: values.interval, unit: UNITS.MINUTES },
+ },
+ windowDelay: {
+ period: { interval: values.windowDelay, unit: UNITS.MINUTES },
+ },
+ resultIndexMinAge: values.resultIndexMinAge,
+ resultIndexMinSize: values.resultIndexMinSize,
+ resultIndexTtl: values.resultIndexTtl,
+ flattenCustomResultIndex: values.flattenCustomResultIndex,
+ resultIndex: values.resultIndex,
+ horizon: values.horizon,
+ history: values.history,
+ } as Forecaster;
+
+ return forecasterBody;
+}
+
+export function formikToImputationOption(
+ featureName: string | undefined,
+ imputationFormikValues?: ImputationFormikValues,
+): ImputationOption | undefined {
+ // If no feature name is provided, return undefined
+ if (!featureName) return undefined;
+
+ // Map the formik method to the imputation method; return undefined if method is not recognized.
+ const method = formikToImputationMethod(imputationFormikValues?.imputationMethod);
+ if (!method) return undefined;
+
+ // Convert custom_value array to defaultFill if the method is FIXED_VALUES.
+ const defaultFill = method === ImputationMethod.FIXED_VALUES
+ ? imputationFormikValues?.custom_value?.map(({ data }) => ({
+ featureName: featureName,
+ data,
+ }))
+ : undefined;
+
+ // Construct and return the ImputationOption object.
+ return { method, defaultFill };
+}
+
+export function imputationMethodToFormik(
+ forecaster: Forecaster
+): string {
+ var imputationMethod = get(forecaster, 'imputationOption.method', undefined) as ImputationMethod;
+
+ switch (imputationMethod) {
+ case ImputationMethod.FIXED_VALUES:
+ return SparseDataOptionValue.CUSTOM_VALUE;
+ case ImputationMethod.PREVIOUS:
+ return SparseDataOptionValue.PREVIOUS_VALUE;
+ case ImputationMethod.ZERO:
+ return SparseDataOptionValue.SET_TO_ZERO;
+ default:
+ break;
+ }
+
+ return SparseDataOptionValue.IGNORE;
+}
+
+export function formikToImputationMethod(
+ formikValue: string | undefined
+): ImputationMethod | undefined {
+ switch (formikValue) {
+ case SparseDataOptionValue.CUSTOM_VALUE:
+ return ImputationMethod.FIXED_VALUES;
+ case SparseDataOptionValue.PREVIOUS_VALUE:
+ return ImputationMethod.PREVIOUS;
+ case SparseDataOptionValue.SET_TO_ZERO:
+ return ImputationMethod.ZERO;
+ default:
+ return undefined;
+ }
+}
+
+export const getCustomValueStrArray = (imputationMethodStr : string, forecaster: Forecaster): string[] => {
+ if (SparseDataOptionValue.CUSTOM_VALUE === imputationMethodStr) {
+ const defaultFill : Array<{ featureName: string; data: number }> = get(forecaster, 'imputationOption.defaultFill', []);
+
+ return defaultFill
+ .map(({ featureName, data }) => `${featureName}: ${data}`);
+ }
+ return []
+}
+
+export const toNumberOrEmpty = (value: string): number | '' => {
+ return value === '' ? '' : Number(value);
+};
diff --git a/public/pages/ConfigureModel/containers/__tests__/__snapshots__/ConfigureModel.test.tsx.snap b/public/pages/ConfigureModel/containers/__tests__/__snapshots__/ConfigureModel.test.tsx.snap
index 4ad250105..15f9777a4 100644
--- a/public/pages/ConfigureModel/containers/__tests__/__snapshots__/ConfigureModel.test.tsx.snap
+++ b/public/pages/ConfigureModel/containers/__tests__/__snapshots__/ConfigureModel.test.tsx.snap
@@ -737,7 +737,6 @@ exports[` spec creating model configuration renders the compon