diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 647de24ad4920..9a6a585abd68c 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -484,6 +484,7 @@ packages/kbn-management/cards_navigation @elastic/platform-deployment-management
src/plugins/management @elastic/platform-deployment-management
packages/kbn-management/settings/components/field_input @elastic/platform-deployment-management
packages/kbn-management/settings/components/field_row @elastic/platform-deployment-management
+packages/kbn-management/settings/components/form @elastic/platform-deployment-management
packages/kbn-management/settings/field_definition @elastic/platform-deployment-management
packages/kbn-management/settings/setting_ids @elastic/appex-sharedux @elastic/platform-deployment-management
packages/kbn-management/settings/section_registry @elastic/appex-sharedux @elastic/platform-deployment-management
diff --git a/package.json b/package.json
index 2749e9b2143c4..a08963d37feea 100644
--- a/package.json
+++ b/package.json
@@ -506,6 +506,7 @@
"@kbn/management-plugin": "link:src/plugins/management",
"@kbn/management-settings-components-field-input": "link:packages/kbn-management/settings/components/field_input",
"@kbn/management-settings-components-field-row": "link:packages/kbn-management/settings/components/field_row",
+ "@kbn/management-settings-components-form": "link:packages/kbn-management/settings/components/form",
"@kbn/management-settings-field-definition": "link:packages/kbn-management/settings/field_definition",
"@kbn/management-settings-ids": "link:packages/kbn-management/settings/setting_ids",
"@kbn/management-settings-section-registry": "link:packages/kbn-management/settings/section_registry",
@@ -867,7 +868,6 @@
"d3": "3.5.17",
"d3-array": "2.12.1",
"d3-brush": "^3.0.0",
- "d3-cloud": "1.2.5",
"d3-interpolate": "^3.0.1",
"d3-scale": "^3.3.0",
"d3-selection": "^3.0.0",
diff --git a/packages/kbn-management/settings/components/form/README.mdx b/packages/kbn-management/settings/components/form/README.mdx
new file mode 100644
index 0000000000000..163f476284f89
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/README.mdx
@@ -0,0 +1,18 @@
+---
+id: management/settings/components/form
+slug: /management/settings/components/form
+title: Management Settings Form Component
+description: A package containing a component for rendering the form in the Advanced Settings UI.
+tags: ['management', 'settings']
+date: 2023-09-12
+---
+
+## Description
+
+This package contains a component for rendering the Advanced Settings UI form that contains `FieldRow` components, each of which displays a single UiSetting field row.
+The form also handles the logic for saving any changes to the UiSettings values by directly communicating with the uiSettings service.
+
+
+## Notes
+
+- This implementation was extracted from the `Form` component in the `advancedSettings` plugin.
\ No newline at end of file
diff --git a/packages/kbn-management/settings/components/form/bottom_bar/bottom_bar.test.tsx b/packages/kbn-management/settings/components/form/bottom_bar/bottom_bar.test.tsx
new file mode 100644
index 0000000000000..ddb3502bb2009
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/bottom_bar/bottom_bar.test.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react';
+import {
+ BottomBar,
+ BottomBarProps,
+ DATA_TEST_SUBJ_SAVE_BUTTON,
+ DATA_TEST_SUBJ_CANCEL_BUTTON,
+} from './bottom_bar';
+import { wrap } from '../mocks';
+
+const saveAll = jest.fn();
+const clearAllUnsaved = jest.fn();
+const unsavedChangesCount = 3;
+
+const defaultProps: BottomBarProps = {
+ onSaveAll: saveAll,
+ onClearAllUnsaved: clearAllUnsaved,
+ hasInvalidChanges: false,
+ unsavedChangesCount,
+ isLoading: false,
+};
+
+const unsavedChangesCountText = unsavedChangesCount + ' unsaved settings';
+
+describe('BottomBar', () => {
+ it('renders without errors', () => {
+ const { container } = render(wrap());
+ expect(container).toBeInTheDocument();
+ });
+
+ it('fires saveAll when the Save button is clicked', () => {
+ const { getByTestId } = render(wrap());
+
+ const input = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON);
+ fireEvent.click(input);
+ expect(saveAll).toHaveBeenCalled();
+ });
+
+ it('fires clearAllUnsaved when the Cancel button is clicked', () => {
+ const { getByTestId } = render(wrap());
+
+ const input = getByTestId(DATA_TEST_SUBJ_CANCEL_BUTTON);
+ fireEvent.click(input);
+ expect(saveAll).toHaveBeenCalled();
+ });
+
+ it('renders unsaved changes count', () => {
+ const { getByText } = render(wrap());
+
+ expect(getByText(unsavedChangesCountText)).toBeInTheDocument();
+ });
+
+ it('save button is disabled when there are invalid changes', () => {
+ const { getByTestId } = render(
+ wrap()
+ );
+
+ const input = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON);
+ expect(input).toBeDisabled();
+ });
+
+ it('save button is loading when in loading state', () => {
+ const { getByTestId, getByLabelText } = render(
+ wrap()
+ );
+
+ const input = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON);
+ expect(input).toBeDisabled();
+ expect(getByLabelText('Loading')).toBeInTheDocument();
+ });
+});
diff --git a/packages/kbn-management/settings/components/form/bottom_bar/bottom_bar.tsx b/packages/kbn-management/settings/components/form/bottom_bar/bottom_bar.tsx
new file mode 100644
index 0000000000000..818c86b78109a
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/bottom_bar/bottom_bar.tsx
@@ -0,0 +1,104 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+
+import {
+ EuiBottomBar,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiToolTip,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { UnsavedCount } from './unsaved_count';
+import { useFormStyles } from '../form.styles';
+
+export const DATA_TEST_SUBJ_SAVE_BUTTON = 'settings-save-button';
+export const DATA_TEST_SUBJ_CANCEL_BUTTON = 'settings-cancel-button';
+
+/**
+ * Props for a {@link BottomBar} component.
+ */
+export interface BottomBarProps {
+ onSaveAll: () => void;
+ onClearAllUnsaved: () => void;
+ hasInvalidChanges: boolean;
+ isLoading: boolean;
+ unsavedChangesCount: number;
+}
+
+/**
+ * Component for displaying the bottom bar of a {@link Form}.
+ */
+export const BottomBar = ({
+ onSaveAll,
+ onClearAllUnsaved,
+ hasInvalidChanges,
+ isLoading,
+ unsavedChangesCount,
+}: BottomBarProps) => {
+ const { cssFormButton, cssFormUnsavedCount } = useFormStyles();
+
+ return (
+
+
+
+
+
+
+
+
+ {i18n.translate('management.settings.form.cancelButtonLabel', {
+ defaultMessage: 'Cancel changes',
+ })}
+
+
+
+
+
+ {i18n.translate('management.settings.form.saveButtonLabel', {
+ defaultMessage: 'Save changes',
+ })}
+
+
+
+
+
+ );
+};
diff --git a/packages/kbn-management/settings/components/form/bottom_bar/index.tsx b/packages/kbn-management/settings/components/form/bottom_bar/index.tsx
new file mode 100644
index 0000000000000..0abbe24520157
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/bottom_bar/index.tsx
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export { BottomBar } from './bottom_bar';
diff --git a/packages/kbn-management/settings/components/form/bottom_bar/unsaved_count.tsx b/packages/kbn-management/settings/components/form/bottom_bar/unsaved_count.tsx
new file mode 100644
index 0000000000000..c5bb501ce98f4
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/bottom_bar/unsaved_count.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+
+import { EuiText } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { useFormStyles } from '../form.styles';
+
+/**
+ * Props for a {@link UnsavedCount} component.
+ */
+interface UnsavedCountProps {
+ unsavedCount: number;
+}
+
+/**
+ * Component for displaying the count of unsaved changes in a {@link BottomBar}.
+ */
+export const UnsavedCount = ({ unsavedCount }: UnsavedCountProps) => {
+ const { cssFormUnsavedCountMessage } = useFormStyles();
+ return (
+
+
+
+ );
+};
diff --git a/packages/kbn-management/settings/components/form/form.styles.ts b/packages/kbn-management/settings/components/form/form.styles.ts
new file mode 100644
index 0000000000000..994bab4530f1f
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/form.styles.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { useEuiTheme, euiBreakpoint } from '@elastic/eui';
+import { css } from '@emotion/react';
+
+/**
+ * A React hook that provides stateful `css` classes for the {@link Form} component.
+ */
+export const useFormStyles = () => {
+ const euiTheme = useEuiTheme();
+ const { size, colors } = euiTheme.euiTheme;
+
+ return {
+ cssFormButton: css`
+ width: 100%;
+ `,
+ cssFormUnsavedCount: css`
+ ${euiBreakpoint(euiTheme, ['xs'])} {
+ display: none;
+ }
+ `,
+ cssFormUnsavedCountMessage: css`
+ box-shadow: -${size.xs} 0 ${colors.warning};
+ padding-left: ${size.s};
+ `,
+ };
+};
diff --git a/packages/kbn-management/settings/components/form/form.test.tsx b/packages/kbn-management/settings/components/form/form.test.tsx
new file mode 100644
index 0000000000000..2f1cbdb80bcac
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/form.test.tsx
@@ -0,0 +1,144 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import { fireEvent, render, waitFor } from '@testing-library/react';
+
+import { FieldDefinition, SettingType } from '@kbn/management-settings-types';
+import { getFieldDefinitions } from '@kbn/management-settings-field-definition';
+
+import { Form } from './form';
+import { wrap, getSettingsMock, createFormServicesMock, uiSettingsClientMock } from './mocks';
+import { TEST_SUBJ_PREFIX_FIELD } from '@kbn/management-settings-components-field-input/input';
+import { DATA_TEST_SUBJ_SAVE_BUTTON, DATA_TEST_SUBJ_CANCEL_BUTTON } from './bottom_bar/bottom_bar';
+import { FormServices } from './types';
+
+const settingsMock = getSettingsMock();
+const fields: Array> = getFieldDefinitions(
+ settingsMock,
+ uiSettingsClientMock
+);
+
+describe('Form', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders without errors', () => {
+ const { container } = render(wrap());
+
+ expect(container).toBeInTheDocument();
+ });
+
+ it('renders as read only if saving is disabled', () => {
+ const { getByTestId } = render(wrap());
+
+ (Object.keys(settingsMock) as SettingType[]).forEach((type) => {
+ if (type === 'json' || type === 'markdown') {
+ return;
+ }
+
+ const inputTestSubj = `${TEST_SUBJ_PREFIX_FIELD}-${type}`;
+
+ if (type === 'color') {
+ expect(getByTestId(`euiColorPickerAnchor ${inputTestSubj}`)).toBeDisabled();
+ } else {
+ expect(getByTestId(inputTestSubj)).toBeDisabled();
+ }
+ });
+ });
+
+ it('renders bottom bar when a field is changed', () => {
+ const { getByTestId, queryByTestId } = render(
+ wrap()
+ );
+
+ expect(queryByTestId(DATA_TEST_SUBJ_SAVE_BUTTON)).not.toBeInTheDocument();
+ expect(queryByTestId(DATA_TEST_SUBJ_CANCEL_BUTTON)).not.toBeInTheDocument();
+
+ const testFieldType = 'string';
+ const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${testFieldType}`);
+ fireEvent.change(input, { target: { value: 'test' } });
+
+ expect(getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON)).toBeInTheDocument();
+ expect(getByTestId(DATA_TEST_SUBJ_CANCEL_BUTTON)).toBeInTheDocument();
+ });
+
+ it('fires saveChanges when Save button is clicked', async () => {
+ const services: FormServices = createFormServicesMock();
+ const { getByTestId } = render(wrap(, services));
+
+ const testFieldType = 'string';
+ const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${testFieldType}`);
+ fireEvent.change(input, { target: { value: 'test' } });
+
+ const saveButton = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON);
+ fireEvent.click(saveButton);
+
+ expect(services.saveChanges).toHaveBeenCalledWith({
+ string: { type: 'string', unsavedValue: 'test' },
+ });
+ });
+
+ it('clears changes when Cancel button is clicked', () => {
+ const { getByTestId } = render(wrap());
+
+ const testFieldType = 'string';
+ const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${testFieldType}`);
+ fireEvent.change(input, { target: { value: 'test' } });
+
+ const cancelButton = getByTestId(DATA_TEST_SUBJ_CANCEL_BUTTON);
+ fireEvent.click(cancelButton);
+
+ expect(input).toHaveValue(settingsMock[testFieldType].value);
+ });
+
+ it('fires showError when saving is unsuccessful', () => {
+ const services: FormServices = createFormServicesMock();
+ const saveChangesWithError = jest.fn(() => {
+ throw new Error('Unable to save');
+ });
+ const testServices = { ...services, saveChanges: saveChangesWithError };
+
+ const { getByTestId } = render(
+ wrap(, testServices)
+ );
+
+ const testFieldType = 'string';
+ const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${testFieldType}`);
+ fireEvent.change(input, { target: { value: 'test' } });
+
+ const saveButton = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON);
+ fireEvent.click(saveButton);
+
+ expect(testServices.showError).toHaveBeenCalled();
+ });
+
+ it('fires showReloadPagePrompt when changing a reloadPageRequired setting', async () => {
+ const services: FormServices = createFormServicesMock();
+ // Make all settings require a page reload
+ const testFields: Array> = getFieldDefinitions(
+ getSettingsMock(true),
+ uiSettingsClientMock
+ );
+ const { getByTestId } = render(
+ wrap(, services)
+ );
+
+ const testFieldType = 'string';
+ const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${testFieldType}`);
+ fireEvent.change(input, { target: { value: 'test' } });
+
+ const saveButton = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON);
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(services.showReloadPagePrompt).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/kbn-management/settings/components/form/form.tsx b/packages/kbn-management/settings/components/form/form.tsx
new file mode 100644
index 0000000000000..fabc80755cad8
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/form.tsx
@@ -0,0 +1,86 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React, { Fragment } from 'react';
+
+import type { FieldDefinition } from '@kbn/management-settings-types';
+import { FieldRow, RowOnChangeFn } from '@kbn/management-settings-components-field-row';
+import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
+import { isEmpty } from 'lodash';
+import { BottomBar } from './bottom_bar';
+import { useSave } from './use_save';
+
+/**
+ * Props for a {@link Form} component.
+ */
+export interface FormProps {
+ /** A list of {@link FieldDefinition} corresponding to settings to be displayed in the form. */
+ fields: Array>;
+ /** True if saving settings is enabled, false otherwise. */
+ isSavingEnabled: boolean;
+}
+
+/**
+ * Component for displaying a set of {@link FieldRow} in a form.
+ * @param props The {@link FormProps} for the {@link Form} component.
+ */
+export const Form = (props: FormProps) => {
+ const { fields, isSavingEnabled } = props;
+
+ const [unsavedChanges, setUnsavedChanges] = React.useState<
+ Record>
+ >({});
+
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ const unsavedChangesCount = Object.keys(unsavedChanges).length;
+ const hasInvalidChanges = Object.values(unsavedChanges).some(({ isInvalid }) => isInvalid);
+
+ const clearAllUnsaved = () => {
+ setUnsavedChanges({});
+ };
+
+ const saveChanges = useSave({ fields, clearChanges: clearAllUnsaved });
+
+ const saveAll = async () => {
+ setIsLoading(true);
+ await saveChanges(unsavedChanges);
+ setIsLoading(false);
+ };
+
+ const onChange: RowOnChangeFn = (id, change) => {
+ if (!change) {
+ const { [id]: unsavedChange, ...rest } = unsavedChanges;
+ setUnsavedChanges(rest);
+ return;
+ }
+
+ setUnsavedChanges((changes) => ({ ...changes, [id]: change }));
+ };
+
+ const fieldRows = fields.map((field) => {
+ const { id: key } = field;
+ const unsavedChange = unsavedChanges[key];
+ return ;
+ });
+
+ return (
+
+ {fieldRows}
+ {!isEmpty(unsavedChanges) && (
+
+ )}
+
+ );
+};
diff --git a/packages/kbn-management/settings/components/form/index.ts b/packages/kbn-management/settings/components/form/index.ts
new file mode 100644
index 0000000000000..d674990322a09
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/index.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export { Form } from './form';
+
+export type { FormKibanaDependencies, FormServices } from './types';
+export { FormProvider, FormKibanaProvider } from './services';
diff --git a/packages/kbn-management/settings/components/form/kibana.jsonc b/packages/kbn-management/settings/components/form/kibana.jsonc
new file mode 100644
index 0000000000000..5db9d203e37f3
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/kibana.jsonc
@@ -0,0 +1,5 @@
+{
+ "type": "shared-common",
+ "id": "@kbn/management-settings-components-form",
+ "owner": "@elastic/platform-deployment-management"
+}
diff --git a/packages/kbn-management/settings/components/form/mocks/context.tsx b/packages/kbn-management/settings/components/form/mocks/context.tsx
new file mode 100644
index 0000000000000..2af26a8f0aaf1
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/mocks/context.tsx
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React, { ReactChild } from 'react';
+import { I18nProvider } from '@kbn/i18n-react';
+
+import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
+import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
+import { I18nStart } from '@kbn/core-i18n-browser';
+
+import { createFieldRowServicesMock } from '@kbn/management-settings-components-field-row/mocks';
+import { FormProvider } from '../services';
+import type { FormServices } from '../types';
+
+const createRootMock = () => {
+ const i18n: I18nStart = {
+ Context: ({ children }) => {children},
+ };
+ const theme = themeServiceMock.createStartContract();
+ return {
+ i18n,
+ theme,
+ };
+};
+
+export const createFormServicesMock = (): FormServices => ({
+ ...createFieldRowServicesMock(),
+ saveChanges: jest.fn(),
+ showError: jest.fn(),
+ showReloadPagePrompt: jest.fn(),
+});
+
+export const TestWrapper = ({
+ children,
+ services = createFormServicesMock(),
+}: {
+ children: ReactChild;
+ services?: FormServices;
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const wrap = (component: JSX.Element, services: FormServices = createFormServicesMock()) => (
+
+);
diff --git a/packages/kbn-management/settings/components/form/mocks/index.ts b/packages/kbn-management/settings/components/form/mocks/index.ts
new file mode 100644
index 0000000000000..80e92448a3bb4
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/mocks/index.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export { TestWrapper, createFormServicesMock, wrap } from './context';
+export { getSettingsMock } from './settings';
+export { uiSettingsClientMock } from './settings_client';
diff --git a/packages/kbn-management/settings/components/form/mocks/settings.ts b/packages/kbn-management/settings/components/form/mocks/settings.ts
new file mode 100644
index 0000000000000..e22f24e4a1a09
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/mocks/settings.ts
@@ -0,0 +1,114 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { KnownTypeToMetadata, SettingType } from '@kbn/management-settings-types';
+
+type Settings = {
+ [key in SettingType]: KnownTypeToMetadata;
+};
+
+/**
+ * A utility function returning a representative set of UiSettings.
+ * @param requirePageReload The value of the `requirePageReload` param for all settings.
+ */
+export const getSettingsMock = (requirePageReload: boolean = false): Settings => {
+ const defaults = {
+ requiresPageReload: requirePageReload,
+ readonly: false,
+ category: ['category'],
+ };
+
+ return {
+ array: {
+ description: 'Description for Array test setting',
+ name: 'array:test:setting',
+ type: 'array',
+ userValue: null,
+ value: ['example_value'],
+ ...defaults,
+ },
+ boolean: {
+ description: 'Description for Boolean test setting',
+ name: 'boolean:test:setting',
+ type: 'boolean',
+ userValue: null,
+ value: true,
+ ...defaults,
+ },
+ color: {
+ description: 'Description for Color test setting',
+ name: 'color:test:setting',
+ type: 'color',
+ userValue: null,
+ value: '#FF00CC',
+ ...defaults,
+ },
+ image: {
+ description: 'Description for Image test setting',
+ name: 'image:test:setting',
+ type: 'image',
+ userValue: null,
+ value: '',
+ ...defaults,
+ },
+ number: {
+ description: 'Description for Number test setting',
+ name: 'number:test:setting',
+ type: 'number',
+ userValue: null,
+ value: 1,
+ ...defaults,
+ },
+ json: {
+ name: 'json:test:setting',
+ description: 'Description for Json test setting',
+ type: 'json',
+ userValue: null,
+ value: '{"foo": "bar"}',
+ ...defaults,
+ },
+ markdown: {
+ name: 'markdown:test:setting',
+ description: 'Description for Markdown test setting',
+ type: 'markdown',
+ userValue: null,
+ value: '',
+ ...defaults,
+ },
+ select: {
+ description: 'Description for Select test setting',
+ name: 'select:test:setting',
+ options: ['apple', 'orange', 'banana'],
+ optionLabels: {
+ apple: 'Apple',
+ orange: 'Orange',
+ banana: 'Banana',
+ },
+ type: 'select',
+ userValue: null,
+ value: 'apple',
+ ...defaults,
+ },
+ string: {
+ description: 'Description for String test setting',
+ name: 'string:test:setting',
+ type: 'string',
+ userValue: null,
+ value: 'hello world',
+ ...defaults,
+ },
+ undefined: {
+ description: 'Description for Undefined test setting',
+ name: 'undefined:test:setting',
+ type: 'undefined',
+ userValue: null,
+ value: undefined,
+ ...defaults,
+ },
+ };
+};
diff --git a/packages/kbn-management/settings/components/form/mocks/settings_client.ts b/packages/kbn-management/settings/components/form/mocks/settings_client.ts
new file mode 100644
index 0000000000000..7087add88a943
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/mocks/settings_client.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
+
+/**
+ * Mock of the portion of the {@link IUiSettingsClient} used as a parameter in the {@link getFieldDefinitions} function.
+ */
+export const uiSettingsClientMock: Pick = {
+ isCustom: () => false,
+ isOverridden: () => false,
+};
diff --git a/packages/kbn-management/settings/components/form/package.json b/packages/kbn-management/settings/components/form/package.json
new file mode 100644
index 0000000000000..3e14acc7378dd
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@kbn/management-settings-components-form",
+ "private": true,
+ "version": "1.0.0",
+ "license": "SSPL-1.0 OR Elastic License 2.0"
+}
\ No newline at end of file
diff --git a/packages/kbn-management/settings/components/form/reload_page_toast.tsx b/packages/kbn-management/settings/components/form/reload_page_toast.tsx
new file mode 100644
index 0000000000000..a8414dd4ccbe8
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/reload_page_toast.tsx
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+
+import { i18n } from '@kbn/i18n';
+import { toMountPoint } from '@kbn/react-kibana-mount';
+import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { ToastInput } from '@kbn/core-notifications-browser';
+import { I18nStart } from '@kbn/core-i18n-browser';
+import { ThemeServiceStart } from '@kbn/core-theme-browser';
+
+export const DATA_TEST_SUBJ_PAGE_RELOAD_BUTTON = 'pageReloadButton';
+
+/**
+ * Utility function for returning a {@link ToastInput} for displaying a prompt for reloading the page.
+ * @param theme The {@link ThemeServiceStart} contract.
+ * @param i18nStart The {@link I18nStart} contract.
+ * @returns A toast.
+ */
+export const reloadPageToast = (theme: ThemeServiceStart, i18nStart: I18nStart): ToastInput => {
+ return {
+ title: i18n.translate('management.settings.form.requiresPageReloadToastDescription', {
+ defaultMessage: 'One or more settings require you to reload the page to take effect.',
+ }),
+ text: toMountPoint(
+
+
+ window.location.reload()}
+ data-test-subj={DATA_TEST_SUBJ_PAGE_RELOAD_BUTTON}
+ >
+ {i18n.translate('management.settings.form.requiresPageReloadToastButtonLabel', {
+ defaultMessage: 'Reload page',
+ })}
+
+
+ ,
+ { i18n: i18nStart, theme }
+ ),
+ color: 'success',
+ };
+};
diff --git a/packages/kbn-management/settings/components/form/services.tsx b/packages/kbn-management/settings/components/form/services.tsx
new file mode 100644
index 0000000000000..bdbfbdc88c33b
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/services.tsx
@@ -0,0 +1,78 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import {
+ FieldRowProvider,
+ FieldRowKibanaProvider,
+} from '@kbn/management-settings-components-field-row';
+import React, { FC, useContext } from 'react';
+import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
+
+import type { FormServices, FormKibanaDependencies, Services } from './types';
+import { reloadPageToast } from './reload_page_toast';
+
+const FormContext = React.createContext(null);
+
+/**
+ * Props for {@link FormProvider}.
+ */
+export interface FormProviderProps extends FormServices {
+ children: React.ReactNode;
+}
+
+/**
+ * React Provider that provides services to a {@link Form} component and its dependents.
+ */
+export const FormProvider = ({ children, ...services }: FormProviderProps) => {
+ const { saveChanges, showError, showReloadPagePrompt, ...rest } = services;
+
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * Kibana-specific Provider that maps Kibana plugins and services to a {@link FormProvider}.
+ */
+export const FormKibanaProvider: FC = ({ children, ...deps }) => {
+ const { settings, toasts, docLinks, theme, i18nStart } = deps;
+
+ return (
+ >) => {
+ const arr = Object.entries(changes).map(([key, value]) =>
+ settings.client.set(key, value.unsavedValue)
+ );
+ return Promise.all(arr);
+ },
+ showError: (message: string) => toasts.addDanger(message),
+ showReloadPagePrompt: () => toasts.add(reloadPageToast(theme, i18nStart)),
+ }}
+ >
+ {children}
+
+ );
+};
+
+/**
+ * React hook for accessing pre-wired services.
+ */
+export const useServices = () => {
+ const context = useContext(FormContext);
+
+ if (!context) {
+ throw new Error(
+ 'FormContext is missing. Ensure your component or React root is wrapped with FormProvider.'
+ );
+ }
+
+ return context;
+};
diff --git a/packages/kbn-management/settings/components/form/storybook/form.stories.tsx b/packages/kbn-management/settings/components/form/storybook/form.stories.tsx
new file mode 100644
index 0000000000000..5ba4a57d8a2f3
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/storybook/form.stories.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+import React from 'react';
+import { EuiPanel } from '@elastic/eui';
+import { action } from '@storybook/addon-actions';
+import { ComponentMeta } from '@storybook/react';
+import { FieldDefinition, SettingType } from '@kbn/management-settings-types';
+import { getFieldDefinitions } from '@kbn/management-settings-field-definition';
+import { getSettingsMock, uiSettingsClientMock } from '../mocks';
+import { Form as Component } from '../form';
+import { FormProvider } from '../services';
+
+export default {
+ title: `Settings/Form/Form`,
+ description: 'A form with field rows',
+ argTypes: {
+ isSavingEnabled: {
+ name: 'Saving is enabled?',
+ control: { type: 'boolean' },
+ },
+ requirePageReload: {
+ name: 'Settings require page reload?',
+ control: { type: 'boolean' },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+} as ComponentMeta;
+
+interface FormStoryProps {
+ /** True if saving settings is enabled, false otherwise. */
+ isSavingEnabled: boolean;
+ /** True if settings require page reload, false otherwise. */
+ requirePageReload: boolean;
+}
+
+export const Form = ({ isSavingEnabled, requirePageReload }: FormStoryProps) => {
+ const fields: Array> = getFieldDefinitions(
+ getSettingsMock(requirePageReload),
+ uiSettingsClientMock
+ );
+
+ return ;
+};
+
+Form.args = {
+ isSavingEnabled: true,
+ requirePageReload: false,
+};
diff --git a/packages/kbn-management/settings/components/form/tsconfig.json b/packages/kbn-management/settings/components/form/tsconfig.json
new file mode 100644
index 0000000000000..359e5560fd2e4
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "extends": "../../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "target/types",
+ "types": [
+ "jest",
+ "node",
+ "react",
+ "@testing-library/jest-dom",
+ ]
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ],
+ "exclude": [
+ "target/**/*"
+ ],
+ "kbn_references": [
+ "@kbn/management-settings-types",
+ "@kbn/management-settings-field-definition",
+ "@kbn/i18n",
+ "@kbn/i18n-react",
+ "@kbn/management-settings-components-field-row",
+ "@kbn/react-kibana-context-root",
+ "@kbn/core-theme-browser-mocks",
+ "@kbn/core-i18n-browser",
+ "@kbn/react-kibana-mount",
+ "@kbn/core-notifications-browser",
+ "@kbn/core-theme-browser",
+ "@kbn/core-ui-settings-browser",
+ "@kbn/management-settings-components-field-input",
+ ]
+}
diff --git a/packages/kbn-management/settings/components/form/types.ts b/packages/kbn-management/settings/components/form/types.ts
new file mode 100644
index 0000000000000..2e803cbd74cb5
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/types.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type {
+ FieldRowKibanaDependencies,
+ FieldRowServices,
+} from '@kbn/management-settings-components-field-row';
+import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
+import { SettingsStart } from '@kbn/core-ui-settings-browser';
+import { I18nStart } from '@kbn/core-i18n-browser';
+import { ThemeServiceStart } from '@kbn/core-theme-browser';
+import { ToastsStart } from '@kbn/core-notifications-browser';
+
+/**
+ * Contextual services used by a {@link Form} component.
+ */
+export interface Services {
+ saveChanges: (changes: Record>) => void;
+ showError: (message: string) => void;
+ showReloadPagePrompt: () => void;
+}
+
+/**
+ * Contextual services used by a {@link Form} component and its dependents.
+ */
+export type FormServices = FieldRowServices & Services;
+
+/**
+ * An interface containing a collection of Kibana plugins and services required to
+ * render a {@link Form} component.
+ */
+interface KibanaDependencies {
+ settings: {
+ client: SettingsStart['client'];
+ };
+ theme: ThemeServiceStart;
+ i18nStart: I18nStart;
+ /** The portion of the {@link ToastsStart} contract used by this component. */
+ toasts: Pick;
+}
+
+/**
+ * An interface containing a collection of Kibana plugins and services required to
+ * render a {@link Form} component and its dependents.
+ */
+export type FormKibanaDependencies = KibanaDependencies & FieldRowKibanaDependencies;
diff --git a/packages/kbn-management/settings/components/form/use_save.ts b/packages/kbn-management/settings/components/form/use_save.ts
new file mode 100644
index 0000000000000..ebd1981eb57d9
--- /dev/null
+++ b/packages/kbn-management/settings/components/form/use_save.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { FieldDefinition, SettingType } from '@kbn/management-settings-types';
+import { isEmpty } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { UnsavedFieldChange } from '@kbn/management-settings-types';
+import { useServices } from './services';
+
+export interface UseSaveParameters {
+ /** All {@link FieldDefinition} in the form. */
+ fields: Array>;
+ /** The function to invoke for clearing all unsaved changes. */
+ clearChanges: () => void;
+}
+
+/**
+ * Hook to provide a function that will save all given {@link UnsavedFieldChange}.
+ *
+ * @param params The {@link UseSaveParameters} to use.
+ * @returns A function that will save all {@link UnsavedFieldChange} that are passed as an argument.
+ */
+export const useSave = (params: UseSaveParameters) => {
+ const { saveChanges, showError, showReloadPagePrompt } = useServices();
+
+ return async (changes: Record>) => {
+ if (isEmpty(changes)) {
+ return;
+ }
+ try {
+ await saveChanges(changes);
+ params.clearChanges();
+ const requiresReload = params.fields.some(
+ (setting) => changes.hasOwnProperty(setting.id) && setting.requiresPageReload
+ );
+ if (requiresReload) {
+ showReloadPagePrompt();
+ }
+ } catch (e) {
+ showError(
+ i18n.translate('management.settings.form.saveErrorMessage', {
+ defaultMessage: 'Unable to save',
+ })
+ );
+ }
+ };
+};
diff --git a/packages/kbn-management/settings/field_definition/get_definitions.ts b/packages/kbn-management/settings/field_definition/get_definitions.ts
index c42613c8c2ce1..83d604db294cf 100644
--- a/packages/kbn-management/settings/field_definition/get_definitions.ts
+++ b/packages/kbn-management/settings/field_definition/get_definitions.ts
@@ -10,6 +10,8 @@ import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { FieldDefinition, SettingType, UiSettingMetadata } from '@kbn/management-settings-types';
import { getFieldDefinition } from './get_definition';
+type SettingsClient = Pick;
+
/**
* Convenience function to convert settings taken from a UiSettingsClient into
* {@link FieldDefinition} objects.
@@ -20,7 +22,7 @@ import { getFieldDefinition } from './get_definition';
*/
export const getFieldDefinitions = (
settings: Record>,
- client: IUiSettingsClient
+ client: SettingsClient
): Array> =>
Object.entries(settings).map(([id, setting]) =>
getFieldDefinition({
diff --git a/tsconfig.base.json b/tsconfig.base.json
index a33f7b1cd960b..08afa3562fdd2 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -962,6 +962,8 @@
"@kbn/management-settings-components-field-input/*": ["packages/kbn-management/settings/components/field_input/*"],
"@kbn/management-settings-components-field-row": ["packages/kbn-management/settings/components/field_row"],
"@kbn/management-settings-components-field-row/*": ["packages/kbn-management/settings/components/field_row/*"],
+ "@kbn/management-settings-components-form": ["packages/kbn-management/settings/components/form"],
+ "@kbn/management-settings-components-form/*": ["packages/kbn-management/settings/components/form/*"],
"@kbn/management-settings-field-definition": ["packages/kbn-management/settings/field_definition"],
"@kbn/management-settings-field-definition/*": ["packages/kbn-management/settings/field_definition/*"],
"@kbn/management-settings-ids": ["packages/kbn-management/settings/setting_ids"],
diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.ts
index 10225de1a1ad4..69080c22a13d0 100644
--- a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.ts
+++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.ts
@@ -858,6 +858,7 @@ export class LensAttributes {
dataType: 'number',
isBucketed: false,
label: label || 'Count of records',
+ customLabel: true,
operationType: 'count',
scale: 'ratio',
sourceField: RECORDS_FIELD,
diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts
index b13f072cc5e64..6fdb18750990e 100644
--- a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts
+++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts
@@ -14,6 +14,7 @@ import {
PERCENTILE,
ReportTypes,
FORMULA_COLUMN,
+ RECORDS_FIELD,
} from '../constants';
import {
CLS_LABEL,
@@ -124,8 +125,8 @@ export function getSyntheticsKPIConfig({ dataView }: ConfigProps): SeriesConfig
},
{
label: 'Total runs',
- id: 'monitor.check_group',
- field: 'monitor.check_group',
+ id: 'total_test_runs',
+ field: RECORDS_FIELD,
columnType: OPERATION_COLUMN,
columnFilters: [
{
diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts
index cae5b16a41584..ed0df54219a29 100644
--- a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts
+++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts
@@ -103,8 +103,9 @@ export function getSyntheticsSingleMetricConfig({ dataView }: ConfigProps): Seri
titlePosition: 'bottom',
},
columnType: FORMULA_COLUMN,
- formula: "unique_count(monitor.check_group, kql='summary: *')",
format: 'number',
+ field: RECORDS_FIELD,
+ columnFilter: { language: 'kuery', query: 'summary: *' },
},
{
id: 'monitor_successful',
@@ -114,9 +115,9 @@ export function getSyntheticsSingleMetricConfig({ dataView }: ConfigProps): Seri
metricStateOptions: {
titlePosition: 'bottom',
},
- columnType: FORMULA_COLUMN,
- formula: 'unique_count(monitor.check_group, kql=\'monitor.status: "up"\')',
format: 'number',
+ field: RECORDS_FIELD,
+ columnFilter: { language: 'kuery', query: 'summary.down: 0' },
},
{
id: 'monitor_errors',
diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts
index 5c09be2382425..9364977451b48 100644
--- a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts
+++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts
@@ -54,6 +54,7 @@ export const sampleAttributeCoreWebVital = {
sourceField: 'user_agent.os.name',
},
'y-axis-column-1': {
+ customLabel: true,
dataType: 'number',
filter: {
language: 'kuery',
@@ -67,6 +68,7 @@ export const sampleAttributeCoreWebVital = {
sourceField: RECORDS_FIELD,
},
'y-axis-column-2': {
+ customLabel: true,
dataType: 'number',
filter: {
language: 'kuery',
@@ -79,6 +81,7 @@ export const sampleAttributeCoreWebVital = {
sourceField: RECORDS_FIELD,
},
'y-axis-column-layer0-0': {
+ customLabel: true,
dataType: 'number',
filter: {
language: 'kuery',
diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts
index 49787f1304859..af5d7a91a541d 100644
--- a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts
+++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts
@@ -50,6 +50,7 @@ export const sampleAttributeKpi = {
},
isBucketed: false,
label: 'test-series',
+ customLabel: true,
operationType: 'count',
scale: 'ratio',
sourceField: RECORDS_FIELD,
diff --git a/x-pack/plugins/fleet/public/hooks/use_agent_version.test.ts b/x-pack/plugins/fleet/public/hooks/use_agent_version.test.ts
new file mode 100644
index 0000000000000..6cb1c8ee42248
--- /dev/null
+++ b/x-pack/plugins/fleet/public/hooks/use_agent_version.test.ts
@@ -0,0 +1,125 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { renderHook } from '@testing-library/react-hooks';
+
+import { useAgentVersion } from './use_agent_version';
+import { useKibanaVersion } from './use_kibana_version';
+import { sendGetAgentsAvailableVersions } from './use_request';
+
+jest.mock('./use_kibana_version');
+jest.mock('./use_request');
+
+describe('useAgentVersion', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return agent version that matches Kibana version if released', async () => {
+ const mockKibanaVersion = '8.8.1';
+ const mockAvailableVersions = ['8.9.0', '8.8.1', '8.8.0', '8.7.0'];
+
+ (useKibanaVersion as jest.Mock).mockReturnValue(mockKibanaVersion);
+ (sendGetAgentsAvailableVersions as jest.Mock).mockResolvedValue({
+ data: { items: mockAvailableVersions },
+ });
+
+ const { result, waitForNextUpdate } = renderHook(() => useAgentVersion());
+
+ expect(sendGetAgentsAvailableVersions).toHaveBeenCalled();
+
+ await waitForNextUpdate();
+
+ expect(result.current).toEqual(mockKibanaVersion);
+ });
+
+ it('should return the latest availeble agent version if a version that matches Kibana version is not released', async () => {
+ const mockKibanaVersion = '8.11.0';
+ const mockAvailableVersions = ['8.8.0', '8.7.0', '8.9.2', '7.16.0'];
+
+ (useKibanaVersion as jest.Mock).mockReturnValue(mockKibanaVersion);
+ (sendGetAgentsAvailableVersions as jest.Mock).mockResolvedValue({
+ data: { items: mockAvailableVersions },
+ });
+
+ const { result, waitForNextUpdate } = renderHook(() => useAgentVersion());
+
+ expect(sendGetAgentsAvailableVersions).toHaveBeenCalled();
+
+ await waitForNextUpdate();
+
+ expect(result.current).toEqual('8.9.2');
+ });
+
+ it('should return the agent version that is <= Kibana version if an agent version that matches Kibana version is not released', async () => {
+ const mockKibanaVersion = '8.8.3';
+ const mockAvailableVersions = ['8.8.0', '8.8.1', '8.8.2', '8.7.0', '8.9.2', '7.16.0'];
+
+ (useKibanaVersion as jest.Mock).mockReturnValue(mockKibanaVersion);
+ (sendGetAgentsAvailableVersions as jest.Mock).mockResolvedValue({
+ data: { items: mockAvailableVersions },
+ });
+
+ const { result, waitForNextUpdate } = renderHook(() => useAgentVersion());
+
+ expect(sendGetAgentsAvailableVersions).toHaveBeenCalled();
+
+ await waitForNextUpdate();
+
+ expect(result.current).toEqual('8.8.2');
+ });
+
+ it('should return the latest availeble agent version if a snapshot version', async () => {
+ const mockKibanaVersion = '8.10.0-SNAPSHOT';
+ const mockAvailableVersions = ['8.8.0', '8.7.0', '8.9.2', '7.16.0'];
+
+ (useKibanaVersion as jest.Mock).mockReturnValue(mockKibanaVersion);
+ (sendGetAgentsAvailableVersions as jest.Mock).mockResolvedValue({
+ data: { items: mockAvailableVersions },
+ });
+
+ const { result, waitForNextUpdate } = renderHook(() => useAgentVersion());
+
+ expect(sendGetAgentsAvailableVersions).toHaveBeenCalled();
+
+ await waitForNextUpdate();
+
+ expect(result.current).toEqual('8.9.2');
+ });
+
+ it('should return kibana version if no agent versions available', async () => {
+ const mockKibanaVersion = '8.11.0';
+ const mockAvailableVersions: string[] = [];
+
+ (useKibanaVersion as jest.Mock).mockReturnValue(mockKibanaVersion);
+ (sendGetAgentsAvailableVersions as jest.Mock).mockResolvedValue({
+ data: { items: mockAvailableVersions },
+ });
+
+ const { result, waitForNextUpdate } = renderHook(() => useAgentVersion());
+
+ expect(sendGetAgentsAvailableVersions).toHaveBeenCalled();
+
+ await waitForNextUpdate();
+
+ expect(result.current).toEqual('8.11.0');
+ });
+
+ it('should return kibana version if the list of available agent versions is not available', async () => {
+ const mockKibanaVersion = '8.11.0';
+
+ (useKibanaVersion as jest.Mock).mockReturnValue(mockKibanaVersion);
+ (sendGetAgentsAvailableVersions as jest.Mock).mockRejectedValue(new Error('Fetching error'));
+
+ const { result, waitForNextUpdate } = renderHook(() => useAgentVersion());
+
+ expect(sendGetAgentsAvailableVersions).toHaveBeenCalled();
+ await waitForNextUpdate();
+
+ expect(result.current).toEqual(mockKibanaVersion);
+ });
+});
diff --git a/x-pack/plugins/fleet/public/hooks/use_agent_version.ts b/x-pack/plugins/fleet/public/hooks/use_agent_version.ts
index 32d0ee128ddcc..8c198dbc7773e 100644
--- a/x-pack/plugins/fleet/public/hooks/use_agent_version.ts
+++ b/x-pack/plugins/fleet/public/hooks/use_agent_version.ts
@@ -4,14 +4,15 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-
import { useEffect, useState } from 'react';
+import semverRcompare from 'semver/functions/rcompare';
+import semverLt from 'semver/functions/lt';
import { useKibanaVersion } from './use_kibana_version';
import { sendGetAgentsAvailableVersions } from './use_request';
/**
- * @returns The most recent agent version available to install or upgrade to.
+ * @returns The most compatible agent version available to install or upgrade to.
*/
export const useAgentVersion = (): string | undefined => {
const kibanaVersion = useKibanaVersion();
@@ -21,12 +22,26 @@ export const useAgentVersion = (): string | undefined => {
const getVersions = async () => {
try {
const res = await sendGetAgentsAvailableVersions();
- // if the endpoint returns an error, use the fallback versions
- const versionsList = res?.data?.items ? res.data.items : [kibanaVersion];
+ const availableVersions = res?.data?.items;
+ let agentVersionToUse;
+
+ if (
+ availableVersions &&
+ availableVersions.length > 0 &&
+ availableVersions.indexOf(kibanaVersion) === -1
+ ) {
+ availableVersions.sort(semverRcompare);
+ agentVersionToUse =
+ availableVersions.find((version) => {
+ return semverLt(version, kibanaVersion);
+ }) || availableVersions[0];
+ } else {
+ agentVersionToUse = kibanaVersion;
+ }
- setAgentVersion(versionsList[0]);
+ setAgentVersion(agentVersionToUse);
} catch (err) {
- return;
+ setAgentVersion(kibanaVersion);
}
};
diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/index.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/index.tsx
index bfd7ac5149a88..f63cb3b7f6dc5 100644
--- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/index.tsx
+++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/index.tsx
@@ -18,7 +18,7 @@ export const MonitorForm: React.FC<{
defaultValues?: SyntheticsMonitor;
space?: string;
readOnly?: boolean;
- canUsePublicLocations: boolean;
+ canUsePublicLocations?: boolean;
}> = ({ children, defaultValues, space, readOnly = false, canUsePublicLocations }) => {
const methods = useFormWrapped({
mode: 'onSubmit',
diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx
index 4a8cca5410baa..fdda2247f15a4 100644
--- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx
+++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx
@@ -26,7 +26,7 @@ export const ActionBar = ({
canUsePublicLocations = true,
}: {
readOnly: boolean;
- canUsePublicLocations: boolean;
+ canUsePublicLocations?: boolean;
}) => {
const { monitorId } = useParams<{ monitorId: string }>();
const history = useHistory();
diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_stats/monitor_test_runs_sparkline.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_stats/monitor_test_runs_sparkline.tsx
index d8ba47ef7f39c..c2930a1d22ffb 100644
--- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_stats/monitor_test_runs_sparkline.tsx
+++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_stats/monitor_test_runs_sparkline.tsx
@@ -32,11 +32,11 @@ export const MonitorTestRunsSparkline = ({ monitorIds }: { monitorIds: string[]
'monitor.id': monitorIds.length > 0 ? monitorIds : ['false-monitor-id'], // Show no data when monitorIds is empty
},
dataType: 'synthetics' as const,
- selectedMetricField: 'monitor.check_group',
+ selectedMetricField: 'total_test_runs',
filters: [],
name: labels.TEST_RUNS_LABEL,
color: theme.eui.euiColorVis1,
- operationType: 'unique_count',
+ operationType: 'count',
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/x-pack/plugins/uptime/public/legacy_uptime/components/monitor/status_details/status_bar/monitor_redirects.tsx b/x-pack/plugins/uptime/public/legacy_uptime/components/monitor/status_details/status_bar/monitor_redirects.tsx
index deac2b712f61e..a0db4b2b6a32c 100644
--- a/x-pack/plugins/uptime/public/legacy_uptime/components/monitor/status_details/status_bar/monitor_redirects.tsx
+++ b/x-pack/plugins/uptime/public/legacy_uptime/components/monitor/status_details/status_bar/monitor_redirects.tsx
@@ -7,31 +7,32 @@
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
-import { EuiPopover } from '@elastic/eui';
-import styled from 'styled-components';
+import {
+ EuiPopover,
+ EuiDescriptionListTitle,
+ EuiDescriptionListDescription,
+ EuiButtonEmpty,
+} from '@elastic/eui';
import { Ping } from '../../../../../../common/runtime_types';
import { PingRedirects } from '../../ping_list/ping_redirects';
-import { MonListDescription, MonListTitle } from './status_bar';
interface Props {
monitorStatus: Ping | null;
}
-const RedirectBtn = styled.span`
- cursor: pointer;
-`;
-
export const MonitorRedirects: React.FC = ({ monitorStatus }) => {
const list = monitorStatus?.http?.response?.redirects;
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const button = (
-
-
+ setIsPopoverOpen(!isPopoverOpen)}
data-test-subj="uptimeMonitorRedirectInfo"
+ iconType="arrowDown"
+ iconSide="right"
>
{i18n.translate('xpack.uptime.monitorList.redirects.title.number', {
defaultMessage: '{number}',
@@ -39,13 +40,13 @@ export const MonitorRedirects: React.FC = ({ monitorStatus }) => {
number: list?.length ?? 0,
},
})}
-
-
+
+
);
return list ? (
<>
- Redirects
+ Redirects
{
- return server.config.service?.username === 'localKibanaIntegrationTestsUser';
-};
diff --git a/x-pack/plugins/uptime/server/legacy_uptime/routes/uptime_route_wrapper.ts b/x-pack/plugins/uptime/server/legacy_uptime/routes/uptime_route_wrapper.ts
index 3e92f73efab10..5beaa563790e8 100644
--- a/x-pack/plugins/uptime/server/legacy_uptime/routes/uptime_route_wrapper.ts
+++ b/x-pack/plugins/uptime/server/legacy_uptime/routes/uptime_route_wrapper.ts
@@ -7,7 +7,7 @@
import { KibanaResponse } from '@kbn/core-http-router-server-internal';
import { UMKibanaRouteWrapper } from './types';
-import { isTestUser, UptimeEsClient } from '../lib/lib';
+import { UptimeEsClient } from '../lib/lib';
export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) => ({
...uptimeRoute,
@@ -24,7 +24,7 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) =>
{
request,
uiSettings: coreContext.uiSettings,
- isDev: Boolean(server.isDev && !isTestUser(server)),
+ isDev: Boolean(server.isDev),
}
);
diff --git a/yarn.lock b/yarn.lock
index 84df6498ff250..7e1992e29ade1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4867,6 +4867,10 @@
version "0.0.0"
uid ""
+"@kbn/management-settings-components-form@link:packages/kbn-management/settings/components/form":
+ version "0.0.0"
+ uid ""
+
"@kbn/management-settings-field-definition@link:packages/kbn-management/settings/field_definition":
version "0.0.0"
uid ""
@@ -13964,7 +13968,7 @@ d3-brush@^3.0.0:
d3-selection "3"
d3-transition "3"
-d3-cloud@1.2.5, d3-cloud@^1.2.5:
+d3-cloud@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/d3-cloud/-/d3-cloud-1.2.5.tgz#3e91564f2d27fba47fcc7d812eb5081ea24c603d"
integrity sha512-4s2hXZgvs0CoUIw31oBAGrHt9Kt/7P9Ik5HIVzISFiWkD0Ga2VLAuO/emO/z1tYIpE7KG2smB4PhMPfFMJpahw==