diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index 67b290bf34fcc..851c6bfa39067 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -62,8 +62,11 @@ export const ML_PAGES = { ANOMALY_DETECTION_MODULES_VIEW_OR_CREATE: 'modules/check_view_or_create', SETTINGS: 'settings', CALENDARS_MANAGE: 'settings/calendars_list', + CALENDARS_DST_MANAGE: 'settings/calendars_dst_list', CALENDARS_NEW: 'settings/calendars_list/new_calendar', + CALENDARS_DST_NEW: 'settings/calendars_dst_list/new_calendar', CALENDARS_EDIT: 'settings/calendars_list/edit_calendar', + CALENDARS_DST_EDIT: 'settings/calendars_dst_list/edit_calendar', FILTER_LISTS_MANAGE: 'settings/filter_lists', FILTER_LISTS_NEW: 'settings/filter_lists/new_filter_list', FILTER_LISTS_EDIT: 'settings/filter_lists/edit_filter_list', diff --git a/x-pack/plugins/ml/common/types/calendars.ts b/x-pack/plugins/ml/common/types/calendars.ts index bf37dbcc50572..a5e019d700282 100644 --- a/x-pack/plugins/ml/common/types/calendars.ts +++ b/x-pack/plugins/ml/common/types/calendars.ts @@ -5,16 +5,24 @@ * 2.0. */ -export type CalendarId = string; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -export interface Calendar { - calendar_id: CalendarId; +export type MlCalendarId = string; + +export interface MlCalendar { + calendar_id: MlCalendarId; description: string; events: any[]; job_ids: string[]; total_job_count?: number; } -export interface UpdateCalendar extends Calendar { - calendarId: CalendarId; +export interface UpdateCalendar extends MlCalendar { + calendarId: MlCalendarId; } + +export type MlCalendarEvent = estypes.MlCalendarEvent & { + force_time_shift?: number; + skip_result?: boolean; + skip_model_update?: boolean; +}; diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index 3cc198bb3b3f2..2288fad9cb105 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -56,7 +56,9 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB | typeof ML_PAGES.OVERVIEW | typeof ML_PAGES.CALENDARS_MANAGE + | typeof ML_PAGES.CALENDARS_DST_MANAGE | typeof ML_PAGES.CALENDARS_NEW + | typeof ML_PAGES.CALENDARS_DST_NEW | typeof ML_PAGES.FILTER_LISTS_MANAGE | typeof ML_PAGES.FILTER_LISTS_NEW | typeof ML_PAGES.SETTINGS @@ -247,6 +249,14 @@ export type CalendarEditUrlState = MLPageState< } >; +export type CalendarDstEditUrlState = MLPageState< + typeof ML_PAGES.CALENDARS_DST_EDIT, + { + calendarId: string; + globalState?: MlCommonGlobalState; + } +>; + export type FilterEditUrlState = MLPageState< typeof ML_PAGES.FILTER_LISTS_EDIT, { @@ -277,6 +287,7 @@ export type MlLocatorState = | DataFrameAnalyticsUrlState | DataFrameAnalyticsExplorationUrlState | CalendarEditUrlState + | CalendarDstEditUrlState | FilterEditUrlState | MlGenericUrlState | NotificationsUrlState diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index f43fa93368447..3d659c7a96fd7 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -45,7 +45,7 @@ import type { CREATED_BY_LABEL } from '../../../../../../common/constants/new_jo import { JOB_TYPE, SHARED_RESULTS_INDEX_NAME } from '../../../../../../common/constants/new_job'; import { collectAggs } from './util/general'; import { filterRuntimeMappings } from './util/filter_runtime_mappings'; -import type { Calendar } from '../../../../../../common/types/calendars'; +import type { MlCalendar } from '../../../../../../common/types/calendars'; import { mlCalendarService } from '../../../../services/calendar_service'; import { getDatafeedAggregations } from '../../../../../../common/util/datafeed_utils'; import { getFirstKeyInObject } from '../../../../../../common/util/object_utils'; @@ -58,7 +58,7 @@ export class JobCreator { protected _indexPatternTitle: IndexPatternTitle = ''; protected _indexPatternDisplayName: string = ''; protected _job_config: Job; - protected _calendars: Calendar[]; + protected _calendars: MlCalendar[]; protected _datafeed_config: Datafeed; protected _detectors: Detector[]; protected _influencers: string[]; @@ -271,11 +271,11 @@ export class JobCreator { this._job_config.groups = groups; } - public get calendars(): Calendar[] { + public get calendars(): MlCalendar[] { return this._calendars; } - public set calendars(calendars: Calendar[]) { + public set calendars(calendars: MlCalendar[]) { this._calendars = calendars; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx index f1ca62fc18dcc..d49db1bbc6316 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx @@ -43,12 +43,16 @@ export const AdditionalSection: FC = ({ additionalExpanded, setAdditional + + - + + + diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx index 06718f175b266..8b9f7eb16b1f4 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx @@ -20,15 +20,24 @@ import { EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { + filterCalendarsForDst, + separateCalendarsByType, +} from '../../../../../../../../../settings/calendars/dst_utils'; import { JobCreatorContext } from '../../../../../job_creator_context'; import { Description } from './description'; import { PLUGIN_ID } from '../../../../../../../../../../../common/constants/app'; -import type { Calendar } from '../../../../../../../../../../../common/types/calendars'; +import type { MlCalendar } from '../../../../../../../../../../../common/types/calendars'; import { useMlApi, useMlKibana } from '../../../../../../../../../contexts/kibana'; import { GLOBAL_CALENDAR } from '../../../../../../../../../../../common/constants/calendars'; import { ML_PAGES } from '../../../../../../../../../../../common/constants/locator'; +import { DescriptionDst } from './description_dst'; + +interface Props { + isDst?: boolean; +} -export const CalendarsSelection: FC = () => { +export const CalendarsSelection: FC = ({ isDst = false }) => { const { services: { application: { getUrlForApp }, @@ -37,19 +46,22 @@ export const CalendarsSelection: FC = () => { const mlApi = useMlApi(); const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); - const [selectedCalendars, setSelectedCalendars] = useState(jobCreator.calendars); - const [selectedOptions, setSelectedOptions] = useState>>( - [] + const [selectedCalendars, setSelectedCalendars] = useState( + filterCalendarsForDst(jobCreator.calendars, isDst) ); - const [options, setOptions] = useState>>([]); + const [selectedOptions, setSelectedOptions] = useState< + Array> + >([]); + const [options, setOptions] = useState>>([]); const [isLoading, setIsLoading] = useState(false); async function loadCalendars() { setIsLoading(true); - const calendars = (await mlApi.calendars()).filter( + const { calendars, calendarsDst } = separateCalendarsByType(await mlApi.calendars()); + const filteredCalendars = (isDst ? calendarsDst : calendars).filter( (c) => c.job_ids.includes(GLOBAL_CALENDAR) === false ); - setOptions(calendars.map((c) => ({ label: c.calendar_id, value: c }))); + setOptions(filteredCalendars.map((c) => ({ label: c.calendar_id, value: c }))); setSelectedOptions(selectedCalendars.map((c) => ({ label: c.calendar_id, value: c }))); setIsLoading(false); } @@ -60,12 +72,14 @@ export const CalendarsSelection: FC = () => { }, []); useEffect(() => { - jobCreator.calendars = selectedCalendars; + const { calendars, calendarsDst } = separateCalendarsByType(jobCreator.calendars); + const otherCalendars = isDst ? calendars : calendarsDst; + jobCreator.calendars = [...selectedCalendars, ...otherCalendars]; jobCreatorUpdate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedCalendars.join()]); - const comboBoxProps: EuiComboBoxProps = { + const comboBoxProps: EuiComboBoxProps = { async: true, options, selectedOptions, @@ -77,11 +91,13 @@ export const CalendarsSelection: FC = () => { }; const manageCalendarsHref = getUrlForApp(PLUGIN_ID, { - path: ML_PAGES.CALENDARS_MANAGE, + path: isDst ? ML_PAGES.CALENDARS_DST_MANAGE : ML_PAGES.CALENDARS_MANAGE, }); + const Desc = isDst ? DescriptionDst : Description; + return ( - + @@ -119,6 +135,6 @@ export const CalendarsSelection: FC = () => { /> - + ); }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/description_dst.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/description_dst.tsx new file mode 100644 index 0000000000000..ec8ed142ab628 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/description_dst.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 type { FC, PropsWithChildren } from 'react'; +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiDescribedFormGroup, EuiFormRow, EuiLink } from '@elastic/eui'; +import { useMlKibana } from '../../../../../../../../../contexts/kibana'; + +export const DescriptionDst: FC> = memo(({ children }) => { + const { + services: { docLinks }, + } = useMlKibana(); + const docsUrl = docLinks.links.ml.calendars; + const title = i18n.translate( + 'xpack.ml.newJob.wizard.jobDetailsStep.additionalSection.calendarsDstSelection.title', + { + defaultMessage: 'DST Calendars', + } + ); + return ( + {title}} + description={ + + + + ), + }} + /> + } + > + + <>{children} + + + ); +}); diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index 1792b4cbfefa2..78f820dc662ca 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -128,6 +128,14 @@ export const CALENDAR_MANAGEMENT_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ deepLinkId: 'ml:calendarSettings', }); +export const CALENDAR_DST_MANAGEMENT_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagementLabel', { + defaultMessage: 'Calendar DST management', + }), + href: '/settings/calendars_dst_list', + deepLinkId: 'ml:calendarSettings', +}); + export const FILTER_LISTS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ text: i18n.translate('xpack.ml.settings.breadcrumbs.filterListsLabel', { defaultMessage: 'Filter lists', @@ -160,6 +168,7 @@ const breadcrumbs = { CHANGE_POINT_DETECTION, CREATE_JOB_BREADCRUMB, CALENDAR_MANAGEMENT_BREADCRUMB, + CALENDAR_DST_MANAGEMENT_BREADCRUMB, FILTER_LISTS_BREADCRUMB, SUPPLIED_CONFIGURATIONS, }; diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx index 088c403378de0..16d8d8ac8cd9b 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx @@ -31,7 +31,7 @@ export const calendarListRouteFactory = ( title: i18n.translate('xpack.ml.settings.calendarList.docTitle', { defaultMessage: 'Calendars', }), - render: (props, deps) => , + render: (props, deps) => , breadcrumbs: [ getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), @@ -40,7 +40,24 @@ export const calendarListRouteFactory = ( ], }); -const PageWrapper: FC = () => { +export const calendarDstListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + path: createPath(ML_PAGES.CALENDARS_DST_MANAGE), + title: i18n.translate('xpack.ml.settings.calendarList.docTitle', { + defaultMessage: 'Calendars', + }), + render: (props, deps) => , + breadcrumbs: [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CALENDAR_DST_MANAGEMENT_BREADCRUMB'), + ], +}); + +const PageWrapper: FC = ({ isDst }) => { const { context } = useRouteResolver('full', ['canGetCalendars'], { getMlNodeCount }); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); @@ -52,7 +69,7 @@ const PageWrapper: FC = () => { return ( - + ); }; diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx index 61d5406871283..7d4db9b58e560 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx @@ -14,7 +14,6 @@ import type { NavigateToPath } from '../../../contexts/kibana'; import type { MlRoute, PageProps } from '../../router'; import { createPath, PageLoader } from '../../router'; import { useRouteResolver } from '../../use_resolver'; -import { usePermissionCheck } from '../../../capabilities/check_capabilities'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; import { ML_PAGES } from '../../../../../common/constants/locator'; import { getMlNodeCount } from '../../../ml_nodes_check'; @@ -26,6 +25,7 @@ enum MODE { interface NewCalendarPageProps extends PageProps { mode: MODE; + isDst: boolean; } const NewCalendar = dynamic(async () => ({ @@ -40,7 +40,7 @@ export const newCalendarRouteFactory = ( title: i18n.translate('xpack.ml.settings.createCalendar.docTitle', { defaultMessage: 'Create Calendar', }), - render: (props, deps) => , + render: (props, deps) => , breadcrumbs: [ getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), @@ -62,7 +62,7 @@ export const editCalendarRouteFactory = ( title: i18n.translate('xpack.ml.settings.editCalendar.docTitle', { defaultMessage: 'Edit Calendar', }), - render: (props, deps) => , + render: (props, deps) => , breadcrumbs: [ getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), @@ -75,7 +75,50 @@ export const editCalendarRouteFactory = ( ], }); -const PageWrapper: FC = ({ location, mode }) => { +export const newCalendarDstRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + path: createPath(ML_PAGES.CALENDARS_DST_NEW), + title: i18n.translate('xpack.ml.settings.createCalendarDst.docTitle', { + defaultMessage: 'Create DST Calendar', + }), + render: (props, deps) => , + breadcrumbs: [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CALENDAR_DST_MANAGEMENT_BREADCRUMB', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagement.createLabel', { + defaultMessage: 'Create', + }), + }, + ], +}); + +export const editCalendarDstRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + path: createPath(ML_PAGES.CALENDARS_DST_EDIT, '/:calendarId'), + title: i18n.translate('xpack.ml.settings.editCalendarDst.docTitle', { + defaultMessage: 'Edit DST Calendar', + }), + render: (props, deps) => , + breadcrumbs: [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CALENDAR_DST_MANAGEMENT_BREADCRUMB', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagement.editLabel', { + defaultMessage: 'Edit', + }), + }, + ], +}); + +const PageWrapper: FC = ({ location, mode, isDst }) => { let calendarId: string | undefined; if (mode === MODE.EDIT) { const pathMatch: string[] | null = location.pathname.match(/.+\/(.+)$/); @@ -86,14 +129,9 @@ const PageWrapper: FC = ({ location, mode }) => { useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); - const [canCreateCalendar, canDeleteCalendar] = usePermissionCheck([ - 'canCreateCalendar', - 'canDeleteCalendar', - ]); - return ( - + ); }; diff --git a/x-pack/plugins/ml/public/application/routing/use_active_route.tsx b/x-pack/plugins/ml/public/application/routing/use_active_route.tsx index 202c552559c1b..5827cc036ddf4 100644 --- a/x-pack/plugins/ml/public/application/routing/use_active_route.tsx +++ b/x-pack/plugins/ml/public/application/routing/use_active_route.tsx @@ -15,6 +15,7 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { PLUGIN_ID } from '../../../common/constants/app'; import { useMlKibana } from '../contexts/kibana'; import type { MlRoute } from './router'; +import { ML_PAGES } from '../../locator'; /** * Provides an active route of the ML app. @@ -30,8 +31,9 @@ export const useActiveRoute = (routesList: MlRoute[]): MlRoute => { /** * Temp fix for routes with params. */ - const editCalendarMatch = useRouteMatch('/settings/calendars_list/edit_calendar/:calendarId'); - const editFilterMatch = useRouteMatch('/settings/filter_lists/edit_filter_list/:filterId'); + const editCalendarMatch = useRouteMatch(`/${ML_PAGES.CALENDARS_EDIT}/:calendarId`); + const editCalendarDstMatch = useRouteMatch(`/${ML_PAGES.CALENDARS_DST_EDIT}/:calendarId`); + const editFilterMatch = useRouteMatch(`/${ML_PAGES.FILTER_LISTS_EDIT}/:filterId`); // eslint-disable-next-line react-hooks/exhaustive-deps const routesMap = useMemo(() => keyBy(routesList, 'path'), []); @@ -40,6 +42,9 @@ export const useActiveRoute = (routesList: MlRoute[]): MlRoute => { if (editCalendarMatch) { return routesMap[editCalendarMatch.path]; } + if (editCalendarDstMatch) { + return routesMap[editCalendarDstMatch.path]; + } if (editFilterMatch) { return routesMap[editFilterMatch.path]; } diff --git a/x-pack/plugins/ml/public/application/services/calendar_service.ts b/x-pack/plugins/ml/public/application/services/calendar_service.ts index ada2cbce43f6c..cccd2872ab344 100644 --- a/x-pack/plugins/ml/public/application/services/calendar_service.ts +++ b/x-pack/plugins/ml/public/application/services/calendar_service.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import type { Calendar, CalendarId } from '../../../common/types/calendars'; +import type { MlCalendar, MlCalendarId } from '../../../common/types/calendars'; import type { JobId } from '../../../common/types/anomaly_detection_jobs'; import type { MlApi } from './ml_api_service'; @@ -16,7 +16,7 @@ class CalendarService { * @param calendar * @param jobId */ - async assignNewJobId(mlApi: MlApi, calendar: Calendar, jobId: JobId) { + async assignNewJobId(mlApi: MlApi, calendar: MlCalendar, jobId: JobId) { const { calendar_id: calendarId } = calendar; try { await mlApi.updateCalendar({ @@ -38,7 +38,7 @@ class CalendarService { * Fetches calendars by the list of ids. * @param calendarIds */ - async fetchCalendarsByIds(mlApi: MlApi, calendarIds: CalendarId[]): Promise { + async fetchCalendarsByIds(mlApi: MlApi, calendarIds: MlCalendarId[]): Promise { try { const calendars = await mlApi.calendars({ calendarIds }); return Array.isArray(calendars) ? calendars : [calendars]; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index e1a6d8c15d397..fa6d179059eec 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -20,7 +20,7 @@ import type { } from '../../../../common/types/ml_server_info'; import type { MlCapabilitiesResponse } from '../../../../common/types/capabilities'; import type { RecognizeModuleResult } from '../../../../common/types/modules'; -import type { Calendar, CalendarId, UpdateCalendar } from '../../../../common/types/calendars'; +import type { MlCalendar, MlCalendarId, UpdateCalendar } from '../../../../common/types/calendars'; import type { BucketSpanEstimatorData } from '../../../../common/types/job_service'; import type { Job, @@ -555,9 +555,9 @@ export function mlApiProvider(httpService: HttpService) { /** * Gets a list of calendars * @param obj - * @returns {Promise} + * @returns {Promise} */ - calendars(obj?: { calendarId?: CalendarId; calendarIds?: CalendarId[] }) { + calendars(obj?: { calendarId?: MlCalendarId; calendarIds?: MlCalendarId[] }) { const { calendarId, calendarIds } = obj || {}; let calendarIdsPathComponent = ''; if (calendarId) { @@ -565,14 +565,14 @@ export function mlApiProvider(httpService: HttpService) { } else if (calendarIds) { calendarIdsPathComponent = `/${calendarIds.join(',')}`; } - return httpService.http({ + return httpService.http({ path: `${ML_INTERNAL_BASE_PATH}/calendars${calendarIdsPathComponent}`, method: 'GET', version: '1', }); }, - addCalendar(obj: Calendar) { + addCalendar(obj: MlCalendar) { const body = JSON.stringify(obj); return httpService.http({ path: `${ML_INTERNAL_BASE_PATH}/calendars`, diff --git a/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx b/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx index e5fce41df2e18..9b6a71639cb97 100644 --- a/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx +++ b/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx @@ -27,11 +27,13 @@ import { AnomalyDetectionSettingsContext } from './anomaly_detection_settings_co import { useToastNotificationService } from '../services/toast_notification_service'; import { ML_PAGES } from '../../../common/constants/locator'; import { useCreateAndNavigateToMlLink } from '../contexts/kibana/use_create_url'; +import { separateCalendarsByType } from './calendars/dst_utils'; export const AnomalyDetectionSettings: FC = () => { const mlApi = useMlApi(); const [calendarsCount, setCalendarsCount] = useState(0); + const [calendarsDstCount, setCalendarsDstCount] = useState(0); const [filterListsCount, setFilterListsCount] = useState(0); const { canGetFilters, canCreateFilter, canGetCalendars, canCreateCalendar } = useContext( @@ -40,7 +42,9 @@ export const AnomalyDetectionSettings: FC = () => { const { displayErrorToast } = useToastNotificationService(); const redirectToCalendarList = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_MANAGE); + const redirectToCalendarDstList = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_DST_MANAGE); const redirectToNewCalendarPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_NEW); + const redirectToNewCalendarDstPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_DST_NEW); const redirectToFilterLists = useCreateAndNavigateToMlLink(ML_PAGES.FILTER_LISTS_MANAGE); const redirectToNewFilterListPage = useCreateAndNavigateToMlLink(ML_PAGES.FILTER_LISTS_NEW); @@ -53,8 +57,9 @@ export const AnomalyDetectionSettings: FC = () => { // Obtain the counts of calendars and filter lists. if (canGetCalendars === true) { try { - const calendars = await mlApi.calendars(); + const { calendarsDst, calendars } = separateCalendarsByType(await mlApi.calendars()); setCalendarsCount(calendars.length); + setCalendarsDstCount(calendarsDst.length); } catch (e) { displayErrorToast( e, @@ -94,7 +99,7 @@ export const AnomalyDetectionSettings: FC = () => { - +

{ - + + + +

+ +

+
+ + + +

+ +

+
+
+ + + {canGetCalendars && ( + + + {calendarsDstCount}, + calendarsDstCount, + }} + /> + + + )} + + + + + + + + + + + +
+ + + + + +

{ + ); diff --git a/x-pack/plugins/ml/public/application/settings/calendars/dst_utils.test.ts b/x-pack/plugins/ml/public/application/settings/calendars/dst_utils.test.ts new file mode 100644 index 0000000000000..0bdb7a7af89ce --- /dev/null +++ b/x-pack/plugins/ml/public/application/settings/calendars/dst_utils.test.ts @@ -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 { getDSTChangeDates, createDstEvents } from './dst_utils'; +import moment from 'moment-timezone'; + +describe('getDSTChangeDates', () => { + it('should return correct DST change dates for a given timezone and year', () => { + const timezone = 'America/New_York'; + const year = 2023; + const { start, end } = getDSTChangeDates(timezone, year); + + expect(start).not.toBeNull(); + expect(end).not.toBeNull(); + + expect(moment(start).isDST()).toBe(true); + expect(moment(end).isDST()).toBe(false); + }); + + it('should return null for start and end if no DST changes are found', () => { + const timezone = 'Asia/Tokyo'; + const year = 2023; + const { start, end } = getDSTChangeDates(timezone, year); + + expect(start).toBeNull(); + expect(end).toBeNull(); + }); + + it('should handle edge cases around the start and end of the year', () => { + const timezone = 'Europe/London'; + const year = 2023; + const { start, end } = getDSTChangeDates(timezone, year); + + expect(start).not.toBeNull(); + expect(end).not.toBeNull(); + + if (start && end) { + expect(moment(start).isDST()).toBe(true); + expect(moment(end).isDST()).toBe(false); + } + }); +}); + +describe('createDstEvents', () => { + it('should create DST events for a given timezone', () => { + const timezone = 'America/New_York'; + const events = createDstEvents(timezone); + + expect(events.length).toBeGreaterThan(0); + events.forEach((event) => { + expect(event).toHaveProperty('event_id'); + expect(event).toHaveProperty('description'); + expect(event).toHaveProperty('start_time'); + expect(event).toHaveProperty('end_time'); + expect(event).toHaveProperty('skip_result', false); + expect(event).toHaveProperty('skip_model_update', false); + expect(event).toHaveProperty('force_time_shift'); + expect(event.description).toMatch(/(Winter|Summer) \d{4}/); + }); + }); + + it('should create correct number of DST events', () => { + const timezone = 'Europe/London'; + const events = createDstEvents(timezone); + + // Each year should have 2 events (start and end of DST) + const expectedNumberOfEvents = 20 * 2; + expect(events.length).toBe(expectedNumberOfEvents); + }); + + it('should handle timezones with no DST changes', () => { + const timezone = 'Asia/Tokyo'; + const events = createDstEvents(timezone); + + expect(events.length).toBe(0); + }); +}); diff --git a/x-pack/plugins/ml/public/application/settings/calendars/dst_utils.ts b/x-pack/plugins/ml/public/application/settings/calendars/dst_utils.ts new file mode 100644 index 0000000000000..6e4fd1fc5b9e4 --- /dev/null +++ b/x-pack/plugins/ml/public/application/settings/calendars/dst_utils.ts @@ -0,0 +1,153 @@ +/* + * 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 type { Moment } from 'moment-timezone'; +import moment from 'moment-timezone'; + +import type { MlCalendar, MlCalendarEvent } from '../../../../common/types/calendars'; +import { generateTempId } from './edit/utils'; + +const YEARS_OF_DST_EVENTS = 20; + +function addZeroPadding(num: number) { + return num < 10 ? `0${num}` : num; +} + +const DST_CHANGE_DESCRIPTIONS = { + WINTER: i18n.translate('xpack.ml.calendarsEdit.dstChangeDescriptionWinter', { + defaultMessage: 'Winter', + }), + SUMMER: i18n.translate('xpack.ml.calendarsEdit.dstChangeDescriptionSummer', { + defaultMessage: 'Summer', + }), +} as const; + +function createDstEvent(time: Moment, year: number, shiftSecs: number) { + return { + event_id: generateTempId(), + description: `${ + shiftSecs > 0 ? DST_CHANGE_DESCRIPTIONS.SUMMER : DST_CHANGE_DESCRIPTIONS.WINTER + } ${year}`, + start_time: time.valueOf(), + end_time: time.add(2, 'days').valueOf(), + skip_result: false, + skip_model_update: false, + force_time_shift: shiftSecs, + }; +} + +export function getDSTChangeDates(timezone: string, year: number) { + let start: Moment | null = null; + let end: Moment | null = null; + + for (let month = 1; month < 13; month++) { + for (let day = 1; day <= 31; day++) { + const date = moment.tz( + `${year}-${addZeroPadding(month)}-${addZeroPadding(day)} 09:00:00`, + timezone + ); + if (date.isValid() === false) { + continue; + } + + if (!start && date.isDST()) { + // loop over hours + for (let hour = 0; hour < 24; hour++) { + const date2 = moment.tz( + `${year}-${addZeroPadding(month)}-${addZeroPadding(day)} ${addZeroPadding(hour)}:00:00`, + timezone + ); + if (date2.isDST() === true) { + start = date2; + break; + } + } + } + + if (start && !end && date.isDST() === false) { + // loop over hours + for (let hour = 0; hour < 24; hour++) { + const date2 = moment.tz( + `${year}-${addZeroPadding(month)}-${addZeroPadding(day)} ${addZeroPadding(hour)}:00:00`, + timezone + ); + if (date2.isDST() === false) { + end = date2; + break; + } + } + } + } + } + + return { start, end, year }; +} + +function generateDSTChangeDates( + timezone: string, + years: number +): { + dates: Array<{ start: Moment | null; end: Moment | null; year: number }>; + shiftSecs: number; +} { + const thisYear = new Date().getFullYear(); + const endYear = thisYear + years; + const dates = []; + for (let year = thisYear; year < endYear; year++) { + const dstChanges = getDSTChangeDates(timezone, year); + dates.push(dstChanges); + } + const janDate = moment.tz(`${thisYear}-01-10 09:00:00`, timezone); + const juneDate = moment.tz(`${thisYear}-06-10 09:00:00`, timezone); + const diffMins = juneDate.utcOffset() - janDate.utcOffset(); + const shiftSecs = diffMins * 60; + return { dates, shiftSecs }; +} + +export function createDstEvents(timezone: string) { + const { dates, shiftSecs } = generateDSTChangeDates(timezone, YEARS_OF_DST_EVENTS); + return dates.reduce((acc, date) => { + if (!date.start || !date.end) { + return acc; + } + acc.push(createDstEvent(date.start, date.year, shiftSecs)); + acc.push(createDstEvent(date.end, date.year, -shiftSecs)); + + return acc; + }, []); +} + +export function isDstCalendar(calendar: MlCalendar) { + return calendar.events.some((event) => { + return event.force_time_shift !== undefined; + }); +} + +export function filterCalendarsForDst(calendars: MlCalendar[], isDst: boolean) { + return calendars.filter((calendar) => { + return isDstCalendar(calendar) === isDst; + }); +} + +export function separateCalendarsByType(allCalendars: MlCalendar[]) { + const calendarsDst: MlCalendar[] = []; + const calendars: MlCalendar[] = []; + allCalendars.forEach((calendar) => { + if (isDstCalendar(calendar)) { + calendarsDst.push(calendar); + } else { + calendars.push(calendar); + } + }); + return { calendarsDst, calendars }; +} + +export function generateTimeZones() { + const zones = moment.tz.names(); + return zones; +} diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap index 39098fa671fcd..e70c43b383f98 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap @@ -34,37 +34,9 @@ exports[`CalendarForm Renders calendar form 1`] = ` value="" /> - - } - > - - - - } - name="switch" - /> @@ -128,14 +100,12 @@ exports[`CalendarForm Renders calendar form 1`] = ` } > ({ useCreateAndNavigateToMlLink: jest.fn(), })); +jest.mock('../../../../capabilities/check_capabilities', () => ({ + usePermissionCheck: () => [true, true], +})); + const testProps = { calendarId: '', canCreateCalendar: true, diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.tsx similarity index 54% rename from x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js rename to x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.tsx index 103d11821437f..5d8e02b8056a1 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.tsx @@ -5,9 +5,11 @@ * 2.0. */ -import React, { Fragment } from 'react'; -import { PropTypes } from 'prop-types'; +import type { FC } from 'react'; +import React, { useState, useCallback } from 'react'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { EuiSwitchEvent, EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiButton, EuiComboBox, @@ -21,17 +23,21 @@ import { EuiSwitch, } from '@elastic/eui'; -import { EventsTable } from '../events_table'; - import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { usePermissionCheck } from '../../../../capabilities/check_capabilities'; import { ML_PAGES } from '../../../../../../common/constants/locator'; import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url'; import { MlPageHeader } from '../../../../components/page_header'; +import { DstEventGenerator } from './dst_event_generator'; +import { EventsTable } from '../events_table'; -function EditHeader({ calendarId, description }) { +const EditHeader: FC<{ calendarId: string; description: string }> = ({ + calendarId, + description, +}) => { return ( - + <> ) : null} - + ); +}; + +interface Props { + calendarId: string; + description: string; + eventsList: estypes.MlCalendarEvent[]; + groupIdOptions: EuiComboBoxOptionOption[]; + isEdit: boolean; + isNewCalendarIdValid: boolean; + jobIdOptions: EuiComboBoxOptionOption[]; + onCalendarIdChange: (e: React.ChangeEvent) => void; + onCreate: () => void; + onCreateGroupOption: (searchValue: string, flattenedOptions: EuiComboBoxOptionOption[]) => void; + onDescriptionChange: (e: React.ChangeEvent) => void; + onEdit: () => void; + onEventDelete: (eventId: string) => void; + onGroupSelection: (selectedOptions: any) => void; + showImportModal: () => void; + onJobSelection: (selectedOptions: any) => void; + saving: boolean; + loading: boolean; + selectedGroupOptions: EuiComboBoxOptionOption[]; + selectedJobOptions: EuiComboBoxOptionOption[]; + showNewEventModal: () => void; + isGlobalCalendar: boolean; + onGlobalCalendarChange: (e: EuiSwitchEvent) => void; + addEvents: (events: estypes.MlCalendarEvent[]) => void; + clearEvents: () => void; + isDst: boolean; } -export const CalendarForm = ({ +export const CalendarForm: FC = ({ calendarId, - canCreateCalendar, - canDeleteCalendar, description, eventsList, - groupIds, + groupIdOptions, isEdit, isNewCalendarIdValid, - jobIds, + jobIdOptions, onCalendarIdChange, onCreate, onCreateGroupOption, @@ -79,7 +112,12 @@ export const CalendarForm = ({ showNewEventModal, isGlobalCalendar, onGlobalCalendarChange, + addEvents, + clearEvents, + isDst, }) => { + const [canCreateCalendar] = usePermissionCheck(['canCreateCalendar']); + const [timezone, setTimezone] = useState(undefined); const msg = i18n.translate('xpack.ml.calendarsEdit.calendarForm.allowedCharactersDescription', { defaultMessage: 'Use lowercase alphanumerics (a-z and 0-9), hyphens or underscores; ' + @@ -92,20 +130,38 @@ export const CalendarForm = ({ saving || !isNewCalendarIdValid || calendarId === '' || - loading === true; - const redirectToCalendarsManagementPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_MANAGE); + loading === true || + (isDst && eventsList.length === 0); + const redirectToCalendarsManagementPage = useCreateAndNavigateToMlLink( + isDst ? ML_PAGES.CALENDARS_DST_MANAGE : ML_PAGES.CALENDARS_MANAGE + ); + + const addDstEvents = useCallback( + (events: estypes.MlCalendarEvent[]) => { + clearEvents(); + addEvents(events); + }, + [addEvents, clearEvents] + ); return ( {isEdit === true ? ( ) : ( - + <> - + {isDst ? ( + + ) : ( + + )} - + } + > + - } - > - - + + ) : null} - + )} - - } - checked={isGlobalCalendar} - onChange={onGlobalCalendarChange} - disabled={saving === true || canCreateCalendar === false || loading === true} - data-test-subj="mlCalendarApplyToAllJobsSwitch" - /> + {isDst === false ? ( + + } + checked={isGlobalCalendar} + onChange={onGlobalCalendarChange} + disabled={saving === true || canCreateCalendar === false || loading === true} + data-test-subj="mlCalendarApplyToAllJobsSwitch" + /> + ) : null} - {isGlobalCalendar === false && ( + {isGlobalCalendar === false ? ( <> @@ -175,7 +235,7 @@ export const CalendarForm = ({ } > - )} + ) : null} + isDst ? ( + + ) : ( + + ) } fullWidth > - + <> + {isDst ? ( + + ) : null} + + @@ -260,30 +336,3 @@ export const CalendarForm = ({ ); }; - -CalendarForm.propTypes = { - calendarId: PropTypes.string.isRequired, - canCreateCalendar: PropTypes.bool.isRequired, - canDeleteCalendar: PropTypes.bool.isRequired, - description: PropTypes.string, - groupIds: PropTypes.array.isRequired, - isEdit: PropTypes.bool.isRequired, - isNewCalendarIdValid: PropTypes.bool.isRequired, - jobIds: PropTypes.array.isRequired, - onCalendarIdChange: PropTypes.func.isRequired, - onCreate: PropTypes.func.isRequired, - onCreateGroupOption: PropTypes.func.isRequired, - onDescriptionChange: PropTypes.func.isRequired, - onEdit: PropTypes.func.isRequired, - onEventDelete: PropTypes.func.isRequired, - onGroupSelection: PropTypes.func.isRequired, - showImportModal: PropTypes.func.isRequired, - onJobSelection: PropTypes.func.isRequired, - saving: PropTypes.bool.isRequired, - loading: PropTypes.bool.isRequired, - selectedGroupOptions: PropTypes.array.isRequired, - selectedJobOptions: PropTypes.array.isRequired, - showNewEventModal: PropTypes.func.isRequired, - isGlobalCalendar: PropTypes.bool.isRequired, - onGlobalCalendarChange: PropTypes.func.isRequired, -}; diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/dst_event_generator.tsx b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/dst_event_generator.tsx new file mode 100644 index 0000000000000..914a22856c89a --- /dev/null +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/dst_event_generator.tsx @@ -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 type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { + EuiCallOut, + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, +} from '@elastic/eui'; +import type { FC } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { createDstEvents, generateTimeZones } from '../../dst_utils'; + +interface Props { + addEvents: (events: estypes.MlCalendarEvent[]) => void; + setTimezone: (timezone: string) => void; + isDisabled?: boolean; +} + +export const DstEventGenerator: FC = ({ addEvents, setTimezone, isDisabled }) => { + const [selectedTimeZones, setSelectedTimeZones] = useState< + Array> + >([]); + const [eventsCount, setEventsCount] = useState(null); + + useEffect(() => { + if (selectedTimeZones.length > 0) { + setTimezone(selectedTimeZones[0].value!); + const events = createDstEvents(selectedTimeZones[0].value!); + addEvents(events); + setEventsCount(events.length); + } else { + addEvents([]); + setEventsCount(null); + } + }, [addEvents, selectedTimeZones, setTimezone]); + + const timeZoneOptions = useMemo(() => { + return generateTimeZones().map((tz) => { + return { + label: tz, + value: tz, + }; + }); + }, []); + + return ( + <> + + } + > + + + + + + + + {eventsCount === 0 ? ( + <> + + + + } + > +
+ +
+
+ + ) : null} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/index.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/index.js rename to x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/index.ts diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/__snapshots__/events_table.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/__snapshots__/events_table.test.js.snap index 5d890d38c88a5..cf5d3a492c7b2 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/__snapshots__/events_table.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/__snapshots__/events_table.test.js.snap @@ -17,13 +17,17 @@ exports[`EventsTable Renders events table with no search bar 1`] = ` }, Object { "field": "start_time", - "name": "Start", + "name": + Start + , "render": [Function], "sortable": true, }, Object { "field": "end_time", - "name": "End", + "name": + End + , "render": [Function], "sortable": true, }, @@ -62,7 +66,7 @@ exports[`EventsTable Renders events table with no search bar 1`] = ` Object { "sort": Object { "direction": "asc", - "field": "description", + "field": "start_time", }, } } @@ -88,13 +92,17 @@ exports[`EventsTable Renders events table with search bar 1`] = ` }, Object { "field": "start_time", - "name": "Start", + "name": + Start + , "render": [Function], "sortable": true, }, Object { "field": "end_time", - "name": "End", + "name": + End + , "render": [Function], "sortable": true, }, @@ -167,7 +175,7 @@ exports[`EventsTable Renders events table with search bar 1`] = ` Object { "sort": Object { "direction": "asc", - "field": "description", + "field": "start_time", }, } } diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.test.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.test.js index d45d93043e16b..25961e266c3bc 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.test.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.test.js @@ -9,6 +9,10 @@ import { shallowWithIntl } from '@kbn/test-jest-helpers'; import React from 'react'; import { EventsTable } from './events_table'; +jest.mock('../../../../capabilities/check_capabilities', () => ({ + usePermissionCheck: () => [true, true], +})); + const testProps = { canCreateCalendar: true, eventsList: [ diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.tsx similarity index 51% rename from x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js rename to x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.tsx index 7dc6ad4d1d2f7..5946b80a5507e 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.tsx @@ -5,19 +5,25 @@ * 2.0. */ -import PropTypes from 'prop-types'; -import React, { Fragment } from 'react'; -import moment from 'moment'; +import type { FC } from 'react'; +import React, { useCallback } from 'react'; +import moment from 'moment-timezone'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { EuiButton, EuiButtonEmpty, EuiInMemoryTable, EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiButtonEmpty, EuiIconTip, EuiInMemoryTable, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { TIME_FORMAT } from '@kbn/ml-date-utils'; +import { usePermissionCheck } from '../../../../capabilities/check_capabilities'; -function DeleteButton({ onClick, testSubj, disabled }) { +const DeleteButton: FC<{ + onClick: () => void; + testSubj: string; + disabled: boolean; +}> = ({ onClick, testSubj, disabled }) => { return ( - + <> - + ); +}; + +interface Props { + eventsList: estypes.MlCalendarEvent[]; + onDeleteClick: (eventId: string) => void; + showImportModal: () => void; + showNewEventModal: () => void; + showSearchBar?: boolean; + loading?: boolean; + saving?: boolean; + timezone?: string; + isDst: boolean; } -export const EventsTable = ({ - canCreateCalendar, - canDeleteCalendar, +export const EventsTable: FC = ({ eventsList, onDeleteClick, showSearchBar, @@ -44,19 +60,32 @@ export const EventsTable = ({ showNewEventModal, loading, saving, + timezone, + isDst, }) => { - const sorting = { - sort: { - field: 'description', - direction: 'asc', - }, - }; + const [canCreateCalendar, canDeleteCalendar] = usePermissionCheck([ + 'canCreateCalendar', + 'canDeleteCalendar', + ]); const pagination = { initialPageSize: 5, pageSizeOptions: [5, 10], }; + const formatEventDate = useCallback( + (timeMs: number) => { + if (timezone === undefined) { + const time = moment(timeMs); + return time.format(TIME_FORMAT); + } + + const time = moment.tz(timeMs, timezone); + return time.toLocaleString(); + }, + [timezone] + ); + const columns = [ { field: 'description', @@ -69,35 +98,66 @@ export const EventsTable = ({ }, { field: 'start_time', - name: i18n.translate('xpack.ml.calendarsEdit.eventsTable.startColumnName', { - defaultMessage: 'Start', - }), + name: ( + + {i18n.translate('xpack.ml.calendarsEdit.eventsTable.startColumnName', { + defaultMessage: 'Start', + })} + {isDst ? ( + <> +   + + + ) : null} + + ), sortable: true, - render: (timeMs) => { - const time = moment(timeMs); - return time.format(TIME_FORMAT); - }, + render: formatEventDate, }, { field: 'end_time', - name: i18n.translate('xpack.ml.calendarsEdit.eventsTable.endColumnName', { - defaultMessage: 'End', - }), + name: ( + + {i18n.translate('xpack.ml.calendarsEdit.eventsTable.endColumnName', { + defaultMessage: 'End', + })} + {isDst ? ( + <> +   + + + ) : null} + + ), sortable: true, - render: (timeMs) => { - const time = moment(timeMs); - return time.format(TIME_FORMAT); - }, + render: formatEventDate, }, { field: '', name: '', - render: (event) => ( + render: (event: estypes.MlCalendarEvent) => ( { - onDeleteClick(event.event_id); + onDeleteClick(event.event_id!); }} /> ), @@ -140,38 +200,25 @@ export const EventsTable = ({ }; return ( - + <> - items={eventsList} itemId="event_id" columns={columns} pagination={pagination} - sorting={sorting} + sorting={{ + sort: { + field: 'start_time', + direction: 'asc', + }, + }} search={showSearchBar ? search : undefined} data-test-subj="mlCalendarEventsTable" rowProps={(item) => ({ 'data-test-subj': `mlCalendarEventListRow row-${item.description}`, })} /> - + ); }; - -EventsTable.propTypes = { - canCreateCalendar: PropTypes.bool, - canDeleteCalendar: PropTypes.bool, - eventsList: PropTypes.array.isRequired, - onDeleteClick: PropTypes.func.isRequired, - showImportModal: PropTypes.func, - showNewEventModal: PropTypes.func, - showSearchBar: PropTypes.bool, - loading: PropTypes.bool, - saving: PropTypes.bool, -}; - -EventsTable.defaultProps = { - showSearchBar: false, - canCreateCalendar: true, - canDeleteCalendar: true, -}; diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/index.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/index.js rename to x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/index.ts diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/import_modal/import_modal.test.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/import_modal/import_modal.test.js index bcbbdec7b65c6..ceccb4f2ba11e 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/import_modal/import_modal.test.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/import_modal/import_modal.test.js @@ -9,6 +9,10 @@ import { shallowWithIntl, mountWithIntl } from '@kbn/test-jest-helpers'; import React from 'react'; import { ImportModal } from './import_modal'; +jest.mock('../../../../capabilities/check_capabilities', () => ({ + usePermissionCheck: () => [true, true], +})); + const testProps = { addImportedEvents: jest.fn(), closeImportModal: jest.fn(), diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/imported_events/__snapshots__/imported_events.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/edit/imported_events/__snapshots__/imported_events.test.js.snap index 87740b17bafb4..cae4e2d7b9f70 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/imported_events/__snapshots__/imported_events.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/imported_events/__snapshots__/imported_events.test.js.snap @@ -24,8 +24,6 @@ exports[`ImportedEvents Renders imported events 1`] = ` grow={false} >
; diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js index 51939aaba639a..8e1b09716c58f 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js @@ -25,8 +25,7 @@ import { HelpMenu } from '../../../components/help_menu'; class NewCalendarUI extends Component { static propTypes = { calendarId: PropTypes.string, - canCreateCalendar: PropTypes.bool.isRequired, - canDeleteCalendar: PropTypes.bool.isRequired, + isDst: PropTypes.bool.isRequired, }; constructor(props) { @@ -66,7 +65,12 @@ class NewCalendarUI extends Component { application: { navigateToUrl }, }, } = this.props.kibana; - await navigateToUrl(`${basePath.get()}/app/ml/${ML_PAGES.CALENDARS_MANAGE}`, true); + await navigateToUrl( + `${basePath.get()}/app/ml/${ + this.props.isDst ? ML_PAGES.CALENDARS_DST_MANAGE : ML_PAGES.CALENDARS_MANAGE + }`, + true + ); }; async formSetup() { @@ -219,6 +223,11 @@ class NewCalendarUI extends Component { description: event.description, start_time: event.start_time, end_time: event.end_time, + ...(event.skip_result !== undefined ? { skip_result: event.skip_result } : {}), + ...(event.skip_model_update !== undefined + ? { skip_model_update: event.skip_model_update } + : {}), + ...(event.force_time_shift !== undefined ? { force_time_shift: event.force_time_shift } : {}), })); // set up calendar @@ -308,6 +317,19 @@ class NewCalendarUI extends Component { })); }; + addEvents = (events) => { + this.setState((prevState) => ({ + events: [...prevState.events, ...events], + isNewEventModalVisible: false, + })); + }; + + clearEvents = () => { + this.setState(() => ({ + events: [], + })); + }; + addImportedEvents = (events) => { this.setState((prevState) => ({ events: [...prevState.events, ...events], @@ -354,16 +376,14 @@ class NewCalendarUI extends Component { {modal} diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js index 018acd8e6a306..7f899bc28730f 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js @@ -31,6 +31,9 @@ jest.mock('../../../capabilities/get_capabilities', () => ({ jest.mock('../../../ml_nodes_check/check_ml_nodes', () => ({ mlNodesAvailable: () => true, })); +jest.mock('../../../capabilities/check_capabilities', () => ({ + usePermissionCheck: () => [true, true], +})); const calendarsMock = [ { @@ -115,16 +118,11 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({ import { NewCalendar } from './new_calendar'; -const props = { - canCreateCalendar: true, - canDeleteCalendar: true, -}; - describe('NewCalendar', () => { test('Renders new calendar form', () => { const { getByTestId } = render( - + ); @@ -134,7 +132,7 @@ describe('NewCalendar', () => { test('Import modal button is disabled', () => { const { getByTestId } = render( - + ); @@ -146,7 +144,7 @@ describe('NewCalendar', () => { test('New event modal button is disabled', async () => { const { getByTestId } = render( - + ); @@ -158,7 +156,7 @@ describe('NewCalendar', () => { test('isDuplicateId returns true if form calendar id already exists in calendars', async () => { const { getByTestId, queryByTestId, getByText } = render( - + ); @@ -187,20 +185,4 @@ describe('NewCalendar', () => { 'Cannot create calendar with id [this-is-a-new-calendar] as it already exists.' ); }); - - test('Save button is disabled if canCreateCalendar is false', () => { - const noCreateProps = { - ...props, - canCreateCalendar: false, - }; - - const { getByTestId } = render( - - - - ); - - const saveButton = getByTestId('mlSaveCalendarButton'); - expect(saveButton).toBeDisabled(); - }); }); diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/utils.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/utils.ts similarity index 83% rename from x-pack/plugins/ml/public/application/settings/calendars/edit/utils.js rename to x-pack/plugins/ml/public/application/settings/calendars/edit/utils.ts index b4c655b755b99..0b8735a296c9a 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/utils.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/utils.ts @@ -5,13 +5,15 @@ * 2.0. */ -import { isJobIdValid } from '../../../../../common/util/job_utils'; import { i18n } from '@kbn/i18n'; -function getJobIds(mlApi) { +import { isJobIdValid } from '../../../../../common/util/job_utils'; +import type { MlApi } from '../../../services/ml_api_service'; + +function getJobIds(mlApi: MlApi) { return new Promise((resolve, reject) => { mlApi.jobs - .jobsSummary() + .jobsSummary([]) .then((resp) => { resolve(resp.map((job) => job.id)); }) @@ -23,13 +25,14 @@ function getJobIds(mlApi) { values: { err }, } ); + // eslint-disable-next-line no-console console.log(errorMessage); reject(errorMessage); }); }); } -function getGroupIds(mlApi) { +function getGroupIds(mlApi: MlApi) { return new Promise((resolve, reject) => { mlApi.jobs .groups() @@ -44,13 +47,14 @@ function getGroupIds(mlApi) { values: { err }, } ); + // eslint-disable-next-line no-console console.log(errorMessage); reject(errorMessage); }); }); } -function getCalendars(mlApi) { +function getCalendars(mlApi: MlApi) { return new Promise((resolve, reject) => { mlApi .calendars() @@ -65,13 +69,14 @@ function getCalendars(mlApi) { values: { err }, } ); + // eslint-disable-next-line no-console console.log(errorMessage); reject(errorMessage); }); }); } -export function getCalendarSettingsData(mlApi) { +export function getCalendarSettingsData(mlApi: MlApi) { return new Promise(async (resolve, reject) => { try { const [jobIds, groupIds, calendars] = await Promise.all([ @@ -86,13 +91,14 @@ export function getCalendarSettingsData(mlApi) { calendars, }); } catch (error) { + // eslint-disable-next-line no-console console.log(error); reject(error); } }); } -export function validateCalendarId(calendarId) { +export function validateCalendarId(calendarId: string) { let valid = true; if (calendarId === '' || calendarId === undefined) { diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/__snapshots__/header.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/list/__snapshots__/header.test.js.snap index a4ee42609c834..de3e3fe60315d 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/__snapshots__/header.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/__snapshots__/header.test.js.snap @@ -82,7 +82,7 @@ exports[`CalendarListsHeader renders header 1`] = ` Object { "br":
, "learnMoreLink": ; diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js b/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js index f49ed3bdac194..ed17534c3feaa 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js @@ -18,11 +18,13 @@ import { deleteCalendars } from './delete_calendars'; import { i18n } from '@kbn/i18n'; import { withKibana } from '@kbn/kibana-react-plugin/public'; import { HelpMenu } from '../../../components/help_menu'; +import { isDstCalendar } from '../dst_utils'; export class CalendarsListUI extends Component { static propTypes = { canCreateCalendar: PropTypes.bool.isRequired, canDeleteCalendar: PropTypes.bool.isRequired, + isDst: PropTypes.bool.isRequired, }; constructor(props) { @@ -42,7 +44,9 @@ export class CalendarsListUI extends Component { this.setState({ loading: true }); try { - const calendars = await mlApi.calendars(); + const calendars = (await mlApi.calendars()).filter( + (calendar) => isDstCalendar(calendar) === this.props.isDst + ); this.setState({ calendars, @@ -146,6 +150,7 @@ export class CalendarsListUI extends Component { 0} + isDst={this.props.isDst} /> {destroyModal} diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.test.js b/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.test.js index c00b86cf8b231..df2a5e9d5cc33 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.test.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.test.js @@ -43,6 +43,9 @@ jest.mock('../../../capabilities/get_capabilities', () => ({ jest.mock('../../../ml_nodes_check/check_ml_nodes', () => ({ mlNodesAvailable: () => true, })); +jest.mock('../../../capabilities/check_capabilities', () => ({ + usePermissionCheck: () => [true, true], +})); const mockCalendars = [ { @@ -114,6 +117,7 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({ const props = { canCreateCalendar: true, canDeleteCalendar: true, + isDst: false, }; describe('CalendarsList', () => { diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/header.js b/x-pack/plugins/ml/public/application/settings/calendars/list/header.js deleted file mode 100644 index 08f345b2de5a2..0000000000000 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/header.js +++ /dev/null @@ -1,107 +0,0 @@ -/* - * 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. - */ - -/* - * React component for the header section of the calendars list page. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiText, - EuiTextColor, - EuiButtonEmpty, -} from '@elastic/eui'; - -import { withKibana } from '@kbn/kibana-react-plugin/public'; -import { MlPageHeader } from '../../../components/page_header'; - -function CalendarsListHeaderUI({ totalCount, refreshCalendars, kibana }) { - const docsUrl = kibana.services.docLinks.links.ml.calendars; - return ( - <> - - - - - - - - -

- -

-
-
-
-
- - - - - - - - - -
- - -

- - , - learnMoreLink: ( - - - - ), - }} - /> - -

-
- - - ); -} -CalendarsListHeaderUI.propTypes = { - totalCount: PropTypes.number.isRequired, - refreshCalendars: PropTypes.func.isRequired, -}; - -export const CalendarsListHeader = withKibana(CalendarsListHeaderUI); diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/header.test.js b/x-pack/plugins/ml/public/application/settings/calendars/list/header.test.js index ba4d49a31135c..e1c42d5068592 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/header.test.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/header.test.js @@ -15,6 +15,16 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({ return comp; }, })); +jest.mock('../../../capabilities/check_capabilities', () => ({ + usePermissionCheck: () => [true, true], +})); +jest.mock('../../../contexts/kibana/kibana_context', () => ({ + useMlKibana: () => ({ + services: { + docLinks: { links: { ml: { calendars: jest.fn() } } }, + }, + }), +})); describe('CalendarListsHeader', () => { const refreshCalendars = jest.fn(() => {}); diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/header.tsx b/x-pack/plugins/ml/public/application/settings/calendars/list/header.tsx new file mode 100644 index 0000000000000..ace83a21dcf51 --- /dev/null +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/header.tsx @@ -0,0 +1,137 @@ +/* + * 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. + */ + +/* + * React component for the header section of the calendars list page. + */ + +import type { FC } from 'react'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, + EuiTextColor, + EuiButtonEmpty, +} from '@elastic/eui'; + +import { MlPageHeader } from '../../../components/page_header'; +import { useMlKibana } from '../../../contexts/kibana/kibana_context'; + +interface Props { + isDst: boolean; + totalCount: number; + refreshCalendars: () => void; +} + +export const CalendarsListHeader: FC = ({ totalCount, refreshCalendars, isDst }) => { + const { + services: { + docLinks: { links }, + }, + } = useMlKibana(); + const docsUrl = links.ml.calendars; + return ( + <> + + {isDst ? ( + + ) : ( + + )} + + + + + + +

+ +

+
+
+
+
+ + + + + + + + + +
+ + +

+ + {isDst ? ( + , + learnMoreLink: ( + + + + ), + }} + /> + ) : ( + , + learnMoreLink: ( + + + + ), + }} + /> + )} + +

+
+ + + ); +}; diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js b/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js index e9240557d2a68..c8feff122c3ec 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js @@ -26,8 +26,11 @@ export const CalendarsListTable = ({ canDeleteCalendar, mlNodesAvailable, itemsSelected, + isDst, }) => { - const redirectToNewCalendarPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_NEW); + const redirectToNewCalendarPage = useCreateAndNavigateToMlLink( + isDst ? ML_PAGES.CALENDARS_DST_NEW : ML_PAGES.CALENDARS_NEW + ); const sorting = { sort: { @@ -51,7 +54,10 @@ export const CalendarsListTable = ({ truncateText: true, scope: 'row', render: (id) => ( - + {id} ), diff --git a/x-pack/plugins/ml/public/locator/formatters/settings.tsx b/x-pack/plugins/ml/public/locator/formatters/settings.tsx index fd9b9dd3dba4c..e0e73e3b3b89c 100644 --- a/x-pack/plugins/ml/public/locator/formatters/settings.tsx +++ b/x-pack/plugins/ml/public/locator/formatters/settings.tsx @@ -27,6 +27,24 @@ export function formatEditCalendarUrl( return url; } +export function formatEditCalendarDstUrl( + appBasePath: string, + pageState: CalendarEditUrlState['pageState'] +): string { + let url = `${appBasePath}/${ML_PAGES.CALENDARS_DST_EDIT}`; + if (pageState) { + const { globalState, calendarId } = pageState; + if (calendarId !== undefined) { + url = `${url}/${calendarId}`; + } + if (globalState) { + url = setStateToKbnUrl('_g', globalState, { useHash: false, storeInHashQuery: false }, url); + } + } + + return url; +} + export function formatEditFilterUrl( appBasePath: string, pageState: FilterEditUrlState['pageState'] diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index d86fd10e7dc87..f2807687110f6 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -29,6 +29,7 @@ import { formatGenericMlUrl, formatEditCalendarUrl, formatEditFilterUrl, + formatEditCalendarDstUrl, } from './formatters'; import { formatTrainedModelsManagementUrl, @@ -114,7 +115,9 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.FILTER_LISTS_MANAGE: case ML_PAGES.FILTER_LISTS_NEW: case ML_PAGES.CALENDARS_MANAGE: + case ML_PAGES.CALENDARS_DST_MANAGE: case ML_PAGES.CALENDARS_NEW: + case ML_PAGES.CALENDARS_DST_NEW: path = formatGenericMlUrl('', params.page, params.pageState); break; case ML_PAGES.FILTER_LISTS_EDIT: @@ -122,6 +125,8 @@ export class MlLocatorDefinition implements LocatorDefinition { break; case ML_PAGES.CALENDARS_EDIT: path = formatEditCalendarUrl('', params.pageState); + case ML_PAGES.CALENDARS_DST_EDIT: + path = formatEditCalendarDstUrl('', params.pageState); break; case ML_PAGES.NOTIFICATIONS: path = formatNotificationsUrl('', params.pageState);