diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index 53a8e76b2131c81..f12abcde6966023 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -59,3 +59,4 @@ export const BASE_ALERTING_API_PATH = '/api/alerting'; export const INTERNAL_BASE_ALERTING_API_PATH = '/internal/alerting'; export const ALERTS_FEATURE_ID = 'alerts'; export const MONITORING_HISTORY_LIMIT = 200; +export const ENABLE_MAINTENANCE_WINDOWS = false; diff --git a/x-pack/plugins/alerting/kibana.jsonc b/x-pack/plugins/alerting/kibana.jsonc index 6fd3e2ba32dbf6c..4bfb08d4ee4cf58 100644 --- a/x-pack/plugins/alerting/kibana.jsonc +++ b/x-pack/plugins/alerting/kibana.jsonc @@ -20,7 +20,10 @@ "features", "kibanaUtils", "licensing", - "taskManager" + "taskManager", + "kibanaReact", + "management", + "esUiShared", ], "optionalPlugins": [ "usageCollection", diff --git a/x-pack/plugins/alerting/public/application/maintenance_windows.tsx b/x-pack/plugins/alerting/public/application/maintenance_windows.tsx new file mode 100644 index 000000000000000..88ac69cf82b662a --- /dev/null +++ b/x-pack/plugins/alerting/public/application/maintenance_windows.tsx @@ -0,0 +1,90 @@ +/* + * 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 React, { Suspense } from 'react'; +import ReactDOM from 'react-dom'; +import { Router, Switch } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Route } from '@kbn/shared-ux-router'; +import { CoreStart } from '@kbn/core/public'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; +import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { ManagementAppMountParams } from '@kbn/management-plugin/public'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { AlertingPluginStart } from '../plugin'; +import { paths } from '../config'; + +const MaintenanceWindowsLazy: React.FC = React.lazy(() => import('../pages/maintenance_windows')); +const MaintenanceWindowsCreateLazy: React.FC = React.lazy( + () => import('../pages/maintenance_windows/maintenance_window_create_page') +); + +const App = React.memo(() => { + return ( + <> + + + }> + + + + + }> + + + + + + ); +}); +App.displayName = 'App'; + +export const renderApp = ({ + core, + plugins, + mountParams, + kibanaVersion, +}: { + core: CoreStart; + plugins: AlertingPluginStart; + mountParams: ManagementAppMountParams; + kibanaVersion: string; +}) => { + const { element, history, theme$ } = mountParams; + const i18nCore = core.i18n; + const isDarkMode = core.uiSettings.get('theme:darkMode'); + + const queryClient = new QueryClient(); + + ReactDOM.render( + + + + + + + + + + + + + , + element + ); + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/x-pack/plugins/alerting/public/config/index.ts b/x-pack/plugins/alerting/public/config/index.ts new file mode 100644 index 000000000000000..e83ca0ad67909f2 --- /dev/null +++ b/x-pack/plugins/alerting/public/config/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export { paths, AlertingDeepLinkId, APP_ID, MAINTENANCE_WINDOWS_APP_ID } from './paths'; + +export type { IAlertingDeepLinkId } from './paths'; diff --git a/x-pack/plugins/alerting/public/config/paths.ts b/x-pack/plugins/alerting/public/config/paths.ts new file mode 100644 index 000000000000000..1fdfa48078c0ae1 --- /dev/null +++ b/x-pack/plugins/alerting/public/config/paths.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +export const MAINTENANCE_WINDOWS_APP_ID = 'maintenanceWindows'; +export const APP_ID = 'management'; + +export const paths = { + alerting: { + maintenanceWindows: `/${MAINTENANCE_WINDOWS_APP_ID}`, + maintenanceWindowsCreate: '/create', + }, +}; + +export const AlertingDeepLinkId = { + maintenanceWindows: MAINTENANCE_WINDOWS_APP_ID, + maintenanceWindowsCreate: 'create', +}; + +export type IAlertingDeepLinkId = typeof AlertingDeepLinkId[keyof typeof AlertingDeepLinkId]; diff --git a/x-pack/plugins/alerting/public/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/alerting/public/hooks/use_breadcrumbs.test.tsx new file mode 100644 index 000000000000000..fb42a59db0fe2ef --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_breadcrumbs.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 { useBreadcrumbs } from './use_breadcrumbs'; +import { AlertingDeepLinkId } from '../config'; +import { AppMockRenderer, createAppMockRenderer } from '../lib/test_utils'; + +const mockSetBreadcrumbs = jest.fn(); +const mockSetTitle = jest.fn(); + +jest.mock('../utils/kibana_react', () => { + const originalModule = jest.requireActual('../utils/kibana_react'); + return { + ...originalModule, + useKibana: () => { + const { services } = originalModule.useKibana(); + return { + services: { + ...services, + chrome: { setBreadcrumbs: mockSetBreadcrumbs, docTitle: { change: mockSetTitle } }, + }, + }; + }, + }; +}); + +jest.mock('./use_navigation', () => { + const originalModule = jest.requireActual('./use_navigation'); + return { + ...originalModule, + useNavigation: jest.fn().mockReturnValue({ + getAppUrl: jest.fn((params?: { deepLinkId: string }) => params?.deepLinkId ?? '/test'), + }), + }; +}); + +let appMockRenderer: AppMockRenderer; + +describe('useBreadcrumbs', () => { + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + test('set maintenance windows breadcrumbs', () => { + renderHook(() => useBreadcrumbs(AlertingDeepLinkId.maintenanceWindows), { + wrapper: appMockRenderer.AppWrapper, + }); + expect(mockSetBreadcrumbs).toHaveBeenCalledWith([ + { href: '/test', onClick: expect.any(Function), text: 'Stack Management' }, + { text: 'Maintenance Windows' }, + ]); + }); + + test('set create maintenance windows breadcrumbs', () => { + renderHook(() => useBreadcrumbs(AlertingDeepLinkId.maintenanceWindowsCreate), { + wrapper: appMockRenderer.AppWrapper, + }); + expect(mockSetBreadcrumbs).toHaveBeenCalledWith([ + { href: '/test', onClick: expect.any(Function), text: 'Stack Management' }, + { + href: AlertingDeepLinkId.maintenanceWindows, + onClick: expect.any(Function), + text: 'Maintenance Windows', + }, + { text: 'Create' }, + ]); + }); +}); diff --git a/x-pack/plugins/alerting/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/alerting/public/hooks/use_breadcrumbs.ts new file mode 100644 index 000000000000000..d33ce68bd0b1b43 --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_breadcrumbs.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ChromeBreadcrumb } from '@kbn/core/public'; +import { MouseEvent, useEffect } from 'react'; +import { useKibana } from '../utils/kibana_react'; +import { useNavigation } from './use_navigation'; +import { APP_ID, AlertingDeepLinkId, IAlertingDeepLinkId } from '../config'; + +const breadcrumbTitle: Record = { + [AlertingDeepLinkId.maintenanceWindows]: i18n.translate( + 'xpack.alerting.breadcrumbs.maintenanceWindowsLinkText', + { + defaultMessage: 'Maintenance Windows', + } + ), + [AlertingDeepLinkId.maintenanceWindowsCreate]: i18n.translate( + 'xpack.alerting.breadcrumbs.createMaintenanceWindowsLinkText', + { + defaultMessage: 'Create', + } + ), +}; + +const topLevelBreadcrumb: Record = { + [AlertingDeepLinkId.maintenanceWindowsCreate]: AlertingDeepLinkId.maintenanceWindows, +}; + +function addClickHandlers( + breadcrumbs: ChromeBreadcrumb[], + navigateToHref?: (url: string) => Promise +) { + return breadcrumbs.map((bc) => ({ + ...bc, + ...(bc.href + ? { + onClick: (event: MouseEvent) => { + if (navigateToHref && bc.href) { + event.preventDefault(); + navigateToHref(bc.href); + } + }, + } + : {}), + })); +} + +function getTitleFromBreadCrumbs(breadcrumbs: ChromeBreadcrumb[]) { + return breadcrumbs.map(({ text }) => text?.toString() ?? '').reverse(); +} + +export const useBreadcrumbs = (pageDeepLink: IAlertingDeepLinkId) => { + const { + services: { + chrome: { docTitle, setBreadcrumbs }, + application: { navigateToUrl }, + }, + } = useKibana(); + const setTitle = docTitle.change; + const { getAppUrl } = useNavigation(APP_ID); + + useEffect(() => { + const breadcrumbs = [ + { + text: i18n.translate('xpack.alerting.breadcrumbs.stackManagementLinkText', { + defaultMessage: 'Stack Management', + }), + href: getAppUrl(), + }, + ...(topLevelBreadcrumb[pageDeepLink] + ? [ + { + text: breadcrumbTitle[topLevelBreadcrumb[pageDeepLink]], + href: getAppUrl({ deepLinkId: topLevelBreadcrumb[pageDeepLink] }), + }, + ] + : []), + { + text: breadcrumbTitle[pageDeepLink], + }, + ]; + + if (setBreadcrumbs) { + setBreadcrumbs(addClickHandlers(breadcrumbs, navigateToUrl)); + } + if (setTitle) { + setTitle(getTitleFromBreadCrumbs(breadcrumbs)); + } + }, [pageDeepLink, getAppUrl, navigateToUrl, setBreadcrumbs, setTitle]); +}; diff --git a/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.test.tsx b/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.test.tsx new file mode 100644 index 000000000000000..195af1bb083e595 --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { act, renderHook } from '@testing-library/react-hooks/dom'; +import { waitFor } from '@testing-library/dom'; + +import { MaintenanceWindow } from '../pages/maintenance_windows/types'; +import { AppMockRenderer, createAppMockRenderer } from '../lib/test_utils'; +import { useCreateMaintenanceWindow } from './use_create_maintenance_window'; + +const mockAddDanger = jest.fn(); +const mockAddSuccess = jest.fn(); + +jest.mock('../utils/kibana_react', () => { + const originalModule = jest.requireActual('../utils/kibana_react'); + return { + ...originalModule, + useKibana: () => { + const { services } = originalModule.useKibana(); + return { + services: { + ...services, + notifications: { toasts: { addSuccess: mockAddSuccess, addDanger: mockAddDanger } }, + }, + }; + }, + }; +}); +jest.mock('../services/maintenance_windows_api/create', () => ({ + createMaintenanceWindow: jest.fn(), +})); + +const { createMaintenanceWindow } = jest.requireMock('../services/maintenance_windows_api/create'); + +const maintenanceWindow: MaintenanceWindow = { + title: 'test', + duration: 1, + rRule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + freq: 3, + interval: 1, + byweekday: ['TH'], + }, +}; + +let appMockRenderer: AppMockRenderer; + +describe('useCreateMaintenanceWindow', () => { + beforeEach(() => { + jest.clearAllMocks(); + + appMockRenderer = createAppMockRenderer(); + createMaintenanceWindow.mockResolvedValue(maintenanceWindow); + }); + + it('should call onSuccess if api succeeds', async () => { + const { result } = renderHook(() => useCreateMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate(maintenanceWindow); + }); + await waitFor(() => expect(mockAddSuccess).toBeCalledWith("Created maintenance window 'test'")); + }); + + it('should call onError if api fails', async () => { + createMaintenanceWindow.mockRejectedValue(''); + + const { result } = renderHook(() => useCreateMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate(maintenanceWindow); + }); + + await waitFor(() => + expect(mockAddDanger).toBeCalledWith('Failed to create maintenance window.') + ); + }); +}); diff --git a/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.ts b/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.ts new file mode 100644 index 000000000000000..08c01bb080055e3 --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.ts @@ -0,0 +1,44 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { useMutation } from '@tanstack/react-query'; + +import { useKibana } from '../utils/kibana_react'; +import { MaintenanceWindow } from '../pages/maintenance_windows/types'; +import { createMaintenanceWindow } from '../services/maintenance_windows_api/create'; + +export function useCreateMaintenanceWindow() { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const mutationFn = (maintenanceWindow: MaintenanceWindow) => { + return createMaintenanceWindow({ http, maintenanceWindow }); + }; + + return useMutation(mutationFn, { + onSuccess: (variables: MaintenanceWindow) => { + toasts.addSuccess( + i18n.translate('xpack.alerting.maintenanceWindowsCreateSuccess', { + defaultMessage: "Created maintenance window '{title}'", + values: { + title: variables.title, + }, + }) + ); + }, + onError: () => { + toasts.addDanger( + i18n.translate('xpack.alerting.maintenanceWindowsCreateFailure', { + defaultMessage: 'Failed to create maintenance window.', + }) + ); + }, + }); +} diff --git a/x-pack/plugins/alerting/public/hooks/use_navigation.test.tsx b/x-pack/plugins/alerting/public/hooks/use_navigation.test.tsx new file mode 100644 index 000000000000000..abf7a27f595a121 --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_navigation.test.tsx @@ -0,0 +1,93 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; + +import { + useCreateMaintenanceWindowNavigation, + useMaintenanceWindowsNavigation, +} from './use_navigation'; +import { AppMockRenderer, createAppMockRenderer } from '../lib/test_utils'; +import { APP_ID, MAINTENANCE_WINDOWS_APP_ID } from '../config'; + +const mockNavigateTo = jest.fn(); +const mockGetAppUrl = jest.fn(); + +jest.mock('../utils/kibana_react', () => { + const originalModule = jest.requireActual('../utils/kibana_react'); + return { + ...originalModule, + useKibana: () => { + const { services } = originalModule.useKibana(); + return { + services: { + ...services, + application: { getUrlForApp: mockGetAppUrl, navigateToApp: mockNavigateTo }, + }, + }; + }, + }; +}); + +let appMockRenderer: AppMockRenderer; + +describe('useNavigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + describe('useMaintenanceWindowsNavigation', () => { + it('it calls getMaintenanceWindowsUrl with correct arguments', () => { + const { result } = renderHook(() => useMaintenanceWindowsNavigation(), { + wrapper: appMockRenderer.AppWrapper, + }); + + act(() => { + result.current.getMaintenanceWindowsUrl(false); + }); + + expect(mockGetAppUrl).toHaveBeenCalledWith(APP_ID, { + absolute: false, + path: '/', + deepLinkId: MAINTENANCE_WINDOWS_APP_ID, + }); + }); + + it('it calls navigateToMaintenanceWindows with correct arguments', () => { + const { result } = renderHook(() => useMaintenanceWindowsNavigation(), { + wrapper: appMockRenderer.AppWrapper, + }); + + act(() => { + result.current.navigateToMaintenanceWindows(); + }); + + expect(mockNavigateTo).toHaveBeenCalledWith(APP_ID, { + path: '/', + deepLinkId: MAINTENANCE_WINDOWS_APP_ID, + }); + }); + }); + + describe('useCreateMaintenanceWindowNavigation', () => { + it('it calls navigateToCreateMaintenanceWindow with correct arguments', () => { + const { result } = renderHook(() => useCreateMaintenanceWindowNavigation(), { + wrapper: appMockRenderer.AppWrapper, + }); + + act(() => { + result.current.navigateToCreateMaintenanceWindow(); + }); + + expect(mockNavigateTo).toHaveBeenCalledWith(APP_ID, { + deepLinkId: MAINTENANCE_WINDOWS_APP_ID, + path: '/create', + }); + }); + }); +}); diff --git a/x-pack/plugins/alerting/public/hooks/use_navigation.ts b/x-pack/plugins/alerting/public/hooks/use_navigation.ts new file mode 100644 index 000000000000000..12f64dbdcad3085 --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_navigation.ts @@ -0,0 +1,55 @@ +/* + * 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 { useCallback } from 'react'; +import type { NavigateToAppOptions } from '@kbn/core/public'; +import { useKibana } from '../utils/kibana_react'; +import { paths, APP_ID, MAINTENANCE_WINDOWS_APP_ID } from '../config'; + +export const useNavigation = (appId: string) => { + const { navigateToApp, getUrlForApp } = useKibana().services.application; + + const navigateTo = useCallback( + ({ ...options }: NavigateToAppOptions) => { + navigateToApp(appId, options); + }, + [appId, navigateToApp] + ); + const getAppUrl = useCallback( + (options?: { deepLinkId?: string; path?: string; absolute?: boolean }) => + getUrlForApp(appId, options), + [appId, getUrlForApp] + ); + return { navigateTo, getAppUrl }; +}; + +export const useCreateMaintenanceWindowNavigation = () => { + const { navigateTo } = useNavigation(APP_ID); + return { + navigateToCreateMaintenanceWindow: () => + navigateTo({ + path: paths.alerting.maintenanceWindowsCreate, + deepLinkId: MAINTENANCE_WINDOWS_APP_ID, + }), + }; +}; + +export const useMaintenanceWindowsNavigation = () => { + const { navigateTo, getAppUrl } = useNavigation(APP_ID); + const path = '/'; + const deepLinkId = MAINTENANCE_WINDOWS_APP_ID; + + return { + navigateToMaintenanceWindows: () => navigateTo({ path, deepLinkId }), + getMaintenanceWindowsUrl: (absolute?: boolean) => + getAppUrl({ + path, + deepLinkId, + absolute, + }), + }; +}; diff --git a/x-pack/plugins/alerting/public/index.ts b/x-pack/plugins/alerting/public/index.ts index 7c61ea1b6b1fd36..671934faeecb5b6 100644 --- a/x-pack/plugins/alerting/public/index.ts +++ b/x-pack/plugins/alerting/public/index.ts @@ -5,10 +5,11 @@ * 2.0. */ +import type { PluginInitializerContext } from '@kbn/core/public'; import { AlertingPublicPlugin } from './plugin'; export type { PluginSetupContract, PluginStartContract } from './plugin'; export type { AlertNavigationHandler } from './alert_navigation_registry'; -export function plugin() { - return new AlertingPublicPlugin(); +export function plugin(initializerContext: PluginInitializerContext) { + return new AlertingPublicPlugin(initializerContext); } diff --git a/x-pack/plugins/alerting/public/lib/test_utils.tsx b/x-pack/plugins/alerting/public/lib/test_utils.tsx new file mode 100644 index 000000000000000..2dfd1f37066bf27 --- /dev/null +++ b/x-pack/plugins/alerting/public/lib/test_utils.tsx @@ -0,0 +1,75 @@ +/* + * 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 React from 'react'; +import { of } from 'rxjs'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { I18nProvider } from '@kbn/i18n-react'; +import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react'; +import { CoreStart } from '@kbn/core/public'; +import { coreMock } from '@kbn/core/public/mocks'; +import { euiDarkVars } from '@kbn/ui-theme'; + +/* eslint-disable no-console */ + +type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; + +export interface AppMockRenderer { + render: UiRender; + coreStart: CoreStart; + queryClient: QueryClient; + AppWrapper: React.FC<{ children: React.ReactElement }>; +} + +export const createAppMockRenderer = (): AppMockRenderer => { + const theme$ = of({ eui: euiDarkVars, darkMode: true }); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + /** + * React query prints the errors in the console even though + * all tests are passings. We turn them off for testing. + */ + logger: { + log: console.log, + warn: console.warn, + error: () => {}, + }, + }); + const core = coreMock.createStart(); + const services = { ...core }; + const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => ( + + + + {children} + + + + )); + + AppWrapper.displayName = 'AppWrapper'; + + const render: UiRender = (ui, options) => { + return reactRender(ui, { + wrapper: AppWrapper, + ...options, + }); + }; + + return { + coreStart: services, + render, + queryClient, + AppWrapper, + }; +}; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.test.tsx new file mode 100644 index 000000000000000..df382e57c69d000 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 React from 'react'; +import { within } from '@testing-library/react'; +import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils'; +import { + CreateMaintenanceWindowFormProps, + CreateMaintenanceWindowForm, +} from './create_maintenance_windows_form'; + +const formProps: CreateMaintenanceWindowFormProps = { + onCancel: jest.fn(), + onSuccess: jest.fn(), +}; + +describe('CreateMaintenanceWindowForm', () => { + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders all form fields except the recurring form fields', async () => { + const result = appMockRenderer.render(); + + expect(result.getByTestId('title-field')).toBeInTheDocument(); + expect(result.getByTestId('date-field')).toBeInTheDocument(); + expect(result.getByTestId('recurring-field')).toBeInTheDocument(); + expect(result.queryByTestId('recurring-form')).not.toBeInTheDocument(); + }); + + it('should initialize the form when no initialValue provided', () => { + const result = appMockRenderer.render(); + + const titleInput = within(result.getByTestId('title-field')).getByTestId('input'); + const dateInputs = within(result.getByTestId('date-field')).getAllByLabelText( + // using the aria-label to query for the date-picker input + 'Press the down key to open a popover containing a calendar.' + ); + const recurringInput = within(result.getByTestId('recurring-field')).getByTestId('input'); + + expect(titleInput).toHaveValue(''); + // except for the date field + expect(dateInputs[0]).not.toHaveValue(''); + expect(dateInputs[1]).not.toHaveValue(''); + expect(recurringInput).not.toBeChecked(); + }); + + it('should prefill the form when provided with initialValue', () => { + const result = appMockRenderer.render( + + ); + + const titleInput = within(result.getByTestId('title-field')).getByTestId('input'); + const dateInputs = within(result.getByTestId('date-field')).getAllByLabelText( + // using the aria-label to query for the date-picker input + 'Press the down key to open a popover containing a calendar.' + ); + const recurringInput = within(result.getByTestId('recurring-field')).getByTestId('input'); + + expect(titleInput).toHaveValue('test'); + expect(dateInputs[0]).toHaveValue('03/24/2023 12:00 AM'); + expect(dateInputs[1]).toHaveValue('03/26/2023 12:00 AM'); + expect(recurringInput).toBeChecked(); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx new file mode 100644 index 000000000000000..6778907ade0728e --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx @@ -0,0 +1,159 @@ +/* + * 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 React, { useCallback, useState } from 'react'; +import moment from 'moment'; +import { + Form, + getUseField, + useForm, + useFormData, + UseMultiFields, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; + +import { FormProps, schema } from './schema'; +import * as i18n from '../translations'; +import { RecurringSchedule } from './recurring_schedule_form/recurring_schedule'; +import { SubmitButton } from './submit_button'; +import { convertToRRule } from '../helpers/convert_to_rrule'; +import { useCreateMaintenanceWindow } from '../../../hooks/use_create_maintenance_window'; +import { useUiSetting } from '../../../utils/kibana_react'; +import { DatePickerRangeField } from './fields/date_picker_range_field'; + +const UseField = getUseField({ component: Field }); + +export interface CreateMaintenanceWindowFormProps { + onCancel: () => void; + onSuccess: () => void; + initialValue?: FormProps; +} + +export const useTimeZone = (): string => { + const timeZone = useUiSetting('dateFormat:tz'); + return timeZone === 'Browser' ? moment.tz.guess() : timeZone; +}; + +export const CreateMaintenanceWindowForm = React.memo( + ({ onCancel, onSuccess, initialValue }) => { + const [defaultDateValue] = useState(moment().toISOString()); + const timezone = useTimeZone(); + + const { mutate: createMaintenanceWindow } = useCreateMaintenanceWindow(); + + const submitMaintenanceWindow = useCallback( + async (formData, isValid) => { + if (isValid) { + const startDate = moment(formData.startDate); + const endDate = moment(formData.endDate); + await createMaintenanceWindow( + { + title: formData.title, + duration: endDate.diff(startDate), + rRule: convertToRRule(startDate, timezone, formData.recurringSchedule), + }, + { onSuccess } + ); + } + }, + [createMaintenanceWindow, onSuccess, timezone] + ); + + const { form } = useForm({ + defaultValue: initialValue, + options: { stripEmptyFields: false }, + schema, + onSubmit: submitMaintenanceWindow, + }); + + const [{ recurring }] = useFormData({ + form, + watch: ['recurring'], + }); + const isRecurring = recurring || false; + + return ( +
+ + + + + + + + + + + {(fields) => ( + + )} + + + + + + + + + {isRecurring ? : null} + + + + + + + + + {i18n.CANCEL} + + + + + + + + ); + } +); +CreateMaintenanceWindowForm.displayName = 'CreateMaintenanceWindowForm'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.test.tsx new file mode 100644 index 000000000000000..614e0de265cdaa5 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { docLinksServiceMock } from '@kbn/core-doc-links-server-mocks'; + +import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils'; +import { EmptyPrompt } from './empty_prompt'; + +describe('EmptyPrompt', () => { + let appMockRenderer: AppMockRenderer; + + const docLinks = docLinksServiceMock.createStartContract().links; + const handleClickCreate = () => {}; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + test('it renders', () => { + const result = appMockRenderer.render( + + ); + + expect(result.getByText('Create your first maintenance window')).toBeInTheDocument(); + expect( + result.getByText('Schedule a time period in which rule notifications cease.') + ).toBeInTheDocument(); + }); + + test('it renders an action button when showCreateButton is provided', () => { + const result = appMockRenderer.render( + + ); + + expect(result.getByRole('button')).toBeInTheDocument(); + }); + + test('it does not render an action button when showCreateButton is not provided', () => { + const result = appMockRenderer.render( + + ); + + expect(result.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.tsx new file mode 100644 index 000000000000000..63e7a36c74e0696 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.tsx @@ -0,0 +1,48 @@ +/* + * 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 React, { useMemo } from 'react'; +import { EuiButton, EuiButtonEmpty, EuiPageTemplate } from '@elastic/eui'; +import { DocLinks } from '@kbn/doc-links'; +import * as i18n from '../translations'; + +interface EmptyPromptProps { + onClickCreate: () => void; + docLinks: DocLinks; + showCreateButton?: boolean; +} + +const emptyTitle =

{i18n.EMPTY_PROMPT_TITLE}

; +const emptyBody =

{i18n.EMPTY_PROMPT_DESCRIPTION}

; + +export const EmptyPrompt = React.memo( + ({ onClickCreate, showCreateButton = true, docLinks }) => { + const renderActions = useMemo(() => { + if (showCreateButton) { + return [ + + {i18n.EMPTY_PROMPT_BUTTON} + , + + {i18n.EMPTY_PROMPT_DOCUMENTATION} + , + ]; + } + return null; + }, [showCreateButton, onClickCreate, docLinks]); + + return ( + + ); + } +); +EmptyPrompt.displayName = 'EmptyPrompt'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/fields/button_group_field.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/fields/button_group_field.tsx new file mode 100644 index 000000000000000..65c7e85b79c7a59 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/fields/button_group_field.tsx @@ -0,0 +1,76 @@ +/* + * 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 React, { useCallback } from 'react'; +import { EuiButtonGroup, EuiButtonGroupOptionProps, EuiFormRow } from '@elastic/eui'; +import { + useFormData, + useFormContext, + FieldHook, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { get } from 'lodash'; + +interface ButtonGroupFieldProps { + field: FieldHook; + legend: string; + options: EuiButtonGroupOptionProps[]; + type?: 'single' | 'multi'; + 'data-test-subj'?: string; +} + +export const ButtonGroupField: React.FC = React.memo( + ({ field, legend, options, type = 'single', ...rest }) => { + const { setFieldValue } = useFormContext(); + const [formData] = useFormData({ watch: [field.path] }); + const selected = get(formData, field.path); + + const onChange = useCallback( + (current: string) => { + setFieldValue(field.path, current); + }, + [setFieldValue, field.path] + ); + + const onChangeMulti = useCallback( + (current: string) => { + const newSelectedValue = { ...selected, [current]: !selected[current] }; + // Don't allow the user to deselect all options + if (!Object.values(newSelectedValue).every((v) => v === false)) { + setFieldValue(field.path, newSelectedValue); + } + }, + [setFieldValue, selected, field.path] + ); + + return ( + + {type === 'multi' ? ( + + ) : ( + + )} + + ); + } +); +ButtonGroupField.displayName = 'ButtonGroupField'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/fields/date_picker_field.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/fields/date_picker_field.tsx new file mode 100644 index 000000000000000..e9f0d6ddea0a401 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/fields/date_picker_field.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { Moment } from 'moment'; +import { EuiDatePicker, EuiFormRow } from '@elastic/eui'; +import { + useFormData, + useFormContext, + FieldHook, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { getSelectedForDatePicker as getSelected } from '../../helpers/get_selected_for_date_picker'; + +interface DatePickerFieldProps { + field: FieldHook; + showTimeSelect?: boolean; + 'data-test-subj'?: string; +} + +export const DatePickerField: React.FC = React.memo( + ({ field, showTimeSelect = true, ...rest }) => { + const { setFieldValue } = useFormContext(); + const [form] = useFormData({ watch: [field.path] }); + + const selected = getSelected(form, field.path); + + const onChange = useCallback( + (currentDate: Moment | null) => { + // convert the moment date back into a string if it's not null + setFieldValue(field.path, currentDate ? currentDate.toISOString() : currentDate); + }, + [setFieldValue, field.path] + ); + + return ( + + + + ); + } +); +DatePickerField.displayName = 'DatePickerField'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/fields/date_picker_range_field.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/fields/date_picker_range_field.tsx new file mode 100644 index 000000000000000..91b657500e23b29 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/fields/date_picker_range_field.tsx @@ -0,0 +1,84 @@ +/* + * 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 React, { useCallback } from 'react'; +import { Moment } from 'moment'; +import { EuiDatePicker, EuiDatePickerRange, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import { + useFormData, + useFormContext, + FieldHook, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import * as i18n from '../../translations'; +import { getSelectedForDatePicker as getSelected } from '../../helpers/get_selected_for_date_picker'; + +interface DatePickerRangeFieldProps { + fields: { startDate: FieldHook; endDate: FieldHook }; + showTimeSelect?: boolean; + 'data-test-subj'?: string; +} + +export const DatePickerRangeField: React.FC = React.memo( + ({ fields, showTimeSelect = true, ...rest }) => { + const { setFieldValue } = useFormContext(); + const [form] = useFormData({ watch: [fields.startDate.path, fields.endDate.path] }); + + const startDate = getSelected(form, fields.startDate.path); + const endDate = getSelected(form, fields.endDate.path); + + const onChange = useCallback( + (currentDate: Moment | null, path: string) => { + // convert the moment date back into a string if it's not null + setFieldValue(path, currentDate ? currentDate.toISOString() : currentDate); + }, + [setFieldValue] + ); + const isInvalid = startDate.isAfter(endDate); + + return ( + <> + + date && onChange(date, fields.startDate.path)} + startDate={startDate} + endDate={endDate} + aria-label="Start date" + showTimeSelect={showTimeSelect} + minDate={startDate} + /> + } + endDateControl={ + date && onChange(date, fields.endDate.path)} + startDate={startDate} + endDate={endDate} + aria-label="End date" + showTimeSelect={showTimeSelect} + minDate={startDate} + /> + } + fullWidth + /> + + {isInvalid ? ( + <> + + + {i18n.CREATE_FORM_SCHEDULE_INVALID} + + + ) : null} + + ); + } +); +DatePickerRangeField.displayName = 'DatePickerRangeField'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/link_icon.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/link_icon.test.tsx new file mode 100644 index 000000000000000..9d1ad779471cbf6 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/link_icon.test.tsx @@ -0,0 +1,72 @@ +/* + * 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 React from 'react'; + +import { LinkIcon } from './link_icon'; +import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils'; + +describe('LinkIcon', () => { + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + test('it renders', () => { + const result = appMockRenderer.render( + + {'Test link'} + + ); + + expect(result.getByText('Test link')).toBeInTheDocument(); + }); + + test('it renders an action button when onClick is provided', () => { + const result = appMockRenderer.render( + alert('Test alert')}> + {'Test link'} + + ); + + expect(result.getByRole('button')).toBeInTheDocument(); + }); + + test('it renders an icon', () => { + const result = appMockRenderer.render({'Test link'}); + + expect(result.getByTestId('link-icon')).toBeInTheDocument(); + }); + + test('it positions the icon to the right when iconSide is right', () => { + const result = appMockRenderer.render( + + {'Test link'} + + ); + + expect(result.getByRole('button')).toHaveStyle({ 'flex-direction': 'row-reverse' }); + }); + + test('it positions the icon to the left when iconSide is left (or not provided)', () => { + const result = appMockRenderer.render( + + {'Test link'} + + ); + + expect(result.getByRole('button')).toHaveStyle({ 'flex-direction': 'row' }); + }); + + test('it renders a label', () => { + const result = appMockRenderer.render({'Test link'}); + + expect(result.getByTestId('link-icon-label')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/link_icon.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/link_icon.tsx new file mode 100644 index 000000000000000..c89f67e63aa026f --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/link_icon.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IconSize, IconType } from '@elastic/eui'; +import { EuiIcon, EuiLink } from '@elastic/eui'; +import type { LinkAnchorProps } from '@elastic/eui/src/components/link/link'; +import type { ReactNode } from 'react'; +import React from 'react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { css } from '@emotion/react'; + +function getStyles(iconSide: string) { + return { + link: css` + align-items: center; + display: inline-flex; + vertical-align: top; + white-space: nowrap; + flex-direction: ${iconSide === 'left' ? 'row' : 'row-reverse'}; + `, + leftSide: css` + margin-right: ${euiThemeVars.euiSizeXS}; + `, + rightSide: css` + flex-direction: row-reverse; + + .euiIcon { + margin-left: ${euiThemeVars.euiSizeXS}; + } + `, + }; +} + +export interface LinkIconProps { + children: string | ReactNode; + iconSize?: IconSize; + iconType: IconType; + dataTestSubj?: string; + ariaLabel?: string; + color?: LinkAnchorProps['color']; + disabled?: boolean; + iconSide?: 'left' | 'right'; + onClick?: () => void; +} + +export const LinkIcon = React.memo( + ({ + ariaLabel, + children, + color, + dataTestSubj, + disabled, + iconSide = 'left', + iconSize = 's', + iconType, + onClick, + ...rest + }) => { + const styles = getStyles(iconSide); + + return ( + + + {children} + + ); + } +); +LinkIcon.displayName = 'LinkIcon'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/page_header.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/page_header.test.tsx new file mode 100644 index 000000000000000..fea66d0148d062f --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/page_header.test.tsx @@ -0,0 +1,47 @@ +/* + * 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 React from 'react'; + +import { PageHeader } from './page_header'; +import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils'; + +describe('PageHeader', () => { + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + test('it renders', () => { + const result = appMockRenderer.render( + + ); + + expect(result.getByText('Test title')).toBeInTheDocument(); + expect(result.getByText('test description')).toBeInTheDocument(); + }); + + test('it does not render the description when not provided', () => { + const result = appMockRenderer.render(); + + expect(result.queryByTestId('description')).not.toBeInTheDocument(); + }); + + test('it renders the back link when provided', () => { + const result = appMockRenderer.render(); + + expect(result.getByTestId('link-back')).toBeInTheDocument(); + }); + + test('it does not render the back link when not provided', () => { + const result = appMockRenderer.render(); + + expect(result.queryByTestId('link-back')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/page_header.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/page_header.tsx new file mode 100644 index 000000000000000..6c2d85afecd7192 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/page_header.tsx @@ -0,0 +1,81 @@ +/* + * 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 React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { css } from '@emotion/react'; + +import { LinkIcon } from './link_icon'; +import * as i18n from '../translations'; +import { TruncatedText } from './truncated_text'; +import { useMaintenanceWindowsNavigation } from '../../../hooks/use_navigation'; + +export const styles = { + linkBack: css` + font-size: ${euiThemeVars.euiFontSizeXS}; + line-height: ${euiThemeVars.euiLineHeight}; + margin-bottom: ${euiThemeVars.euiSizeS}; + `, +}; + +interface TitleProps { + title: string; + description?: string; +} +const Title = React.memo(({ title, description }) => { + return ( + + + +

{}

+
+
+ {description ? ( + <> + + + + + + ) : null} +
+ ); +}); +Title.displayName = 'Title'; + +export interface PageHeaderProps { + title: string; + showBackButton?: boolean; + description?: string; +} + +export const PageHeader = React.memo( + ({ showBackButton = false, title, description }) => { + const { navigateToMaintenanceWindows } = useMaintenanceWindowsNavigation(); + + const navigateToMaintenanceWindowsClick = useCallback(() => { + navigateToMaintenanceWindows(); + }, [navigateToMaintenanceWindows]); + + return ( + + + {showBackButton && ( +
+ + {i18n.MAINTENANCE_WINDOWS_RETURN_LINK} + +
+ )} + + </EuiFlexItem> + </EuiFlexGroup> + ); + } +); +PageHeader.displayName = 'PageHeader'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/custom_recurring_schedule.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/custom_recurring_schedule.test.tsx new file mode 100644 index 000000000000000..67f2af2d5df2ce7 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/custom_recurring_schedule.test.tsx @@ -0,0 +1,152 @@ +/* + * 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 React from 'react'; +import { fireEvent, waitFor, within } from '@testing-library/react'; +import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { AppMockRenderer, createAppMockRenderer } from '../../../../lib/test_utils'; +import { FormProps, schema } from '../schema'; +import { CustomRecurringSchedule } from './custom_recurring_schedule'; +import { EndsOptions, Frequency } from '../../constants'; + +const initialValue: FormProps = { + title: 'test', + startDate: '2023-03-24', + endDate: '2023-03-26', + recurring: true, + recurringSchedule: { + frequency: 'CUSTOM', + ends: EndsOptions.NEVER, + }, +}; + +describe('CustomRecurringSchedule', () => { + let appMockRenderer: AppMockRenderer; + + const MockHookWrapperComponent: React.FC<{ iv?: FormProps }> = ({ + children, + iv = initialValue, + }) => { + const { form } = useForm<FormProps>({ + defaultValue: iv, + options: { stripEmptyFields: false }, + schema, + }); + + return <Form form={form}>{children}</Form>; + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders all form fields', async () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <CustomRecurringSchedule /> + </MockHookWrapperComponent> + ); + + expect(result.getByTestId('interval-field')).toBeInTheDocument(); + expect(result.getByTestId('custom-frequency-field')).toBeInTheDocument(); + expect(result.getByTestId('byweekday-field')).toBeInTheDocument(); + expect(result.queryByTestId('bymonth-field')).not.toBeInTheDocument(); + }); + + it('renders byweekday field if custom frequency = weekly', async () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <CustomRecurringSchedule /> + </MockHookWrapperComponent> + ); + + fireEvent.change(within(result.getByTestId('custom-frequency-field')).getByTestId('select'), { + target: { value: Frequency.WEEKLY }, + }); + await waitFor(() => expect(result.getByTestId('byweekday-field')).toBeInTheDocument()); + }); + + it('renders byweekday field if frequency = daily', async () => { + const iv: FormProps = { + ...initialValue, + recurringSchedule: { + frequency: Frequency.DAILY, + ends: EndsOptions.NEVER, + }, + }; + const result = appMockRenderer.render( + <MockHookWrapperComponent iv={iv}> + <CustomRecurringSchedule /> + </MockHookWrapperComponent> + ); + + expect(result.getByTestId('byweekday-field')).toBeInTheDocument(); + }); + + it('renders bymonth field if custom frequency = monthly', async () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <CustomRecurringSchedule /> + </MockHookWrapperComponent> + ); + + fireEvent.change(within(result.getByTestId('custom-frequency-field')).getByTestId('select'), { + target: { value: Frequency.MONTHLY }, + }); + await waitFor(() => expect(result.getByTestId('bymonth-field')).toBeInTheDocument()); + }); + + it('should initialize the form when no initialValue provided', () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <CustomRecurringSchedule /> + </MockHookWrapperComponent> + ); + + const frequencyInput = within(result.getByTestId('custom-frequency-field')).getByTestId( + 'select' + ); + const intervalInput = within(result.getByTestId('interval-field')).getByTestId('input'); + + expect(frequencyInput).toHaveValue('2'); + expect(intervalInput).toHaveValue(1); + }); + + it('should prefill the form when provided with initialValue', () => { + const iv: FormProps = { + ...initialValue, + recurringSchedule: { + frequency: 'CUSTOM', + ends: EndsOptions.NEVER, + customFrequency: Frequency.WEEKLY, + interval: 3, + byweekday: { 1: false, 2: false, 3: true, 4: true, 5: false, 6: false, 7: false }, + }, + }; + const result = appMockRenderer.render( + <MockHookWrapperComponent iv={iv}> + <CustomRecurringSchedule /> + </MockHookWrapperComponent> + ); + + const frequencyInput = within(result.getByTestId('custom-frequency-field')).getByTestId( + 'select' + ); + const intervalInput = within(result.getByTestId('interval-field')).getByTestId('input'); + const input3 = within(result.getByTestId('byweekday-field')) + .getByTestId('3') + .getAttribute('aria-pressed'); + const input4 = within(result.getByTestId('byweekday-field')) + .getByTestId('4') + .getAttribute('aria-pressed'); + expect(frequencyInput).toHaveValue('2'); + expect(intervalInput).toHaveValue(3); + expect(input3).toBe('true'); + expect(input4).toBe('true'); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/custom_recurring_schedule.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/custom_recurring_schedule.tsx new file mode 100644 index 000000000000000..8bf9b7d24eba553 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/custom_recurring_schedule.tsx @@ -0,0 +1,132 @@ +/* + * 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 React, { useMemo } from 'react'; +import moment from 'moment'; +import { css } from '@emotion/react'; +import { getUseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiFlexGroup, EuiFlexItem, EuiFormLabel, EuiSpacer } from '@elastic/eui'; +import { CREATE_FORM_CUSTOM_FREQUENCY, Frequency, WEEKDAY_OPTIONS } from '../../constants'; +import * as i18n from '../../translations'; +import { ButtonGroupField } from '../fields/button_group_field'; +import { getInitialByWeekday } from '../../helpers/get_initial_by_weekday'; +import { getWeekdayInfo } from '../../helpers/get_weekday_info'; +import { FormProps } from '../schema'; + +const UseField = getUseField({ component: Field }); + +const styles = { + flexField: css` + .euiFormRow__labelWrapper { + margin-bottom: unset; + } + `, +}; + +export const CustomRecurringSchedule: React.FC = React.memo(() => { + const [{ startDate, recurringSchedule }] = useFormData<FormProps>({ + watch: [ + 'startDate', + 'recurringSchedule.frequency', + 'recurringSchedule.interval', + 'recurringSchedule.customFrequency', + ], + }); + + const frequencyOptions = useMemo( + () => CREATE_FORM_CUSTOM_FREQUENCY(recurringSchedule?.interval), + [recurringSchedule?.interval] + ); + + const bymonthOptions = useMemo(() => { + if (!startDate) return []; + const date = moment(startDate); + const { dayOfWeek, nthWeekdayOfMonth, isLastOfMonth } = getWeekdayInfo(date, 'ddd'); + return [ + { + id: 'day', + label: i18n.CREATE_FORM_CUSTOM_REPEAT_MONTHLY_ON_DAY(date), + }, + { + id: 'weekday', + label: i18n.CREATE_FORM_WEEKDAY_SHORT(dayOfWeek)[isLastOfMonth ? 0 : nthWeekdayOfMonth], + }, + ]; + }, [startDate]); + + const defaultByWeekday = useMemo(() => getInitialByWeekday([], moment(startDate)), [startDate]); + + return ( + <> + {recurringSchedule?.frequency !== Frequency.DAILY ? ( + <> + <EuiSpacer size="s" /> + <EuiFlexGroup gutterSize="s" alignItems="flexStart"> + <EuiFlexItem> + <UseField + path="recurringSchedule.interval" + css={styles.flexField} + componentProps={{ + 'data-test-subj': 'interval-field', + id: 'interval', + euiFieldProps: { + min: 1, + prepend: ( + <EuiFormLabel htmlFor={'interval'}> + {i18n.CREATE_FORM_INTERVAL_EVERY} + </EuiFormLabel> + ), + }, + }} + /> + </EuiFlexItem> + <EuiFlexItem> + <UseField + path="recurringSchedule.customFrequency" + componentProps={{ + 'data-test-subj': 'custom-frequency-field', + euiFieldProps: { + options: frequencyOptions, + }, + }} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="s" /> + </> + ) : null} + {recurringSchedule?.customFrequency === Frequency.WEEKLY || + recurringSchedule?.frequency === Frequency.DAILY ? ( + <UseField + path="recurringSchedule.byweekday" + config={{ label: '', validations: [], defaultValue: defaultByWeekday }} + component={ButtonGroupField} + componentProps={{ + 'data-test-subj': 'byweekday-field', + legend: 'Repeat on weekday', + options: WEEKDAY_OPTIONS, + type: 'multi', + }} + /> + ) : null} + + {recurringSchedule?.customFrequency === Frequency.MONTHLY ? ( + <UseField + path="recurringSchedule.bymonth" + config={{ label: '', validations: [], defaultValue: 'day' }} + component={ButtonGroupField} + componentProps={{ + 'data-test-subj': 'bymonth-field', + legend: 'Repeat on weekday or month day', + options: bymonthOptions, + }} + /> + ) : null} + </> + ); +}); +CustomRecurringSchedule.displayName = 'CustomRecurringSchedule'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/recurring_schedule.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/recurring_schedule.test.tsx new file mode 100644 index 000000000000000..8f8cfbf87f71893 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/recurring_schedule.test.tsx @@ -0,0 +1,123 @@ +/* + * 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 React from 'react'; +import { fireEvent, within } from '@testing-library/react'; +import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { AppMockRenderer, createAppMockRenderer } from '../../../../lib/test_utils'; +import { FormProps, schema } from '../schema'; +import { RecurringSchedule } from './recurring_schedule'; +import { EndsOptions, Frequency } from '../../constants'; + +const initialValue: FormProps = { + title: 'test', + startDate: '2023-03-24', + endDate: '2023-03-26', + recurring: true, +}; + +describe('RecurringSchedule', () => { + let appMockRenderer: AppMockRenderer; + + const MockHookWrapperComponent: React.FC<{ iv?: FormProps }> = ({ + children, + iv = initialValue, + }) => { + const { form } = useForm<FormProps>({ + defaultValue: iv, + options: { stripEmptyFields: false }, + schema, + }); + + return <Form form={form}>{children}</Form>; + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders all form fields', async () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <RecurringSchedule /> + </MockHookWrapperComponent> + ); + + expect(result.getByTestId('frequency-field')).toBeInTheDocument(); + expect(result.queryByTestId('custom-recurring-form')).not.toBeInTheDocument(); + expect(result.getByTestId('ends-field')).toBeInTheDocument(); + expect(result.queryByTestId('until-field')).not.toBeInTheDocument(); + expect(result.queryByTestId('count-field')).not.toBeInTheDocument(); + }); + + it('renders until field if ends = on_date', async () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <RecurringSchedule /> + </MockHookWrapperComponent> + ); + + const btn = within(result.getByTestId('ends-field')).getByTestId('ondate'); + + fireEvent.click(btn); + expect(result.getByTestId('until-field')).toBeInTheDocument(); + }); + + it('renders until field if ends = after_x', async () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <RecurringSchedule /> + </MockHookWrapperComponent> + ); + + const btn = within(result.getByTestId('ends-field')).getByTestId('afterx'); + + fireEvent.click(btn); + expect(result.getByTestId('count-field')).toBeInTheDocument(); + }); + + it('should initialize the form when no initialValue provided', () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <RecurringSchedule /> + </MockHookWrapperComponent> + ); + + const frequencyInput = within(result.getByTestId('frequency-field')).getByTestId('select'); + const endsInput = within(result.getByTestId('ends-field')).getByTestId('never'); + + expect(frequencyInput).toHaveValue('3'); + expect(endsInput).toBeChecked(); + }); + + it('should prefill the form when provided with initialValue', () => { + const iv: FormProps = { + ...initialValue, + recurringSchedule: { + frequency: Frequency.MONTHLY, + ends: EndsOptions.ON_DATE, + until: '2023-03-24', + }, + }; + const result = appMockRenderer.render( + <MockHookWrapperComponent iv={iv}> + <RecurringSchedule /> + </MockHookWrapperComponent> + ); + + const frequencyInput = within(result.getByTestId('frequency-field')).getByTestId('select'); + const endsInput = within(result.getByTestId('ends-field')).getByTestId('ondate'); + const untilInput = within(result.getByTestId('until-field')).getByLabelText( + // using the aria-label to query for the date-picker input + 'Press the down key to open a popover containing a calendar.' + ); + expect(frequencyInput).toHaveValue('1'); + expect(endsInput).toBeChecked(); + expect(untilInput).toHaveValue('03/24/2023'); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/recurring_schedule.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/recurring_schedule.tsx new file mode 100644 index 000000000000000..4b8b5cf8b0791fc --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/recurring_schedule_form/recurring_schedule.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo } from 'react'; +import moment from 'moment'; +import { EuiFormLabel, EuiHorizontalRule, EuiSplitPanel } from '@elastic/eui'; +import { getUseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { getWeekdayInfo } from '../../helpers/get_weekday_info'; +import { + DEFAULT_FREQUENCY_OPTIONS, + DEFAULT_PRESETS, + EndsOptions, + Frequency, + RECURRENCE_END_OPTIONS, +} from '../../constants'; +import * as i18n from '../../translations'; +import { ButtonGroupField } from '../fields/button_group_field'; +import { DatePickerField } from '../fields/date_picker_field'; +import { CustomRecurringSchedule } from './custom_recurring_schedule'; +import { recurringSummary } from '../../helpers/recurring_summary'; +import { getPresets } from '../../helpers/get_presets'; +import { FormProps } from '../schema'; + +const UseField = getUseField({ component: Field }); + +export const RecurringSchedule: React.FC = React.memo(() => { + const [{ startDate, recurringSchedule }] = useFormData<FormProps>({ + watch: [ + 'startDate', + 'recurringSchedule.frequency', + 'recurringSchedule.interval', + 'recurringSchedule.ends', + 'recurringSchedule.until', + 'recurringSchedule.count', + 'recurringSchedule.customFrequency', + 'recurringSchedule.byweekday', + 'recurringSchedule.bymonth', + ], + }); + + const { options, presets } = useMemo(() => { + if (!startDate) { + return { options: DEFAULT_FREQUENCY_OPTIONS, presets: DEFAULT_PRESETS }; + } + const date = moment(startDate); + const { dayOfWeek, nthWeekdayOfMonth, isLastOfMonth } = getWeekdayInfo(date); + return { + options: [ + { + text: i18n.CREATE_FORM_FREQUENCY_DAILY, + value: Frequency.DAILY, + }, + { + text: i18n.CREATE_FORM_FREQUENCY_WEEKLY_ON(dayOfWeek), + value: Frequency.WEEKLY, + }, + { + text: i18n.CREATE_FORM_FREQUENCY_NTH_WEEKDAY(dayOfWeek)[ + isLastOfMonth ? 0 : nthWeekdayOfMonth + ], + value: Frequency.MONTHLY, + }, + { + text: i18n.CREATE_FORM_FREQUENCY_YEARLY_ON(date), + value: Frequency.YEARLY, + }, + { + text: i18n.CREATE_FORM_FREQUENCY_CUSTOM, + value: 'CUSTOM', + }, + ], + presets: getPresets(date), + }; + }, [startDate]); + + return ( + <EuiSplitPanel.Outer hasShadow={false} hasBorder={true}> + <EuiSplitPanel.Inner color="subdued"> + <UseField + path="recurringSchedule.frequency" + componentProps={{ + 'data-test-subj': 'frequency-field', + euiFieldProps: { + options, + }, + }} + /> + {recurringSchedule?.frequency === Frequency.DAILY || + recurringSchedule?.frequency === 'CUSTOM' ? ( + <CustomRecurringSchedule data-test-subj="custom-recurring-form" /> + ) : null} + <UseField + path="recurringSchedule.ends" + component={ButtonGroupField} + componentProps={{ + 'data-test-subj': 'ends-field', + legend: 'Recurrence ends', + options: RECURRENCE_END_OPTIONS, + }} + /> + {recurringSchedule?.ends === EndsOptions.ON_DATE ? ( + <UseField + path="recurringSchedule.until" + component={DatePickerField} + componentProps={{ + 'data-test-subj': 'until-field', + showTimeSelect: false, + }} + /> + ) : null} + {recurringSchedule?.ends === EndsOptions.AFTER_X ? ( + <UseField + path="recurringSchedule.count" + componentProps={{ + 'data-test-subj': 'count-field', + id: 'count', + euiFieldProps: { + type: 'number', + min: 1, + prepend: ( + <EuiFormLabel htmlFor={'count'}>{i18n.CREATE_FORM_COUNT_AFTER}</EuiFormLabel> + ), + append: ( + <EuiFormLabel htmlFor={'count'}>{i18n.CREATE_FORM_COUNT_OCCURRENCE}</EuiFormLabel> + ), + }, + }} + /> + ) : null} + </EuiSplitPanel.Inner> + <EuiHorizontalRule margin="none" /> + <EuiSplitPanel.Inner> + {i18n.CREATE_FORM_RECURRING_SUMMARY_PREFIX( + recurringSummary(moment(startDate), recurringSchedule, presets) + )} + </EuiSplitPanel.Inner> + </EuiSplitPanel.Outer> + ); +}); +RecurringSchedule.displayName = 'RecurringSchedule'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/schema.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/schema.ts new file mode 100644 index 000000000000000..c81fdbaa01dbec8 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/schema.ts @@ -0,0 +1,98 @@ +/* + * 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 moment from 'moment'; +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; + +import * as i18n from '../translations'; +import { EndsOptions, Frequency } from '../constants'; + +const { emptyField } = fieldValidators; + +export interface FormProps { + title: string; + startDate: string; + endDate: string; + recurring: boolean; + recurringSchedule?: RecurringScheduleFormProps; +} + +export interface RecurringScheduleFormProps { + frequency: Frequency | 'CUSTOM'; + interval?: number; + ends: string; + until?: string; + count?: number; + customFrequency?: Frequency; + byweekday?: Record<string, boolean>; + bymonth?: string; +} + +export const schema: FormSchema<FormProps> = { + title: { + type: FIELD_TYPES.TEXT, + label: i18n.CREATE_FORM_NAME, + validations: [ + { + validator: emptyField(i18n.CREATE_FORM_NAME_REQUIRED), + }, + ], + }, + startDate: {}, + endDate: {}, + recurring: { + type: FIELD_TYPES.TOGGLE, + label: i18n.CREATE_FORM_REPEAT, + defaultValue: false, + }, + recurringSchedule: { + frequency: { + type: FIELD_TYPES.SELECT, + label: i18n.CREATE_FORM_REPEAT, + defaultValue: Frequency.DAILY, + }, + interval: { + type: FIELD_TYPES.NUMBER, + label: '', + defaultValue: 1, + validations: [ + { + validator: emptyField(i18n.CREATE_FORM_INTERVAL_REQUIRED), + }, + ], + }, + ends: { + label: i18n.CREATE_FORM_ENDS, + defaultValue: EndsOptions.NEVER, + validations: [], + }, + until: { + label: '', + defaultValue: moment().endOf('day').toISOString(), + validations: [], + }, + count: { + label: '', + type: FIELD_TYPES.TEXT, + defaultValue: 1, + validations: [ + { + validator: emptyField(i18n.CREATE_FORM_COUNT_REQUIRED), + }, + ], + }, + customFrequency: { + type: FIELD_TYPES.SELECT, + label: '', + defaultValue: Frequency.WEEKLY, + }, + byweekday: {}, + bymonth: {}, + }, +}; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/submit_button.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/submit_button.test.tsx new file mode 100644 index 000000000000000..c208312a78f2b8c --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/submit_button.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; + +import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { SubmitButton } from './submit_button'; +import type { FormProps } from './schema'; +import { schema } from './schema'; +import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils'; + +describe('SubmitButton', () => { + const onSubmit = jest.fn(); + + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm<FormProps>({ + defaultValue: { title: 'title' }, + schema: { + title: schema.title, + }, + onSubmit, + }); + + return <Form form={form}>{children}</Form>; + }; + + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('it renders', async () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <SubmitButton /> + </MockHookWrapperComponent> + ); + + expect(result.getByTestId('create-submit')).toBeInTheDocument(); + }); + + it('it submits', async () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <SubmitButton /> + </MockHookWrapperComponent> + ); + + fireEvent.click(result.getByTestId('create-submit')); + await waitFor(() => expect(onSubmit).toBeCalled()); + }); + + it('it disables when submitting', async () => { + const result = appMockRenderer.render( + <MockHookWrapperComponent> + <SubmitButton /> + </MockHookWrapperComponent> + ); + + fireEvent.click(result.getByTestId('create-submit')); + await waitFor(() => expect(result.getByTestId('create-submit')).toBeDisabled()); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/submit_button.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/submit_button.tsx new file mode 100644 index 000000000000000..1e48d96026e7839 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/submit_button.tsx @@ -0,0 +1,29 @@ +/* + * 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 React from 'react'; +import { EuiButton } from '@elastic/eui'; + +import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import * as i18n from '../translations'; + +export const SubmitButton: React.FC = React.memo(() => { + const { submit, isSubmitting } = useFormContext(); + + return ( + <EuiButton + data-test-subj="create-submit" + fill + isDisabled={isSubmitting} + isLoading={isSubmitting} + onClick={submit} + > + {i18n.CREATE_MAINTENANCE_WINDOW} + </EuiButton> + ); +}); +SubmitButton.displayName = 'SubmitButton'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/truncated_text.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/truncated_text.test.tsx new file mode 100644 index 000000000000000..0a117f0cabb39fe --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/truncated_text.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 React from 'react'; + +import { TruncatedText } from './truncated_text'; +import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils'; + +describe('TruncatedText', () => { + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + test('it renders', () => { + const result = appMockRenderer.render(<TruncatedText text="Test text" />); + + const text = result.getByText('Test text'); + expect(text).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/truncated_text.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/truncated_text.tsx new file mode 100644 index 000000000000000..147e1eede8fa2b5 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/truncated_text.tsx @@ -0,0 +1,31 @@ +/* + * 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 { css } from '@emotion/react'; +import React from 'react'; + +const LINE_CLAMP = 3; + +export const styles = { + truncatedText: css` + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + `, +}; + +interface TruncatedTextProps { + text: string; +} + +export const TruncatedText = React.memo<TruncatedTextProps>(({ text }) => { + return <div css={styles.truncatedText}>{text}</div>; +}); +TruncatedText.displayName = 'TruncatedText'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/constants.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/constants.ts new file mode 100644 index 000000000000000..2b06ff8fd41a51b --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/constants.ts @@ -0,0 +1,107 @@ +/* + * 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 { invert, mapValues } from 'lodash'; +import moment from 'moment'; +import * as i18n from './translations'; + +// TODO - consolidate enum with backend +export enum Frequency { + YEARLY = '0', + MONTHLY = '1', + WEEKLY = '2', + DAILY = '3', +} + +export const DEFAULT_FREQUENCY_OPTIONS = [ + { + text: i18n.CREATE_FORM_FREQUENCY_DAILY, + value: Frequency.DAILY, + }, + { + text: i18n.CREATE_FORM_FREQUENCY_WEEKLY, + value: Frequency.WEEKLY, + }, + { + text: i18n.CREATE_FORM_FREQUENCY_MONTHLY, + value: Frequency.MONTHLY, + }, + { + text: i18n.CREATE_FORM_FREQUENCY_YEARLY, + value: Frequency.YEARLY, + }, + { + text: i18n.CREATE_FORM_FREQUENCY_CUSTOM, + value: 'CUSTOM', + }, +]; + +export const DEFAULT_PRESETS = { + [Frequency.DAILY]: { + interval: 1, + }, + [Frequency.WEEKLY]: { + interval: 1, + }, + [Frequency.MONTHLY]: { + interval: 1, + }, + [Frequency.YEARLY]: { + interval: 1, + }, +}; + +export enum EndsOptions { + NEVER = 'never', + ON_DATE = 'ondate', + AFTER_X = 'afterx', +} + +export const RECURRENCE_END_OPTIONS = [ + { id: 'never', label: i18n.CREATE_FORM_ENDS_NEVER }, + { id: 'ondate', label: i18n.CREATE_FORM_ENDS_ON_DATE }, + { id: 'afterx', label: i18n.CREATE_FORM_ENDS_AFTER_X }, +]; + +export const CREATE_FORM_CUSTOM_FREQUENCY = (interval: number = 1) => [ + { + text: i18n.CREATE_FORM_CUSTOM_FREQUENCY_DAILY(interval), + value: Frequency.DAILY, + }, + { + text: i18n.CREATE_FORM_CUSTOM_FREQUENCY_WEEKLY(interval), + value: Frequency.WEEKLY, + }, + { + text: i18n.CREATE_FORM_CUSTOM_FREQUENCY_MONTHLY(interval), + value: Frequency.MONTHLY, + }, + { + text: i18n.CREATE_FORM_CUSTOM_FREQUENCY_YEARLY(interval), + value: Frequency.YEARLY, + }, +]; + +export const ISO_WEEKDAYS = [1, 2, 3, 4, 5, 6, 7]; + +export const WEEKDAY_OPTIONS = ISO_WEEKDAYS.map((n) => ({ + id: String(n), + label: moment().isoWeekday(n).format('ddd'), +})); + +export const ISO_WEEKDAYS_TO_RRULE: Record<number, string> = { + 1: 'MO', + 2: 'TU', + 3: 'WE', + 4: 'TH', + 5: 'FR', + 6: 'SA', + 7: 'SU', +}; + +export const RRULE_WEEKDAYS_TO_ISO_WEEKDAYS = mapValues(invert(ISO_WEEKDAYS_TO_RRULE), (v) => + Number(v) +); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/convert_to_rrule.test.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/convert_to_rrule.test.ts new file mode 100644 index 000000000000000..737bb47f4aa3374 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/convert_to_rrule.test.ts @@ -0,0 +1,216 @@ +/* + * 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 moment from 'moment'; + +import { Frequency } from '../constants'; +import { RRuleFrequency } from '../types'; +import { convertToRRule } from './convert_to_rrule'; + +describe('convertToRRule', () => { + const timezone = 'UTC'; + const today = '2023-03-22'; + const startDate = moment(today); + + test('should convert a maintenance window that is not recurring', () => { + const rRule = convertToRRule(startDate, timezone, undefined); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.YEARLY, + count: 1, + }); + }); + + test('should convert a maintenance window that is recurring on a daily schedule', () => { + const rRule = convertToRRule(startDate, timezone, { + byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, + ends: 'never', + frequency: Frequency.DAILY, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.DAILY, + interval: 1, + byweekday: ['WE'], + }); + }); + + test('should convert a maintenance window that is recurring on a daily schedule until', () => { + const until = moment(today).add(1, 'month').toISOString(); + const rRule = convertToRRule(startDate, timezone, { + byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, + ends: 'until', + until, + frequency: Frequency.DAILY, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.DAILY, + interval: 1, + byweekday: ['WE'], + until, + }); + }); + + test('should convert a maintenance window that is recurring on a daily schedule after x', () => { + const rRule = convertToRRule(startDate, timezone, { + byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, + ends: 'afterx', + count: 3, + frequency: Frequency.DAILY, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.DAILY, + interval: 1, + byweekday: ['WE'], + count: 3, + }); + }); + + test('should convert a maintenance window that is recurring on a weekly schedule', () => { + const rRule = convertToRRule(startDate, timezone, { + ends: 'never', + frequency: Frequency.WEEKLY, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.WEEKLY, + interval: 1, + byweekday: ['WE'], + }); + }); + + test('should convert a maintenance window that is recurring on a monthly schedule', () => { + const rRule = convertToRRule(startDate, timezone, { + ends: 'never', + frequency: Frequency.MONTHLY, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.MONTHLY, + interval: 1, + byweekday: ['+4WE'], + }); + }); + + test('should convert a maintenance window that is recurring on a yearly schedule', () => { + const rRule = convertToRRule(startDate, timezone, { + ends: 'never', + frequency: Frequency.YEARLY, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.YEARLY, + interval: 1, + bymonth: [2], + bymonthday: [22], + }); + }); + + test('should convert a maintenance window that is recurring on a custom daily schedule', () => { + const rRule = convertToRRule(startDate, timezone, { + customFrequency: Frequency.DAILY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.DAILY, + interval: 1, + }); + }); + + test('should convert a maintenance window that is recurring on a custom weekly schedule', () => { + const rRule = convertToRRule(startDate, timezone, { + byweekday: { 1: false, 2: false, 3: true, 4: true, 5: false, 6: false, 7: false }, + customFrequency: Frequency.WEEKLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.WEEKLY, + interval: 1, + byweekday: ['WE', 'TH'], + }); + }); + + test('should convert a maintenance window that is recurring on a custom monthly by day schedule', () => { + const rRule = convertToRRule(startDate, timezone, { + bymonth: 'day', + customFrequency: Frequency.MONTHLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.MONTHLY, + interval: 1, + bymonthday: [22], + }); + }); + + test('should convert a maintenance window that is recurring on a custom monthly by weekday schedule', () => { + const rRule = convertToRRule(startDate, timezone, { + bymonth: 'weekday', + customFrequency: Frequency.MONTHLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.MONTHLY, + interval: 1, + byweekday: ['+4WE'], + }); + }); + + test('should convert a maintenance window that is recurring on a custom yearly schedule', () => { + const rRule = convertToRRule(startDate, timezone, { + customFrequency: Frequency.YEARLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 3, + }); + + expect(rRule).toEqual({ + dtstart: startDate.toISOString(), + tzid: 'UTC', + freq: RRuleFrequency.YEARLY, + interval: 3, + bymonth: [2], + bymonthday: [22], + }); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/convert_to_rrule.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/convert_to_rrule.ts new file mode 100644 index 000000000000000..0f20d1c3353659e --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/convert_to_rrule.ts @@ -0,0 +1,77 @@ +/* + * 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 { Moment } from 'moment'; +import { RRule, RRuleFrequency, RRuleFrequencyMap } from '../types'; +import { Frequency, ISO_WEEKDAYS_TO_RRULE } from '../constants'; +import { getNthByWeekday } from './get_nth_by_weekday'; +import { RecurringScheduleFormProps } from '../components/schema'; +import { getPresets } from './get_presets'; + +export const convertToRRule = ( + startDate: Moment, + timezone: string, + recurringForm?: RecurringScheduleFormProps +): RRule => { + const presets = getPresets(startDate); + + const rRule: RRule = { + dtstart: startDate.toISOString(), + tzid: timezone, + }; + + if (!recurringForm) + return { + ...rRule, + // default to yearly and a count of 1 + // if the maintenance window is not recurring + freq: RRuleFrequency.YEARLY, + count: 1, + }; + + if (recurringForm) { + let form = recurringForm; + if (recurringForm.frequency !== 'CUSTOM') { + form = { ...recurringForm, ...presets[recurringForm.frequency] }; + } + + const frequency = form.customFrequency ? form.customFrequency : (form.frequency as Frequency); + rRule.freq = RRuleFrequencyMap[frequency]; + + rRule.interval = form.interval; + + if (form.until) { + rRule.until = form.until; + } + + if (form.count) { + rRule.count = form.count; + } + + if (form.byweekday) { + const byweekday = form.byweekday; + rRule.byweekday = Object.keys(byweekday) + .filter((k) => byweekday[k] === true) + .map((n) => ISO_WEEKDAYS_TO_RRULE[Number(n)]); + } + + if (form.bymonth) { + if (form.bymonth === 'day') { + rRule.bymonthday = [startDate.date()]; + } else if (form.bymonth === 'weekday') { + rRule.byweekday = [getNthByWeekday(startDate)]; + } + } + + if (frequency === Frequency.YEARLY) { + rRule.bymonth = [startDate.month()]; + rRule.bymonthday = [startDate.date()]; + } + } + + return rRule; +}; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_initial_by_weekday.test.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_initial_by_weekday.test.ts new file mode 100644 index 000000000000000..963ce5084f2a8bf --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_initial_by_weekday.test.ts @@ -0,0 +1,47 @@ +/* + * 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 moment from 'moment'; +import { getInitialByWeekday } from './get_initial_by_weekday'; + +describe('getInitialByWeekday', () => { + test('when passed empty recurring params, should return the day of the week of the passed in startDate', () => { + expect(getInitialByWeekday([], moment('2021-11-23'))).toEqual({ + '1': false, + '2': true, + '3': false, + '4': false, + '5': false, + '6': false, + '7': false, + }); + }); + + test('when passed recurring params, should return the passed in days of the week and ignore the startDate', () => { + expect(getInitialByWeekday(['+2MO', '-1FR'], moment('2021-11-23'))).toEqual({ + '1': true, + '2': false, + '3': false, + '4': false, + '5': true, + '6': false, + '7': false, + }); + }); + + test('when passed a null date, should return only Monday', () => { + expect(getInitialByWeekday([], null)).toEqual({ + '1': true, + '2': false, + '3': false, + '4': false, + '5': false, + '6': false, + '7': false, + }); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_initial_by_weekday.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_initial_by_weekday.ts new file mode 100644 index 000000000000000..33a0280dc4dc116 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_initial_by_weekday.ts @@ -0,0 +1,26 @@ +/* + * 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 { Moment } from 'moment'; +import { ISO_WEEKDAYS, ISO_WEEKDAYS_TO_RRULE } from '../constants'; + +export const getInitialByWeekday = (initialStateByweekday: string[], date: Moment | null) => { + const dayOfWeek = date ? date.isoWeekday() : 1; + return ISO_WEEKDAYS.reduce( + (result, n) => ({ + ...result, + [n]: + initialStateByweekday?.length > 0 + ? initialStateByweekday + // Sanitize nth day strings, e.g. +2MO, -1FR, into just days of the week + .map((w) => w.replace(/[0-9+\-]/g, '')) + .includes(ISO_WEEKDAYS_TO_RRULE[n]) + : n === dayOfWeek, + }), + {} as Record<string, boolean> + ); +}; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_nth_by_weekday.test.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_nth_by_weekday.test.ts new file mode 100644 index 000000000000000..b72b88f0307127b --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_nth_by_weekday.test.ts @@ -0,0 +1,23 @@ +/* + * 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 moment from 'moment'; +import { getNthByWeekday } from './get_nth_by_weekday'; + +describe('generateNthByWeekday', () => { + test('should parse the 4th tuesday', () => { + expect(getNthByWeekday(moment('2021-11-23'))).toEqual('+4TU'); + }); + + test('should parse the 3rd tuesday', () => { + expect(getNthByWeekday(moment('2021-11-16'))).toEqual('+3TU'); + }); + + test('should parse the last sunday of the month', () => { + expect(getNthByWeekday(moment('2023-04-30'))).toEqual('-1SU'); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_nth_by_weekday.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_nth_by_weekday.ts new file mode 100644 index 000000000000000..2f7c9516e0e7c13 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_nth_by_weekday.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Moment } from 'moment'; +import { ISO_WEEKDAYS_TO_RRULE } from '../constants'; +import { getWeekdayInfo } from './get_weekday_info'; + +export const getNthByWeekday = (startDate: Moment) => { + const { isLastOfMonth, nthWeekdayOfMonth } = getWeekdayInfo(startDate); + return `${isLastOfMonth ? '-1' : '+' + nthWeekdayOfMonth}${ + ISO_WEEKDAYS_TO_RRULE[startDate.isoWeekday()] + }`; +}; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_presets.test.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_presets.test.ts new file mode 100644 index 000000000000000..edc17378603248d --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_presets.test.ts @@ -0,0 +1,38 @@ +/* + * 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 moment from 'moment'; +import { getPresets } from './get_presets'; + +describe('getPresets', () => { + test('should return presets', () => { + expect(getPresets(moment('2023-03-23'))).toEqual({ + '0': { + interval: 1, + }, + '1': { + bymonth: 'weekday', + interval: 1, + }, + '2': { + byweekday: { + '1': false, + '2': false, + '3': false, + '4': true, + '5': false, + '6': false, + '7': false, + }, + interval: 1, + }, + '3': { + interval: 1, + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_presets.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_presets.ts new file mode 100644 index 000000000000000..737376b957c8aef --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_presets.ts @@ -0,0 +1,29 @@ +/* + * 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 { Moment } from 'moment'; +import { Frequency } from '../constants'; +import { getInitialByWeekday } from './get_initial_by_weekday'; + +export const getPresets = (startDate: Moment) => { + return { + [Frequency.DAILY]: { + interval: 1, + }, + [Frequency.WEEKLY]: { + interval: 1, + byweekday: getInitialByWeekday([], startDate), + }, + [Frequency.MONTHLY]: { + interval: 1, + bymonth: 'weekday', + }, + [Frequency.YEARLY]: { + interval: 1, + }, + }; +}; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_selected_for_date_picker.test.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_selected_for_date_picker.test.ts new file mode 100644 index 000000000000000..a005a919b6e25c3 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_selected_for_date_picker.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { getSelectedForDatePicker } from './get_selected_for_date_picker'; + +describe('getSelectedForDatePicker', () => { + afterAll(() => { + jest.useRealTimers(); + }); + + test('should return the current date if the form is not initialized', () => { + jest.useFakeTimers().setSystemTime(new Date('2023-03-30T00:00:00.000Z')); + + expect(getSelectedForDatePicker({}, 'date').toISOString()).toEqual('2023-03-30T00:00:00.000Z'); + }); + + test('should return the form date if it is valid', () => { + jest.useFakeTimers().setSystemTime(new Date('2023-03-30T00:00:00.000Z')); + + expect( + getSelectedForDatePicker({ date: '2023-01-30T00:00:00.000Z' }, 'date').toISOString() + ).toEqual('2023-01-30T00:00:00.000Z'); + }); + + test('should return the current date if the form date is not valid', () => { + jest.useFakeTimers().setSystemTime(new Date('2023-03-30T00:00:00.000Z')); + + expect(getSelectedForDatePicker({ date: 'test' }, 'date').toISOString()).toEqual( + '2023-03-30T00:00:00.000Z' + ); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_selected_for_date_picker.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_selected_for_date_picker.ts new file mode 100644 index 000000000000000..57eb08de6423906 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_selected_for_date_picker.ts @@ -0,0 +1,21 @@ +/* + * 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 { get } from 'lodash'; +import moment, { Moment } from 'moment'; +import { FormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; + +export function getSelectedForDatePicker(form: FormData, path: string): Moment { + // parse from a string date to moment() if there is an intitial value + // otherwise just get the current date + const initialValue = get(form, path); + let selected = moment(); + if (initialValue && moment(initialValue).isValid()) { + selected = moment(initialValue); + } + return selected; +} diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_weekday_info.test.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_weekday_info.test.ts new file mode 100644 index 000000000000000..650faf291eb074e --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_weekday_info.test.ts @@ -0,0 +1,42 @@ +/* + * 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 moment from 'moment'; +import { getWeekdayInfo } from './get_weekday_info'; + +describe('getWeekdayInfo', () => { + test('should return the fourth tuesday of the month for 11/23/2021', () => { + expect(getWeekdayInfo(moment('2021-11-23'))).toEqual({ + dayOfWeek: 'Tuesday', + isLastOfMonth: false, + nthWeekdayOfMonth: 4, + }); + }); + + test('should return the third Tuesday of the month 11/16/2021', () => { + expect(getWeekdayInfo(moment('2021-11-16'))).toEqual({ + dayOfWeek: 'Tuesday', + isLastOfMonth: false, + nthWeekdayOfMonth: 3, + }); + }); + + test('should return the last Friday of the month 12/25/2020', () => { + expect(getWeekdayInfo(moment('2020-12-25'))).toEqual({ + dayOfWeek: 'Friday', + isLastOfMonth: true, + nthWeekdayOfMonth: 4, + }); + }); + + test('should return expected invalid props for a null date', () => { + expect(getWeekdayInfo(moment(null))).toEqual({ + dayOfWeek: 'Invalid date', + isLastOfMonth: true, + nthWeekdayOfMonth: NaN, + }); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_weekday_info.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_weekday_info.ts new file mode 100644 index 000000000000000..e70083480d51988 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/get_weekday_info.ts @@ -0,0 +1,15 @@ +/* + * 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 moment, { Moment } from 'moment'; + +export const getWeekdayInfo = (date: Moment, dayOfWeekFmt: string = 'dddd') => { + const dayOfWeek = date.format(dayOfWeekFmt); + const nthWeekdayOfMonth = Math.ceil(date.date() / 7); + const isLastOfMonth = nthWeekdayOfMonth > 4 || !date.isSame(moment(date).add(7, 'd'), 'month'); + return { dayOfWeek, nthWeekdayOfMonth, isLastOfMonth }; +}; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/month_day_date.test.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/month_day_date.test.ts new file mode 100644 index 000000000000000..98c50dbf772fa4c --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/month_day_date.test.ts @@ -0,0 +1,15 @@ +/* + * 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 moment from 'moment'; +import { monthDayDate } from './month_day_date'; + +describe('monthDayDate', () => { + test('should parse the month and date', () => { + expect(monthDayDate(moment('2023-03-23'))).toEqual('March 23'); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/month_day_date.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/month_day_date.ts new file mode 100644 index 000000000000000..8c055489e713e1f --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/month_day_date.ts @@ -0,0 +1,19 @@ +/* + * 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 type { Moment } from 'moment'; + +export const monthDayDate = (date: Moment) => localDateWithoutYear(date, 'LL'); + +const localDateWithoutYear = (date: Moment, format: string) => + date + .format(format) + // We want to produce the local equivalent of DD MMM (e.g. MMM DD in US, China, Japan, Hungary, etc.) + // but Moment doesn't let us format just DD MMM according to locale, only DD MM(,?) YYYY, + // so regex replace the year and any commas from the LL formatted string + .replace(new RegExp(`(${date.format('YYYY')}|,)`, 'g'), '') + .trim(); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/recurring_summary.test.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/recurring_summary.test.ts new file mode 100644 index 000000000000000..c889172ae9b443b --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/recurring_summary.test.ts @@ -0,0 +1,186 @@ +/* + * 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 moment from 'moment'; + +import { Frequency } from '../constants'; +import { getPresets } from './get_presets'; +import { recurringSummary } from './recurring_summary'; + +describe('convertToRRule', () => { + const today = '2023-03-22'; + const startDate = moment(today); + const presets = getPresets(startDate); + + test('should return an empty string if the form is undefined', () => { + const summary = recurringSummary(startDate, undefined, presets); + + expect(summary).toEqual(''); + }); + + test('should return the summary for maintenance window that is recurring on a daily schedule', () => { + const summary = recurringSummary( + startDate, + { + byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, + ends: 'never', + frequency: Frequency.DAILY, + }, + presets + ); + + expect(summary).toEqual('every Wednesday'); + }); + + test('should return the summary for maintenance window that is recurring on a daily schedule until', () => { + const until = moment(today).add(1, 'month').toISOString(); + const summary = recurringSummary( + startDate, + { + byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, + ends: 'until', + until, + frequency: Frequency.DAILY, + }, + presets + ); + + expect(summary).toEqual('every Wednesday until April 22, 2023'); + }); + + test('should return the summary for maintenance window that is recurring on a daily schedule after x', () => { + const summary = recurringSummary( + startDate, + { + byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, + ends: 'afterx', + count: 3, + frequency: Frequency.DAILY, + }, + presets + ); + + expect(summary).toEqual('every Wednesday for 3 occurrences'); + }); + + test('should return the summary for maintenance window that is recurring on a weekly schedule', () => { + const summary = recurringSummary( + startDate, + { + ends: 'never', + frequency: Frequency.WEEKLY, + }, + presets + ); + + expect(summary).toEqual('every week on Wednesday'); + }); + + test('should return the summary for maintenance window that is recurring on a monthly schedule', () => { + const summary = recurringSummary( + startDate, + { + ends: 'never', + frequency: Frequency.MONTHLY, + }, + presets + ); + + expect(summary).toEqual('every month on the 4th Wednesday'); + }); + + test('should return the summary for maintenance window that is recurring on a yearly schedule', () => { + const summary = recurringSummary( + startDate, + { + ends: 'never', + frequency: Frequency.YEARLY, + }, + presets + ); + + expect(summary).toEqual('every year on March 22'); + }); + + test('should return the summary for maintenance window that is recurring on a custom daily schedule', () => { + const summary = recurringSummary( + startDate, + { + customFrequency: Frequency.DAILY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }, + presets + ); + + expect(summary).toEqual('every day'); + }); + + test('should return the summary for maintenance window that is recurring on a custom weekly schedule', () => { + const summary = recurringSummary( + startDate, + { + byweekday: { 1: false, 2: false, 3: true, 4: true, 5: false, 6: false, 7: false }, + customFrequency: Frequency.WEEKLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }, + presets + ); + + expect(summary).toEqual('every week on Wednesday, Thursday'); + }); + + test('should return the summary for maintenance window that is recurring on a custom monthly by day schedule', () => { + const summary = recurringSummary( + startDate, + { + bymonth: 'day', + customFrequency: Frequency.MONTHLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }, + presets + ); + + expect(summary).toEqual('every month on day 22'); + }); + + test('should return the summary for maintenance window that is recurring on a custom monthly by weekday schedule', () => { + const summary = recurringSummary( + startDate, + { + bymonth: 'weekday', + customFrequency: Frequency.MONTHLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }, + presets + ); + + expect(summary).toEqual('every month on the 4th Wednesday'); + }); + + test('should return the summary for maintenance window that is recurring on a custom yearly schedule', () => { + const summary = recurringSummary( + startDate, + { + customFrequency: Frequency.YEARLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 3, + }, + presets + ); + + expect(summary).toEqual('every 3 years on March 22'); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/recurring_summary.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/recurring_summary.ts new file mode 100644 index 000000000000000..69a3fc6f0dddaff --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/helpers/recurring_summary.ts @@ -0,0 +1,100 @@ +/* + * 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 moment, { Moment } from 'moment'; +import * as i18n from '../translations'; +import { Frequency, ISO_WEEKDAYS_TO_RRULE, RRULE_WEEKDAYS_TO_ISO_WEEKDAYS } from '../constants'; +import { monthDayDate } from './month_day_date'; +import { getNthByWeekday } from './get_nth_by_weekday'; +import { RecurringScheduleFormProps } from '../components/schema'; + +export const recurringSummary = ( + startDate: Moment, + recurringSchedule: RecurringScheduleFormProps | undefined, + presets: Record<Frequency, Partial<RecurringScheduleFormProps>> +) => { + if (!recurringSchedule) return ''; + + if (recurringSchedule) { + let schedule = recurringSchedule; + if (recurringSchedule.frequency !== 'CUSTOM') { + schedule = { ...recurringSchedule, ...presets[recurringSchedule.frequency] }; + } + + const frequency = schedule.customFrequency + ? schedule.customFrequency + : (schedule.frequency as Frequency); + const interval = schedule.interval || 1; + const frequencySummary = i18n.CREATE_FORM_FREQUENCY_SUMMARY(interval)[frequency]; + + // daily and weekly + let weeklySummary = null; + let dailyWeekdaySummary = null; + let dailyWithWeekdays = false; + const byweekday = schedule.byweekday; + if (byweekday) { + const weekdays = Object.keys(byweekday) + .filter((k) => byweekday[k] === true) + .map((n) => ISO_WEEKDAYS_TO_RRULE[Number(n)]); + const formattedWeekdays = weekdays.map((weekday) => toWeekdayName(weekday)).join(', '); + + weeklySummary = i18n.CREATE_FORM_WEEKLY_SUMMARY(formattedWeekdays); + dailyWeekdaySummary = formattedWeekdays; + + dailyWithWeekdays = frequency === Frequency.DAILY; + } + + // monthly + let monthlySummary = null; + const bymonth = schedule.bymonth; + if (bymonth) { + if (bymonth === 'weekday') { + const nthWeekday = getNthByWeekday(startDate); + const nth = nthWeekday.startsWith('-1') ? 0 : Number(nthWeekday[1]); + monthlySummary = i18n.CREATE_FORM_WEEKDAY_SHORT(toWeekdayName(nthWeekday))[nth]; + monthlySummary = monthlySummary[0].toLocaleLowerCase() + monthlySummary.slice(1); + } else if (bymonth === 'day') { + monthlySummary = i18n.CREATE_FORM_MONTHLY_BY_DAY_SUMMARY(startDate.date()); + } + } + + // yearly + const yearlyByMonthSummary = i18n.CREATE_FORM_YEARLY_BY_MONTH_SUMMARY( + monthDayDate(moment().month(startDate.month()).date(startDate.date())) + ); + + const onSummary = dailyWithWeekdays + ? dailyWeekdaySummary + : frequency === Frequency.WEEKLY + ? weeklySummary + : frequency === Frequency.MONTHLY + ? monthlySummary + : frequency === Frequency.YEARLY + ? yearlyByMonthSummary + : null; + + const untilSummary = schedule.until + ? i18n.CREATE_FORM_UNTIL_DATE_SUMMARY(moment(schedule.until).format('LL')) + : schedule.count + ? i18n.CREATE_FORM_OCURRENCES_SUMMARY(schedule.count) + : null; + + const every = i18n + .CREATE_FORM_RECURRING_SUMMARY( + !dailyWithWeekdays ? frequencySummary : null, + onSummary, + untilSummary + ) + .trim(); + + return every; + } + return ''; +}; + +export const toWeekdayName = (weekday: string) => + moment().isoWeekday(RRULE_WEEKDAYS_TO_ISO_WEEKDAYS[weekday.slice(-2)]).format('dddd'); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx new file mode 100644 index 000000000000000..80a35e4a02aee77 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { EuiPageHeader } from '@elastic/eui'; +import { useKibana } from '../../utils/kibana_react'; +import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; +import { EmptyPrompt } from './components/empty_prompt'; +import * as i18n from './translations'; +import { useCreateMaintenanceWindowNavigation } from '../../hooks/use_navigation'; +import { AlertingDeepLinkId } from '../../config'; + +export const MaintenanceWindowsPage = React.memo(() => { + const { docLinks } = useKibana().services; + const { navigateToCreateMaintenanceWindow } = useCreateMaintenanceWindowNavigation(); + + const { isLoading, maintenanceWindowsList } = { + isLoading: false, + maintenanceWindowsList: { total: 0 }, + }; + const { total } = maintenanceWindowsList || {}; + + useBreadcrumbs(AlertingDeepLinkId.maintenanceWindows); + + const handleClickCreate = useCallback(() => { + navigateToCreateMaintenanceWindow(); + }, [navigateToCreateMaintenanceWindow]); + + return ( + <> + <EuiPageHeader + bottomBorder + pageTitle={i18n.MAINTENANCE_WINDOWS} + description={i18n.MAINTENANCE_WINDOWS_DESCRIPTION} + /> + + {!isLoading && total === 0 ? ( + <EmptyPrompt onClickCreate={handleClickCreate} docLinks={docLinks.links} /> + ) : null} + </> + ); +}); +MaintenanceWindowsPage.displayName = 'MaintenanceWindowsPage'; +// eslint-disable-next-line import/no-default-export +export { MaintenanceWindowsPage as default }; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/maintenance_window_create_page.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/maintenance_window_create_page.tsx new file mode 100644 index 000000000000000..b2273556e23afa4 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/maintenance_window_create_page.tsx @@ -0,0 +1,39 @@ +/* + * 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 React from 'react'; +import { EuiPageSection, EuiSpacer } from '@elastic/eui'; + +import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; +import { useMaintenanceWindowsNavigation } from '../../hooks/use_navigation'; +import * as i18n from './translations'; +import { PageHeader } from './components/page_header'; +import { CreateMaintenanceWindowForm } from './components/create_maintenance_windows_form'; +import { AlertingDeepLinkId } from '../../config'; + +export const MaintenanceWindowsCreatePage = React.memo(() => { + useBreadcrumbs(AlertingDeepLinkId.maintenanceWindowsCreate); + const { navigateToMaintenanceWindows } = useMaintenanceWindowsNavigation(); + + return ( + <EuiPageSection restrictWidth={true}> + <PageHeader + showBackButton={true} + title={i18n.CREATE_MAINTENANCE_WINDOW} + description={i18n.CREATE_MAINTENANCE_WINDOW_DESCRIPTION} + /> + <EuiSpacer size="xl" /> + <CreateMaintenanceWindowForm + onCancel={navigateToMaintenanceWindows} + onSuccess={navigateToMaintenanceWindows} + /> + </EuiPageSection> + ); +}); +MaintenanceWindowsCreatePage.displayName = 'MaintenanceWindowsCreatePage'; +// eslint-disable-next-line import/no-default-export +export { MaintenanceWindowsCreatePage as default }; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts new file mode 100644 index 000000000000000..be8eb9e891bf0fb --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts @@ -0,0 +1,371 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Moment } from 'moment'; +import { Frequency } from 'rrule'; +import { monthDayDate } from './helpers/month_day_date'; + +export const MAINTENANCE_WINDOWS = i18n.translate('xpack.alerting.maintenanceWindows', { + defaultMessage: 'Maintenance Windows', +}); + +export const MAINTENANCE_WINDOWS_DESCRIPTION = i18n.translate( + 'xpack.alerting.maintenanceWindows.description', + { + defaultMessage: 'Suppress rule notifications for scheduled periods of time.', + } +); + +export const EMPTY_PROMPT_BUTTON = i18n.translate( + 'xpack.alerting.maintenanceWindows.emptyPrompt.button', + { + defaultMessage: 'Create a maintenance window', + } +); + +export const EMPTY_PROMPT_DOCUMENTATION = i18n.translate( + 'xpack.alerting.maintenanceWindows.emptyPrompt.documentation', + { + defaultMessage: 'Documentation', + } +); + +export const EMPTY_PROMPT_TITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.emptyPrompt.title', + { + defaultMessage: 'Create your first maintenance window', + } +); + +export const EMPTY_PROMPT_DESCRIPTION = i18n.translate( + 'xpack.alerting.maintenanceWindows.emptyPrompt.description', + { + defaultMessage: 'Schedule a time period in which rule notifications cease.', + } +); + +export const CREATE_MAINTENANCE_WINDOW = i18n.translate( + 'xpack.alerting.maintenanceWindows.create.maintenanceWindow', + { + defaultMessage: 'Create maintenance window', + } +); + +export const CREATE_MAINTENANCE_WINDOW_DESCRIPTION = i18n.translate( + 'xpack.alerting.maintenanceWindows.create.description', + { + defaultMessage: + 'Schedule a single or recurring period in which rule notifications cease and alerts are in maintenance mode.', + } +); + +export const MAINTENANCE_WINDOWS_RETURN_LINK = i18n.translate( + 'xpack.alerting.maintenanceWindows.returnLink', + { + defaultMessage: 'Return', + } +); + +export const CREATE_FORM_NAME = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.name', + { + defaultMessage: 'Name', + } +); + +export const CREATE_FORM_NAME_REQUIRED = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.nameFieldRequiredError', + { + defaultMessage: 'A name is required.', + } +); + +export const CREATE_FORM_SCHEDULE = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.schedule', + { + defaultMessage: 'Schedule', + } +); + +export const CREATE_FORM_SCHEDULE_INVALID = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.scheduleInvalid', + { + defaultMessage: 'The end date must be greater than or equal to the start date.', + } +); + +export const CREATE_FORM_REPEAT = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.repeat', + { + defaultMessage: 'Repeat', + } +); + +export const CREATE_FORM_FREQUENCY_DAILY = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.frequency.daily', + { + defaultMessage: 'Daily', + } +); + +export const CREATE_FORM_FREQUENCY_WEEKLY = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.frequency.weekly', + { + defaultMessage: 'Weekly', + } +); + +export const CREATE_FORM_FREQUENCY_WEEKLY_ON = (dayOfWeek: string) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.frequency.weeklyOnWeekday', { + defaultMessage: 'Weekly on {dayOfWeek}', + values: { dayOfWeek }, + }); + +export const CREATE_FORM_FREQUENCY_MONTHLY = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.frequency.monthly', + { + defaultMessage: 'Monthly', + } +); + +export const CREATE_FORM_FREQUENCY_NTH_WEEKDAY = (dayOfWeek: string) => [ + i18n.translate('xpack.alerting.maintenanceWindows.createForm.frequency.last', { + defaultMessage: 'Monthly on the last {dayOfWeek}', + values: { dayOfWeek }, + }), + i18n.translate('xpack.alerting.maintenanceWindows.createForm.frequency.first', { + defaultMessage: 'Monthly on the first {dayOfWeek}', + values: { dayOfWeek }, + }), + i18n.translate('xpack.alerting.maintenanceWindows.createForm.frequency.second', { + defaultMessage: 'Monthly on the second {dayOfWeek}', + values: { dayOfWeek }, + }), + i18n.translate('xpack.alerting.maintenanceWindows.createForm.frequency.third', { + defaultMessage: 'Monthly on the third {dayOfWeek}', + values: { dayOfWeek }, + }), + i18n.translate('xpack.alerting.maintenanceWindows.createForm.frequency.fourth', { + defaultMessage: 'Monthly on the fourth {dayOfWeek}', + values: { dayOfWeek }, + }), +]; + +export const CREATE_FORM_FREQUENCY_YEARLY = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.frequency.yearly', + { + defaultMessage: 'Yearly', + } +); + +export const CREATE_FORM_FREQUENCY_YEARLY_ON = (startDate: Moment) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.frequency.yearlyOn', { + defaultMessage: 'Yearly on {date}', + values: { + date: monthDayDate(startDate), + }, + }); + +export const CREATE_FORM_FREQUENCY_CUSTOM = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.frequency.custom', + { + defaultMessage: 'Custom', + } +); + +export const CREATE_FORM_ENDS = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.endsLabel', + { + defaultMessage: 'End', + } +); + +export const CREATE_FORM_ENDS_NEVER = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.ends.never', + { + defaultMessage: 'Never', + } +); + +export const CREATE_FORM_ENDS_ON_DATE = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.ends.onDate', + { + defaultMessage: 'On date', + } +); + +export const CREATE_FORM_ENDS_AFTER_X = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.ends.afterX', + { + defaultMessage: 'After \\{x\\}', + } +); + +export const CREATE_FORM_COUNT_AFTER = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.count.after', + { + defaultMessage: 'After', + } +); + +export const CREATE_FORM_COUNT_OCCURRENCE = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.count.occurrence', + { + defaultMessage: 'occurrence', + } +); + +export const CREATE_FORM_COUNT_REQUIRED = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.countFieldRequiredError', + { + defaultMessage: 'A count is required.', + } +); + +export const CREATE_FORM_INTERVAL_REQUIRED = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.intervalFieldRequiredError', + { + defaultMessage: 'An interval is required.', + } +); + +export const CREATE_FORM_INTERVAL_EVERY = i18n.translate( + 'xpack.alerting.maintenanceWindows.createForm.interval.every', + { + defaultMessage: 'Every', + } +); + +export const CREATE_FORM_CUSTOM_FREQUENCY_DAILY = (interval: number) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.customFrequency.daily', { + defaultMessage: '{interval, plural, one {day} other {days}}', + values: { interval }, + }); + +export const CREATE_FORM_CUSTOM_FREQUENCY_WEEKLY = (interval: number) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.customFrequency.weekly', { + defaultMessage: '{interval, plural, one {week} other {weeks}}', + values: { interval }, + }); + +export const CREATE_FORM_CUSTOM_FREQUENCY_MONTHLY = (interval: number) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.customFrequency.monthly', { + defaultMessage: '{interval, plural, one {month} other {months}}', + values: { interval }, + }); + +export const CREATE_FORM_CUSTOM_FREQUENCY_YEARLY = (interval: number) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.customFrequency.yearly', { + defaultMessage: '{interval, plural, one {year} other {years}}', + values: { interval }, + }); + +export const CREATE_FORM_WEEKDAY_SHORT = (dayOfWeek: string) => [ + i18n.translate('xpack.alerting.maintenanceWindows.createForm.lastShort', { + defaultMessage: 'On the last {dayOfWeek}', + values: { dayOfWeek }, + }), + i18n.translate('xpack.alerting.maintenanceWindows.createForm.firstShort', { + defaultMessage: 'On the 1st {dayOfWeek}', + values: { dayOfWeek }, + }), + i18n.translate('xpack.alerting.maintenanceWindows.createForm.secondShort', { + defaultMessage: 'On the 2nd {dayOfWeek}', + values: { dayOfWeek }, + }), + i18n.translate('xpack.alerting.maintenanceWindows.createForm.thirdShort', { + defaultMessage: 'On the 3rd {dayOfWeek}', + values: { dayOfWeek }, + }), + i18n.translate('xpack.alerting.maintenanceWindows.createForm.fourthShort', { + defaultMessage: 'On the 4th {dayOfWeek}', + values: { dayOfWeek }, + }), +]; + +export const CREATE_FORM_CUSTOM_REPEAT_MONTHLY_ON_DAY = (startDate: Moment) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.repeatOnMonthlyDay', { + defaultMessage: 'On day {dayNumber}', + values: { dayNumber: startDate.date() }, + }); + +export const CREATE_FORM_RECURRING_SUMMARY_PREFIX = (summary: string) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.recurringSummaryPrefix', { + defaultMessage: 'Repeats {summary}', + values: { summary }, + }); + +export const CREATE_FORM_FREQUENCY_SUMMARY = (interval: number) => ({ + [Frequency.DAILY]: i18n.translate('xpack.alerting.maintenanceWindows.createForm.daySummary', { + defaultMessage: '{interval, plural, one {day} other {# days}}', + values: { interval }, + }), + [Frequency.WEEKLY]: i18n.translate('xpack.alerting.maintenanceWindows.createForm.weekSummary', { + defaultMessage: '{interval, plural, one {week} other {# weeks}}', + values: { interval }, + }), + [Frequency.MONTHLY]: i18n.translate('xpack.alerting.maintenanceWindows.createForm.monthSummary', { + defaultMessage: '{interval, plural, one {month} other {# months}}', + values: { interval }, + }), + [Frequency.YEARLY]: i18n.translate('xpack.alerting.maintenanceWindows.createForm.yearSummary', { + defaultMessage: '{interval, plural, one {year} other {# years}}', + values: { interval }, + }), +}); + +export const CREATE_FORM_UNTIL_DATE_SUMMARY = (date: string) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.untilDateSummary', { + defaultMessage: 'until {date}', + values: { date }, + }); + +export const CREATE_FORM_OCURRENCES_SUMMARY = (count: number) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.occurrencesSummary', { + defaultMessage: 'for {count, plural, one {# occurrence} other {# occurrences}}', + values: { count }, + }); + +export const CREATE_FORM_RECURRING_SUMMARY = ( + frequencySummary: string | null, + onSummary: string | null, + untilSummary: string | null +) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.recurrenceSummary', { + defaultMessage: 'every {frequencySummary}{on}{until}', + values: { + frequencySummary: frequencySummary ? `${frequencySummary} ` : '', + on: onSummary ? `${onSummary} ` : '', + until: untilSummary ? `${untilSummary}` : '', + }, + }); + +export const CREATE_FORM_WEEKLY_SUMMARY = (weekdays: string) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.weeklySummary', { + defaultMessage: 'on {weekdays}', + values: { + weekdays, + }, + }); + +export const CREATE_FORM_MONTHLY_BY_DAY_SUMMARY = (monthday: number) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.monthlyByDaySummary', { + defaultMessage: 'on day {monthday}', + values: { + monthday, + }, + }); + +export const CREATE_FORM_YEARLY_BY_MONTH_SUMMARY = (date: string) => + i18n.translate('xpack.alerting.maintenanceWindows.createForm.yearlyBymonthSummary', { + defaultMessage: 'on {date}', + values: { date }, + }); + +export const CANCEL = i18n.translate('xpack.alerting.maintenanceWindows.createForm.cancel', { + defaultMessage: 'Cancel', +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/types.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/types.ts new file mode 100644 index 000000000000000..e2a3eb3e732cb16 --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/types.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum RRuleFrequency { + YEARLY = 0, + MONTHLY = 1, + WEEKLY = 2, + DAILY = 3, +} + +export const RRuleFrequencyMap = { + '0': RRuleFrequency.YEARLY, + '1': RRuleFrequency.MONTHLY, + '2': RRuleFrequency.WEEKLY, + '3': RRuleFrequency.DAILY, +}; + +export interface RecurringSchedule { + freq: RRuleFrequency; + interval: number; + until?: string; + count?: number; + byweekday?: string[]; + bymonthday?: number[]; + bymonth?: number[]; +} + +export type RRule = Partial<RecurringSchedule> & { + dtstart: string; + tzid: string; +}; + +export interface MaintenanceWindow { + title: string; + duration: number; + rRule: RRule; +} diff --git a/x-pack/plugins/alerting/public/plugin.test.ts b/x-pack/plugins/alerting/public/plugin.test.ts index fce03fa48ce0e46..a37ab89af03c01a 100644 --- a/x-pack/plugins/alerting/public/plugin.test.ts +++ b/x-pack/plugins/alerting/public/plugin.test.ts @@ -7,16 +7,24 @@ import { AlertingPublicPlugin } from './plugin'; import { coreMock } from '@kbn/core/public/mocks'; +import { + createManagementSectionMock, + managementPluginMock, +} from '@kbn/management-plugin/public/mocks'; -jest.mock('./alert_api', () => ({ +jest.mock('./services/alert_api', () => ({ loadRule: jest.fn(), loadRuleType: jest.fn(), })); +const mockInitializerContext = coreMock.createPluginInitializerContext(); +const management = managementPluginMock.createSetupContract(); +const mockSection = createManagementSectionMock(); + describe('Alerting Public Plugin', () => { describe('start()', () => { it(`should fallback to the viewInAppRelativeUrl part of the rule object if navigation isn't registered`, async () => { - const { loadRule, loadRuleType } = jest.requireMock('./alert_api'); + const { loadRule, loadRuleType } = jest.requireMock('./services/alert_api'); loadRule.mockResolvedValue({ alertTypeId: 'foo', consumer: 'abc', @@ -24,8 +32,11 @@ describe('Alerting Public Plugin', () => { }); loadRuleType.mockResolvedValue({}); - const plugin = new AlertingPublicPlugin(); - plugin.setup(); + mockSection.registerApp = jest.fn(); + management.sections.section.insightsAndAlerting = mockSection; + + const plugin = new AlertingPublicPlugin(mockInitializerContext); + plugin.setup(coreMock.createSetup(), { management }); const pluginStart = plugin.start(coreMock.createStart()); const navigationPath = await pluginStart.getNavigation('123'); diff --git a/x-pack/plugins/alerting/public/plugin.ts b/x-pack/plugins/alerting/public/plugin.ts index 2e29a40b7dba018..b986752ff19600d 100644 --- a/x-pack/plugins/alerting/public/plugin.ts +++ b/x-pack/plugins/alerting/public/plugin.ts @@ -5,11 +5,16 @@ * 2.0. */ -import { Plugin, CoreStart } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import { ManagementAppMountParams, ManagementSetup } from '@kbn/management-plugin/public'; +import { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import { AlertNavigationRegistry, AlertNavigationHandler } from './alert_navigation_registry'; -import { loadRule, loadRuleType } from './alert_api'; -import { Rule } from '../common'; +import { loadRule, loadRuleType } from './services/alert_api'; +import { ENABLE_MAINTENANCE_WINDOWS, Rule } from '../common'; +import { MAINTENANCE_WINDOWS_APP_ID } from './config'; export interface PluginSetupContract { /** @@ -52,12 +57,28 @@ export interface PluginSetupContract { export interface PluginStartContract { getNavigation: (ruleId: Rule['id']) => Promise<string | undefined>; } +export interface AlertingPluginSetup { + management: ManagementSetup; +} + +export interface AlertingPluginStart { + licensing: LicensingPluginStart; + spaces: SpacesPluginStart; +} -export class AlertingPublicPlugin implements Plugin<PluginSetupContract, PluginStartContract> { +export class AlertingPublicPlugin + implements + Plugin<PluginSetupContract, PluginStartContract, AlertingPluginSetup, AlertingPluginStart> +{ private alertNavigationRegistry?: AlertNavigationRegistry; - public setup() { + + constructor(private readonly initContext: PluginInitializerContext) {} + + public setup(core: CoreSetup, plugins: AlertingPluginSetup) { this.alertNavigationRegistry = new AlertNavigationRegistry(); + const kibanaVersion = this.initContext.env.packageInfo.version; + const registerNavigation = async ( applicationId: string, ruleTypeId: string, @@ -71,6 +92,31 @@ export class AlertingPublicPlugin implements Plugin<PluginSetupContract, PluginS handler: AlertNavigationHandler ) => this.alertNavigationRegistry!.registerDefault(applicationId, handler); + if (ENABLE_MAINTENANCE_WINDOWS) { + plugins.management.sections.section.insightsAndAlerting.registerApp({ + id: MAINTENANCE_WINDOWS_APP_ID, + title: i18n.translate('xpack.alerting.management.section.title', { + defaultMessage: 'Maintenance Windows', + }), + async mount(params: ManagementAppMountParams) { + const { renderApp } = await import('./application/maintenance_windows'); + + const [coreStart, pluginsStart] = (await core.getStartServices()) as [ + CoreStart, + AlertingPluginStart, + unknown + ]; + + return renderApp({ + core: coreStart, + plugins: pluginsStart, + mountParams: params, + kibanaVersion, + }); + }, + }); + } + return { registerNavigation, registerDefaultNavigation, diff --git a/x-pack/plugins/alerting/public/alert_api.test.ts b/x-pack/plugins/alerting/public/services/alert_api.test.ts similarity index 99% rename from x-pack/plugins/alerting/public/alert_api.test.ts rename to x-pack/plugins/alerting/public/services/alert_api.test.ts index 7bd910f5deff380..62cd14a3c21dbc1 100644 --- a/x-pack/plugins/alerting/public/alert_api.test.ts +++ b/x-pack/plugins/alerting/public/services/alert_api.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Rule, RuleType } from '../common'; +import { Rule, RuleType } from '../../common'; import { httpServiceMock } from '@kbn/core/public/mocks'; import { loadRule, loadRuleType, loadRuleTypes } from './alert_api'; diff --git a/x-pack/plugins/alerting/public/alert_api.ts b/x-pack/plugins/alerting/public/services/alert_api.ts similarity index 87% rename from x-pack/plugins/alerting/public/alert_api.ts rename to x-pack/plugins/alerting/public/services/alert_api.ts index 79d59f9aab54b3c..346914bb9b8ca54 100644 --- a/x-pack/plugins/alerting/public/alert_api.ts +++ b/x-pack/plugins/alerting/public/services/alert_api.ts @@ -7,9 +7,9 @@ import { HttpSetup } from '@kbn/core/public'; import { AsApiContract } from '@kbn/actions-plugin/common'; -import { BASE_ALERTING_API_PATH, INTERNAL_BASE_ALERTING_API_PATH } from '../common'; -import type { Rule, RuleType } from '../common'; -import { transformRule, transformRuleType, ApiRule } from './lib/common_transformations'; +import { BASE_ALERTING_API_PATH, INTERNAL_BASE_ALERTING_API_PATH } from '../../common'; +import type { Rule, RuleType } from '../../common'; +import { transformRule, transformRuleType, ApiRule } from '../lib/common_transformations'; export async function loadRuleTypes({ http }: { http: HttpSetup }): Promise<RuleType[]> { const res = await http.get<Array<AsApiContract<RuleType>>>( diff --git a/x-pack/plugins/alerting/public/services/maintenance_windows_api/create.test.ts b/x-pack/plugins/alerting/public/services/maintenance_windows_api/create.test.ts new file mode 100644 index 000000000000000..5a7bbf8be2bc4a2 --- /dev/null +++ b/x-pack/plugins/alerting/public/services/maintenance_windows_api/create.test.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core/public/mocks'; +import { createMaintenanceWindow } from './create'; +import { MaintenanceWindow } from '../../pages/maintenance_windows/types'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('createMaintenanceWindow', () => { + test('should call create maintenance window api', async () => { + const apiResponse = { + title: 'test', + duration: 1, + r_rule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + freq: 3, + interval: 1, + byweekday: ['TH'], + }, + }; + http.post.mockResolvedValueOnce(apiResponse); + + const maintenanceWindow: MaintenanceWindow = { + title: 'test', + duration: 1, + rRule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + freq: 3, + interval: 1, + byweekday: ['TH'], + }, + }; + + const result = await createMaintenanceWindow({ http, maintenanceWindow }); + expect(result).toEqual(maintenanceWindow); + expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/maintenance_window", + Object { + "body": "{\\"title\\":\\"test\\",\\"duration\\":1,\\"r_rule\\":{\\"dtstart\\":\\"2023-03-23T19:16:21.293Z\\",\\"tzid\\":\\"America/New_York\\",\\"freq\\":3,\\"interval\\":1,\\"byweekday\\":[\\"TH\\"]}}", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/alerting/public/services/maintenance_windows_api/create.ts b/x-pack/plugins/alerting/public/services/maintenance_windows_api/create.ts new file mode 100644 index 000000000000000..e4032edbef6403b --- /dev/null +++ b/x-pack/plugins/alerting/public/services/maintenance_windows_api/create.ts @@ -0,0 +1,36 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { AsApiContract, RewriteRequestCase, RewriteResponseCase } from '@kbn/actions-plugin/common'; + +import { MaintenanceWindow } from '../../pages/maintenance_windows/types'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common'; + +const rewriteBodyRequest: RewriteResponseCase<MaintenanceWindow> = ({ rRule, ...res }) => ({ + ...res, + r_rule: { ...rRule }, +}); + +const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({ r_rule: rRule, ...rest }) => ({ + ...rest, + rRule: { ...rRule }, +}); + +export async function createMaintenanceWindow({ + http, + maintenanceWindow, +}: { + http: HttpSetup; + maintenanceWindow: MaintenanceWindow; +}): Promise<MaintenanceWindow> { + const res = await http.post<AsApiContract<MaintenanceWindow>>( + `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window`, + { body: JSON.stringify(rewriteBodyRequest(maintenanceWindow)) } + ); + + return rewriteBodyRes(res); +} diff --git a/x-pack/plugins/alerting/public/utils/kibana_react.ts b/x-pack/plugins/alerting/public/utils/kibana_react.ts new file mode 100644 index 000000000000000..22b271ee4a2888d --- /dev/null +++ b/x-pack/plugins/alerting/public/utils/kibana_react.ts @@ -0,0 +1,21 @@ +/* + * 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 { CoreStart } from '@kbn/core/public'; +import { useKibana, useUiSetting } from '@kbn/kibana-react-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { AlertingPluginStart } from '../plugin'; + +export type StartServices<AdditionalServices extends object = {}> = CoreStart & + AlertingPluginStart & + AdditionalServices & { + storage: Storage; + }; +const useTypedKibana = <AdditionalServices extends object = {}>() => + useKibana<StartServices<AdditionalServices>>(); + +export { useTypedKibana as useKibana, useUiSetting }; diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 310d5a2e644cd93..2902848b6dea5cf 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -229,6 +229,7 @@ export class AlertingPlugin { management: { insightsAndAlerting: { triggersActions: true, + maintenanceWindows: true, }, }, }; diff --git a/x-pack/plugins/alerting/tsconfig.json b/x-pack/plugins/alerting/tsconfig.json index 5425e31e5cdd581..3cf3904d3942bd0 100644 --- a/x-pack/plugins/alerting/tsconfig.json +++ b/x-pack/plugins/alerting/tsconfig.json @@ -42,6 +42,14 @@ "@kbn/alerting-state-types", "@kbn/alerts-as-data-utils", "@kbn/core-elasticsearch-client-server-mocks", + "@kbn/shared-ux-router", + "@kbn/kibana-react-plugin", + "@kbn/management-plugin", + "@kbn/es-ui-shared-plugin", + "@kbn/i18n-react", + "@kbn/ui-theme", + "@kbn/core-doc-links-server-mocks", + "@kbn/doc-links", "@kbn/core-saved-objects-utils-server", ], "exclude": [