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==