diff --git a/x-pack/plugins/ml/public/settings/_index.scss b/x-pack/plugins/ml/public/settings/_index.scss index fa32ea3cbff34..f29a6e3a192b0 100644 --- a/x-pack/plugins/ml/public/settings/_index.scss +++ b/x-pack/plugins/ml/public/settings/_index.scss @@ -1,3 +1,3 @@ @import 'settings'; @import 'filter_lists/index'; -@import 'scheduled_events/index'; \ No newline at end of file +@import 'calendars/index'; diff --git a/x-pack/plugins/ml/public/settings/calendars/_calendars.scss b/x-pack/plugins/ml/public/settings/calendars/_calendars.scss new file mode 100644 index 0000000000000..9e60ec241b8e8 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/_calendars.scss @@ -0,0 +1,4 @@ +.mlCalendarManagement { + background: $euiColorLightestShade; + min-height: 100vh; +} diff --git a/x-pack/plugins/ml/public/settings/calendars/_index.scss b/x-pack/plugins/ml/public/settings/calendars/_index.scss new file mode 100644 index 0000000000000..7284feef6a46e --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/_index.scss @@ -0,0 +1,3 @@ +@import 'calendars'; +@import 'edit/index'; +@import 'list/index'; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap b/x-pack/plugins/ml/public/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap new file mode 100644 index 0000000000000..c1fd683d1c6e0 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NewCalendar Renders new calendar form 1`] = ` + + + + + +`; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/_edit.scss b/x-pack/plugins/ml/public/settings/calendars/edit/_edit.scss new file mode 100644 index 0000000000000..4027f519bc915 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/_edit.scss @@ -0,0 +1,8 @@ +.mlCalendarEditForm { + .mlCalendarEditForm__content { + max-width: map-get($euiBreakpoints, 'xl'); + width: 100%; + margin-top: $euiSize; + margin-bottom: $euiSize; + } +} diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/_index.scss b/x-pack/plugins/ml/public/settings/calendars/edit/_index.scss new file mode 100644 index 0000000000000..6928f6ce68281 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/_index.scss @@ -0,0 +1 @@ +@import 'edit'; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap b/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap new file mode 100644 index 0000000000000..49799e4e67b09 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap @@ -0,0 +1,142 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CalendarForm Renders calendar form 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + Save + + + + + Cancel + + + + +`; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/calendar_form.js b/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/calendar_form.js new file mode 100644 index 0000000000000..1f5aeab610610 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/calendar_form.js @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import React, { Fragment } from 'react'; +import { PropTypes } from 'prop-types'; + +import { + EuiButton, + EuiComboBox, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import chrome from 'ui/chrome'; +import { EventsTable } from '../events_table/'; + + +function EditHeader({ + calendarId, + description +}) { + return ( + + +

Calendar {calendarId}

+
+ +

+ {description} +

+
+ +
+ ); +} + +export function CalendarForm({ + calendarId, + description, + eventsList, + groupIds, + isEdit, + isNewCalendarIdValid, + jobIds, + onCalendarIdChange, + onCreate, + onCreateGroupOption, + onDescriptionChange, + onEdit, + onEventDelete, + onGroupSelection, + showImportModal, + onJobSelection, + saving, + selectedGroupOptions, + selectedJobOptions, + showNewEventModal +}) { + const msg = `Use lowercase alphanumerics (a-z and 0-9), hyphens or underscores; + must start and end with an alphanumeric character`; + const helpText = (isNewCalendarIdValid === true && !isEdit) ? msg : undefined; + const error = (isNewCalendarIdValid === false && !isEdit) ? [msg] : undefined; + + return ( + + {!isEdit && + + + + + + + + + + } + {isEdit && + } + + + + + + + + + + + + + + + + + + {saving ? 'Saving...' : 'Save'} + + + + + Cancel + + + + + ); +} + +CalendarForm.propTypes = { + calendarId: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + 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, + selectedGroupOptions: PropTypes.array.isRequired, + selectedJobOptions: PropTypes.array.isRequired, + showNewEventModal: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/calendar_form.test.js b/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/calendar_form.test.js new file mode 100644 index 0000000000000..004426006a38c --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/calendar_form.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + + + +jest.mock('ui/chrome', () => ({ + getBasePath: jest.fn() +})); + + +import { shallow, mount } from 'enzyme'; +import React from 'react'; +import { CalendarForm } from './calendar_form'; + +const testProps = { + calendarId: '', + description: '', + eventsList: [], + groupIds: [], + isEdit: false, + isNewCalendarIdValid: false, + jobIds: [], + onCalendarIdChange: jest.fn(), + onCreate: jest.fn(), + onCreateGroupOption: jest.fn(), + onDescriptionChange: jest.fn(), + onEdit: jest.fn(), + onEventDelete: jest.fn(), + onGroupSelection: jest.fn(), + showImportModal: jest.fn(), + onJobSelection: jest.fn(), + saving: false, + selectedGroupOptions: [], + selectedJobOptions: [], + showNewEventModal: jest.fn() +}; + +describe('CalendarForm', () => { + + test('Renders calendar form', () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('CalendarId shown as title when editing', () => { + const editProps = { + ...testProps, + isEdit: true, + calendarId: 'test-calendar', + description: 'test description', + }; + const wrapper = mount( + + ); + const calendarId = wrapper.find('EuiTitle'); + + expect( + calendarId.containsMatchingElement( +

Calendar test-calendar

+ ) + ).toBeTruthy(); + }); + +}); diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/index.js b/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/index.js new file mode 100644 index 0000000000000..a0588647f2697 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/calendar_form/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +export { CalendarForm } from './calendar_form'; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/directive.js b/x-pack/plugins/ml/public/settings/calendars/edit/directive.js new file mode 100644 index 0000000000000..9889ec2660c30 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/directive.js @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +import 'ngreact'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml', ['react']); + +import { checkFullLicense } from '../../../license/check_license'; +import { checkGetJobsPrivilege } from '../../../privilege/check_privilege'; +import { checkMlNodesAvailable } from '../../../ml_nodes_check/check_ml_nodes'; +import { initPromise } from 'plugins/ml/util/promise'; + +import uiRoutes from 'ui/routes'; + +const template = ` + +
+ +
+`; + +uiRoutes + .when('/settings/calendars_list/new_calendar', { + template, + resolve: { + CheckLicense: checkFullLicense, + privileges: checkGetJobsPrivilege, + checkMlNodesAvailable, + initPromise: initPromise(false) + } + }) + .when('/settings/calendars_list/edit_calendar/:calendarId', { + template, + resolve: { + CheckLicense: checkFullLicense, + privileges: checkGetJobsPrivilege, + checkMlNodesAvailable, + initPromise: initPromise(false) + } + }); + +import { NewCalendar } from './new_calendar.js'; + +module.directive('mlNewCalendar', function ($route) { + return { + restrict: 'E', + replace: false, + scope: {}, + link: function (scope, element) { + const props = { + calendarId: $route.current.params.calendarId + }; + + ReactDOM.render( + React.createElement(NewCalendar, props), + element[0] + ); + } + }; +}); diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/events_table/__snapshots__/events_table.test.js.snap b/x-pack/plugins/ml/public/settings/calendars/edit/events_table/__snapshots__/events_table.test.js.snap new file mode 100644 index 0000000000000..0f632d6aa47bf --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/events_table/__snapshots__/events_table.test.js.snap @@ -0,0 +1,169 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EventsTable Renders events table with no search bar 1`] = ` + + + + +`; + +exports[`EventsTable Renders events table with search bar 1`] = ` + + + + New event + , + + Import events + , + ], + } + } + sorting={ + Object { + "sort": Object { + "direction": "asc", + "field": "description", + }, + } + } + /> + +`; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/events_table/events_table.js b/x-pack/plugins/ml/public/settings/calendars/edit/events_table/events_table.js new file mode 100644 index 0000000000000..eac9eed7bd984 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/events_table/events_table.js @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import PropTypes from 'prop-types'; +import React, { Fragment } from 'react'; +import moment from 'moment'; + +import { + EuiButton, + EuiButtonEmpty, + EuiInMemoryTable, + EuiSpacer, +} from '@elastic/eui'; + +export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +function DeleteButton({ onClick }) { + return ( + + + Delete + + + ); +} + +export function EventsTable({ + eventsList, + onDeleteClick, + showSearchBar, + showImportModal, + showNewEventModal +}) { + const sorting = { + sort: { + field: 'description', + direction: 'asc', + } + }; + + const pagination = { + initialPageSize: 5, + pageSizeOptions: [5, 10] + }; + + const columns = [ + { + field: 'description', + name: 'Description', + sortable: true, + truncateText: true + }, + { + field: 'start_time', + name: 'Start', + sortable: true, + render: (timeMs) => { + const time = moment(timeMs); + return time.format(TIME_FORMAT); + } + }, + { + field: 'end_time', + name: 'End', + sortable: true, + render: (timeMs) => { + const time = moment(timeMs); + return time.format(TIME_FORMAT); + } + }, + { + field: '', + name: '', + render: (event) => ( + { onDeleteClick(event.event_id); }} + /> + ) + }, + ]; + + const search = { + toolsRight: [( + + New event + ), + ( + + Import events + + )], + box: { + incremental: true, + }, + filters: [] + }; + + return ( + + + + + ); +} + +EventsTable.propTypes = { + eventsList: PropTypes.array.isRequired, + onDeleteClick: PropTypes.func.isRequired, + showImportModal: PropTypes.func, + showNewEventModal: PropTypes.func, + showSearchBar: PropTypes.bool, +}; + +EventsTable.defaultProps = { + showSearchBar: false, +}; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/events_table/events_table.test.js b/x-pack/plugins/ml/public/settings/calendars/edit/events_table/events_table.test.js new file mode 100644 index 0000000000000..bcc26d12ec26f --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/events_table/events_table.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + + + +jest.mock('ui/chrome', () => ({ + getBasePath: jest.fn() +})); + + +import { shallow } from 'enzyme'; +import React from 'react'; +import { EventsTable } from './events_table'; + +const testProps = { + eventsList: [{ + calendar_id: 'test-calendar', + description: 'test description', + start_time: 1486656600000, + end_time: 1486657800000, + event_id: 'test-event-one' + }], + onDeleteClick: jest.fn(), + showSearchBar: false, + showImportModal: jest.fn(), + showNewEventModal: jest.fn() +}; + +describe('EventsTable', () => { + + test('Renders events table with no search bar', () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('Renders events table with search bar', () => { + const showSearchBarProps = { + ...testProps, + showSearchBar: true, + }; + + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); + +}); diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/events_table/index.js b/x-pack/plugins/ml/public/settings/calendars/edit/events_table/index.js new file mode 100644 index 0000000000000..daa2488e7aae3 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/events_table/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +export { EventsTable, TIME_FORMAT } from './events_table'; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/__snapshots__/import_modal.test.js.snap b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/__snapshots__/import_modal.test.js.snap new file mode 100644 index 0000000000000..d7c24fedc5318 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/__snapshots__/import_modal.test.js.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ImportModal Renders import modal 1`] = ` + + + + + + + Import events + + + +

+ Import events from an ICS file. +

+
+
+
+ + + + + + + + + + Import + + + Cancel + + +
+
+`; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/import_modal.js b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/import_modal.js new file mode 100644 index 0000000000000..bdadd72d3daf4 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/import_modal.js @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +import React, { + Component, + Fragment +} from 'react'; +import { PropTypes } from 'prop-types'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFilePicker, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import { ImportedEvents } from '../imported_events'; +import { readFile, parseICSFile, filterEvents } from './utils'; + +const MAX_FILE_SIZE_MB = 100; + +export class ImportModal extends Component { + constructor(props) { + super(props); + + this.state = { + includePastEvents: false, + allImportedEvents: [], + selectedEvents: [], + fileLoading: false, + fileLoaded: false, + errorMessage: null, + }; + } + + handleImport = async (loadedFile) => { + const incomingFile = loadedFile[0]; + const errorMessage = 'Could not parse ICS file.'; + let events = []; + + if (incomingFile && incomingFile.size <= (MAX_FILE_SIZE_MB * 1000000)) { + this.setState({ fileLoading: true, fileLoaded: true }); + + try { + const parsedFile = await readFile(incomingFile); + events = parseICSFile(parsedFile.data); + + this.setState({ + allImportedEvents: events, + selectedEvents: filterEvents(events), + fileLoading: false, + errorMessage: null, + includePastEvents: false + }); + } catch (error) { + console.log(errorMessage, error); + this.setState({ errorMessage, fileLoading: false }); + } + } else if (incomingFile && incomingFile.size > (MAX_FILE_SIZE_MB * 1000000)) { + this.setState({ fileLoading: false, errorMessage }); + } else { + this.setState({ fileLoading: false, errorMessage: null }); + } + } + + onEventDelete = (eventId) => { + this.setState(prevState => ({ + allImportedEvents: prevState.allImportedEvents.filter(event => event.event_id !== eventId), + selectedEvents: prevState.selectedEvents.filter(event => event.event_id !== eventId), + })); + } + + onCheckboxToggle = (e) => { + this.setState({ + includePastEvents: e.target.checked, + }); + }; + + handleEventsAdd = () => { + const { allImportedEvents, selectedEvents, includePastEvents } = this.state; + const eventsToImport = includePastEvents ? allImportedEvents : selectedEvents; + + const events = eventsToImport.map((event) => ({ + description: event.description, + start_time: event.start_time, + end_time: event.end_time, + event_id: event.event_id + })); + + this.props.addImportedEvents(events); + } + + renderCallout = () => ( + +

{this.state.errorMessage}

+
+ ); + + render() { + const { closeImportModal } = this.props; + const { + fileLoading, + fileLoaded, + allImportedEvents, + selectedEvents, + errorMessage, + includePastEvents + } = this.state; + + let showRecurringWarning = false; + let importedEvents; + + if (includePastEvents) { + importedEvents = allImportedEvents; + } else { + importedEvents = selectedEvents; + } + + if (importedEvents.find(e => e.asterisk) !== undefined) { + showRecurringWarning = true; + } + + return ( + + + + + + + Import events + + + +

Import events from an ICS file.

+
+
+
+ + + + + + + {errorMessage !== null && this.renderCallout()} + { + allImportedEvents.length > 0 && + + } + + + + + + Import + + + Cancel + + +
+
+ ); + } +} + +ImportModal.propTypes = { + addImportedEvents: PropTypes.func.isRequired, + closeImportModal: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/import_modal.test.js b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/import_modal.test.js new file mode 100644 index 0000000000000..271f67abf9c62 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/import_modal.test.js @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import { shallow, mount } from 'enzyme'; +import React from 'react'; +import { ImportModal } from './import_modal'; + +const testProps = { + addImportedEvents: jest.fn(), + closeImportModal: jest.fn() +}; + +const events = [{ + 'description': 'Downtime feb 9 2017 10:10 to 10:30', + 'start_time': 1486656600000, + 'end_time': 1486657800000, + 'calendar_id': 'farequote-calendar', + 'event_id': 'Ee-YgGcBxHgQWEhCO_xj' +}, +{ + 'description': 'New event!', + 'start_time': 1544076000000, + 'end_time': 1544162400000, + 'calendar_id': 'this-is-a-new-calendar', + 'event_id': 'ehWKhGcBqHkXuWNrIrSV' +}]; + +describe('ImportModal', () => { + + test('Renders import modal', () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('Deletes selected event from event table', () => { + const wrapper = mount( + + ); + + const testState = { + allImportedEvents: events, + selectedEvents: events, + }; + + const instance = wrapper.instance(); + + instance.setState(testState); + wrapper.update(); + expect(wrapper.state('selectedEvents').length).toBe(2); + const deleteButton = wrapper.find('[data-testid="event_delete"]'); + const button = deleteButton.find('EuiButtonEmpty').first(); + button.simulate('click'); + + expect(wrapper.state('selectedEvents').length).toBe(1); + }); + +}); diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/index.js b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/index.js new file mode 100644 index 0000000000000..1dd604712b99f --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +export { ImportModal } from './import_modal'; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/utils.js b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/utils.js new file mode 100644 index 0000000000000..5d67383e53c8f --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/import_modal/utils.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + + + +const icalendar = require('icalendar'); +import moment from 'moment'; +import { generateTempId } from '../utils'; + + +function createEvents(ical) { + const events = ical.events(); + const mlEvents = []; + + events.forEach((e) => { + if (e.element === 'VEVENT') { + const description = e.properties.SUMMARY; + const start = e.properties.DTSTART; + const end = e.properties.DTEND; + const recurring = (e.properties.RRULE !== undefined); + + if (description && start && end && description.length && start.length && end.length) { + // Temp reference to unsaved events to allow removal from table + const tempId = generateTempId(); + + mlEvents.push({ + event_id: tempId, + description: description[0].value, + start_time: start[0].value.valueOf(), + end_time: end[0].value.valueOf(), + asterisk: recurring + }); + } + } + }); + return mlEvents; +} + +export function filterEvents(events) { + const now = moment().valueOf(); + return events.filter(e => e.start_time > now); +} + +export function parseICSFile(data) { + const cal = icalendar.parse_calendar(data); + return createEvents(cal); +} + +export function readFile(file) { + return new Promise((resolve, reject) => { + if (file && file.size) { + const reader = new FileReader(); + reader.readAsText(file); + + reader.onload = (() => { + return () => { + const data = reader.result; + if (data === '') { + reject(); + } else { + resolve({ data }); + } + }; + })(file); + } else { + reject(); + } + }); +} diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/__snapshots__/imported_events.test.js.snap b/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/__snapshots__/imported_events.test.js.snap new file mode 100644 index 0000000000000..17b7a10d5b924 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/__snapshots__/imported_events.test.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ImportedEvents Renders imported events 1`] = ` + + + + +

+ Events to import: + 1 +

+
+
+ + + + + + + +
+`; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/imported_events.js b/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/imported_events.js new file mode 100644 index 0000000000000..488d1541c96cf --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/imported_events.js @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { PropTypes } from 'prop-types'; +import { + EuiCheckbox, + EuiFlexItem, + EuiText, + EuiSpacer +} from '@elastic/eui'; +import { EventsTable } from '../events_table/'; + + +export function ImportedEvents({ + events, + showRecurringWarning, + includePastEvents, + onCheckboxToggle, + onEventDelete, +}) { + return ( + + + + +

Events to import: {events.length}

+ {showRecurringWarning && ( + +

Recurring events not supported. Only the first event will be imported.

+
) + } +
+
+ + + + + + + +
+ ); +} + +ImportedEvents.propTypes = { + events: PropTypes.array.isRequired, + showRecurringWarning: PropTypes.bool.isRequired, + includePastEvents: PropTypes.bool.isRequired, + onCheckboxToggle: PropTypes.func.isRequired, + onEventDelete: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/imported_events.test.js b/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/imported_events.test.js new file mode 100644 index 0000000000000..36451bba5ceb1 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/imported_events.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + + + +jest.mock('ui/chrome', () => ({ + getBasePath: jest.fn() +})); + + +import { shallow } from 'enzyme'; +import React from 'react'; +import { ImportedEvents } from './imported_events'; + +const testProps = { + events: [{ + calendar_id: 'test-calendar', + description: 'test description', + start_time: 1486656600000, + end_time: 1486657800000, + event_id: 'test-event-one' + }], + showRecurringWarning: false, + includePastEvents: false, + onCheckboxToggle: jest.fn(), + onEventDelete: jest.fn(), +}; + +describe('ImportedEvents', () => { + + test('Renders imported events', () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); + +}); diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/index.js b/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/index.js new file mode 100644 index 0000000000000..e52b38005a530 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/imported_events/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ImportedEvents } from './imported_events'; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/index.js b/x-pack/plugins/ml/public/settings/calendars/edit/index.js new file mode 100644 index 0000000000000..fd75a9ceb9b49 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import './directive'; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/new_calendar.js b/x-pack/plugins/ml/public/settings/calendars/edit/new_calendar.js new file mode 100644 index 0000000000000..f7246c566878a --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/new_calendar.js @@ -0,0 +1,317 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import React, { + Component +} from 'react'; +import { PropTypes } from 'prop-types'; + +import { + EuiPage, + EuiPageContent, + EuiOverlayMask, +} from '@elastic/eui'; + +import chrome from 'ui/chrome'; +import { getCalendarSettingsData, validateCalendarId } from './utils'; +import { CalendarForm } from './calendar_form/'; +import { NewEventModal } from './new_event_modal/'; +import { ImportModal } from './import_modal'; +import { ml } from '../../../services/ml_api_service'; +import { toastNotifications } from 'ui/notify'; + +export class NewCalendar extends Component { + constructor(props) { + super(props); + this.state = { + isNewEventModalVisible: false, + isImportModalVisible: false, + isNewCalendarIdValid: null, + loading: true, + jobIds: [], + jobIdOptions: [], + groupIds: [], + groupIdOptions: [], + calendars: [], + formCalendarId: '', + description: '', + selectedJobOptions: [], + selectedGroupOptions: [], + events: [], + saving: false, + selectedCalendar: undefined, + }; + } + + componentDidMount() { + this.formSetup(); + } + + async formSetup() { + try { + const { jobIds, groupIds, calendars } = await getCalendarSettingsData(); + + const jobIdOptions = jobIds.map((jobId) => ({ label: jobId })); + const groupIdOptions = groupIds.map((groupId) => ({ label: groupId })); + + const selectedJobOptions = []; + const selectedGroupOptions = []; + let eventsList = []; + let selectedCalendar; + let formCalendarId = ''; + + // Editing existing calendar. + if (this.props.calendarId !== undefined) { + selectedCalendar = calendars.find((cal) => cal.calendar_id === this.props.calendarId); + + if (selectedCalendar) { + formCalendarId = selectedCalendar.calendar_id; + eventsList = selectedCalendar.events; + + selectedCalendar.job_ids.forEach(id => { + if (jobIds.find((jobId) => jobId === id)) { + selectedJobOptions.push({ label: id }); + } else if (groupIds.find((groupId) => groupId === id)) { + selectedGroupOptions.push({ label: id }); + } + }); + } + } + + this.setState({ + events: eventsList, + formCalendarId, + jobIds, + jobIdOptions, + groupIds, + groupIdOptions, + calendars, + loading: false, + selectedJobOptions, + selectedGroupOptions, + selectedCalendar + }); + } catch (error) { + console.log(error); + this.setState({ loading: false }); + toastNotifications.addDanger('An error occurred loading calendar form data. Try refreshing the page.'); + } + } + + onCreate = async () => { + const calendar = this.setUpCalendarForApi(); + this.setState({ saving: true }); + + try { + await ml.addCalendar(calendar); + window.location = `${chrome.getBasePath()}/app/ml#/settings/calendars_list`; + } catch (error) { + console.log('Error saving calendar', error); + this.setState({ saving: false }); + toastNotifications.addDanger(`An error occurred creating calendar ${calendar.calendarId}`); + } + } + + onEdit = async () => { + const calendar = this.setUpCalendarForApi(); + this.setState({ saving: true }); + + try { + await ml.updateCalendar(calendar); + window.location = `${chrome.getBasePath()}/app/ml#/settings/calendars_list`; + } catch (error) { + console.log('Error saving calendar', error); + this.setState({ saving: false }); + toastNotifications.addDanger(`An error occurred saving calendar ${calendar.calendarId}. Try refreshing the page.`); + } + } + + setUpCalendarForApi = () => { + const { + formCalendarId, + description, + events, + selectedGroupOptions, + selectedJobOptions, + } = this.state; + + const jobIds = selectedJobOptions.map((option) => option.label); + const groupIds = selectedGroupOptions.map((option) => option.label); + + // Reduce events to fields expected by api + const eventsToSave = events.map((event) => ({ + description: event.description, + start_time: event.start_time, + end_time: event.end_time + })); + + // set up calendar + const calendar = { + calendarId: formCalendarId, + description, + events: eventsToSave, + job_ids: [...jobIds, ...groupIds] + }; + + return calendar; + } + + onCreateGroupOption = (newGroup) => { + const newOption = { + label: newGroup, + }; + // Select the option. + this.setState(prevState => ({ + selectedGroupOptions: prevState.selectedGroupOptions.concat(newOption), + })); + }; + + onJobSelection = (selectedJobOptions) => { + this.setState({ + selectedJobOptions, + }); + }; + + onGroupSelection = (selectedGroupOptions) => { + this.setState({ + selectedGroupOptions, + }); + }; + + onCalendarIdChange = (e) => { + const isValid = validateCalendarId(e.target.value); + + this.setState({ + formCalendarId: e.target.value, + isNewCalendarIdValid: isValid + }); + }; + + onDescriptionChange = (e) => { + this.setState({ + description: e.target.value, + }); + }; + + showImportModal = () => { + this.setState(prevState => ({ + isImportModalVisible: !prevState.isImportModalVisible, + })); + } + + closeImportModal = () => { + this.setState({ + isImportModalVisible: false, + }); + } + + onEventDelete = (eventId) => { + this.setState(prevState => ({ + events: prevState.events.filter(event => event.event_id !== eventId) + })); + } + + closeNewEventModal = () => { + this.setState({ isNewEventModalVisible: false }); + } + + showNewEventModal = () => { + this.setState({ isNewEventModalVisible: true }); + } + + addEvent = (event) => { + this.setState(prevState => ({ + events: [...prevState.events, event], + isNewEventModalVisible: false + })); + } + + addImportedEvents = (events) => { + this.setState(prevState => ({ + events: [...prevState.events, ...events], + isImportModalVisible: false + })); + } + + render() { + const { + events, + isNewEventModalVisible, + isImportModalVisible, + isNewCalendarIdValid, + formCalendarId, + description, + groupIdOptions, + jobIdOptions, + saving, + selectedCalendar, + selectedJobOptions, + selectedGroupOptions + } = this.state; + + let modal = ''; + + if (isNewEventModalVisible) { + modal = ( + + + + ); + } else if (isImportModalVisible) { + modal = ( + + + + ); + } + + return ( + + + + + {modal} + + ); + } +} + +NewCalendar.propTypes = { + calendarId: PropTypes.string, +}; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/new_calendar.test.js b/x-pack/plugins/ml/public/settings/calendars/edit/new_calendar.test.js new file mode 100644 index 0000000000000..a65d552ddd019 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/new_calendar.test.js @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +jest.mock('../../../privilege/check_privilege', () => ({ + checkPermission: () => true +})); +jest.mock('../../../license/check_license', () => ({ + hasLicenseExpired: () => false +})); +jest.mock('../../../privilege/get_privileges', () => ({ + getPrivileges: () => {} +})); +jest.mock('../../../ml_nodes_check/check_ml_nodes', () => ({ + mlNodesAvailable: () => true +})); +jest.mock('ui/chrome', () => ({ + getBasePath: jest.fn() +})); +jest.mock('../../../services/ml_api_service', () => ({ + ml: { + calendars: () => { + return Promise.resolve([]); + }, + jobs: { + jobsSummary: () => { + return Promise.resolve([]); + }, + groups: () => { + return Promise.resolve([]); + }, + }, + } +})); +jest.mock('./utils', () => ({ + getCalendarSettingsData: jest.fn().mockImplementation(() => new Promise((resolve) => { + resolve({ + jobIds: ['test-job-one', 'test-job-2'], + groupIds: ['test-group-one', 'test-group-two'], + calendars: [] + }); + })), +})); + +import { shallow, mount } from 'enzyme'; +import React from 'react'; +import { NewCalendar } from './new_calendar'; + +describe('NewCalendar', () => { + + test('Renders new calendar form', () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('Import modal shown on Import Events button click', () => { + const wrapper = mount( + + ); + + const importButton = wrapper.find('[data-testid="ml_import_events"]'); + const button = importButton.find('EuiButton'); + button.simulate('click'); + + expect(wrapper.state('isImportModalVisible')).toBe(true); + }); + + test('New event modal shown on New event button click', () => { + const wrapper = mount( + + ); + + const importButton = wrapper.find('[data-testid="ml_new_event"]'); + const button = importButton.find('EuiButton'); + button.simulate('click'); + + expect(wrapper.state('isNewEventModalVisible')).toBe(true); + }); + +}); diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/new_event_modal/index.js b/x-pack/plugins/ml/public/settings/calendars/edit/new_event_modal/index.js new file mode 100644 index 0000000000000..3c6d2e34d61e7 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/new_event_modal/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { NewEventModal } from './new_event_modal'; + diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/new_event_modal/new_event_modal.js b/x-pack/plugins/ml/public/settings/calendars/edit/new_event_modal/new_event_modal.js new file mode 100644 index 0000000000000..858b60757790e --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/new_event_modal/new_event_modal.js @@ -0,0 +1,289 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +import React, { + Component, + Fragment +} from 'react'; +import { PropTypes } from 'prop-types'; +import { + EuiButton, + EuiButtonEmpty, + EuiDatePicker, + EuiDatePickerRange, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import moment from 'moment'; +import { TIME_FORMAT } from '../events_table/'; +import { generateTempId } from '../utils'; + +const VALID_DATE_STRING_LENGTH = 19; + +export class NewEventModal extends Component { + constructor(props) { + super(props); + + const startDate = moment().startOf('day'); + const endDate = moment().startOf('day').add(1, 'days'); + + this.state = { + startDate, + endDate, + description: '', + startDateString: startDate.format(TIME_FORMAT), + endDateString: endDate.format(TIME_FORMAT) + }; + } + + onDescriptionChange = (e) => { + this.setState({ + description: e.target.value, + }); + }; + + handleAddEvent = () => { + const { description, startDate, endDate } = this.state; + // Temp reference to unsaved events to allow removal from table + const tempId = generateTempId(); + + const event = { + description, + start_time: startDate.valueOf(), + end_time: endDate.valueOf(), + event_id: tempId + }; + + this.props.addEvent(event); + } + + handleChangeStart = (date) => { + let start = null; + let end = this.state.endDate; + + const startMoment = moment(date); + const endMoment = moment(date); + + start = startMoment.startOf('day'); + + if (start > end) { + end = endMoment.startOf('day').add(1, 'days'); + } + this.setState({ + startDate: start, + endDate: end, + startDateString: start.format(TIME_FORMAT), + endDateString: end.format(TIME_FORMAT) + }); + } + + handleChangeEnd = (date) => { + let start = this.state.startDate; + let end = null; + + const startMoment = moment(date); + const endMoment = moment(date); + + end = endMoment.startOf('day'); + + if (start > end) { + start = startMoment.startOf('day').subtract(1, 'days'); + } + this.setState({ + startDate: start, + endDate: end, + startDateString: start.format(TIME_FORMAT), + endDateString: end.format(TIME_FORMAT) + }); + } + + handleTimeStartChange = (event) => { + const dateString = event.target.value; + let isValidDate = false; + + if (dateString.length === VALID_DATE_STRING_LENGTH) { + isValidDate = moment(dateString).isValid(TIME_FORMAT, true); + } else { + this.setState({ + startDateString: dateString, + }); + } + + if (isValidDate) { + this.setState({ + startDateString: dateString, + startDate: moment(dateString) + }); + } + } + + handleTimeEndChange = (event) => { + const dateString = event.target.value; + let isValidDate = false; + + if (dateString.length === VALID_DATE_STRING_LENGTH) { + isValidDate = moment(dateString).isValid(TIME_FORMAT, true); + } else { + this.setState({ + endDateString: dateString, + }); + } + + if (isValidDate) { + this.setState({ + endDateString: dateString, + endDate: moment(dateString) + }); + } + } + + renderRangedDatePicker = () => { + const { + startDate, + endDate, + startDateString, + endDateString, + } = this.state; + + const timeInputs = ( + + + + + + + + + + + + + + + ); + + return ( + + + {timeInputs} + + + endDate} + aria-label="Start date" + timeFormat={TIME_FORMAT} + dateFormat={TIME_FORMAT} + /> + } + endDateControl={ + endDate} + aria-label="End date" + timeFormat={TIME_FORMAT} + dateFormat={TIME_FORMAT} + /> + } + /> + + + ); + } + + render() { + const { closeModal } = this.props; + const { description } = this.state; + + return ( + + + + + Create new event + + + + + + + + + {this.renderRangedDatePicker()} + + + + + + + Add + + + Cancel + + + + + ); + } +} + +NewEventModal.propTypes = { + closeModal: PropTypes.func.isRequired, + addEvent: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/new_event_modal/new_event_modal.test.js b/x-pack/plugins/ml/public/settings/calendars/edit/new_event_modal/new_event_modal.test.js new file mode 100644 index 0000000000000..00541866eb7ee --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/new_event_modal/new_event_modal.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import { shallow } from 'enzyme'; +import React from 'react'; +import { NewEventModal } from './new_event_modal'; +import moment from 'moment'; + +const testProps = { + closeModal: jest.fn(), + addEvent: jest.fn(), +}; + +const stateTimestamps = { + startDate: 1544508000000, + endDate: 1544594400000 +}; + +describe('NewEventModal', () => { + + it('Add button disabled if description empty', () => { + const wrapper = shallow( + + ); + + const addButton = wrapper.find('EuiButton').first(); + expect(addButton.prop('disabled')).toBe(true); + }); + + it('if endDate is less than startDate should set startDate one day before endDate', () => { + const wrapper = shallow(); + const instance = wrapper.instance(); + instance.setState({ + startDate: moment(stateTimestamps.startDate), + endDate: moment(stateTimestamps.endDate) + }); + // set to Dec 11, 2018 and Dec 12, 2018 + const startMoment = moment(stateTimestamps.startDate); + const endMoment = moment(stateTimestamps.endDate); + // make startMoment greater than current end Date + startMoment.startOf('day').add(3, 'days'); + // trigger handleChangeStart directly with startMoment + instance.handleChangeStart(startMoment); + // add 3 days to endMoment as it will be adjusted to be one day after startDate + const expected = endMoment.startOf('day').add(3, 'days').format(); + + expect(wrapper.state('endDate').format()).toBe(expected); + }); + + it('if startDate is greater than endDate should set endDate one day after startDate', () => { + const wrapper = shallow(); + const instance = wrapper.instance(); + instance.setState({ + startDate: moment(stateTimestamps.startDate), + endDate: moment(stateTimestamps.endDate) + }); + + // set to Dec 11, 2018 and Dec 12, 2018 + const startMoment = moment(stateTimestamps.startDate); + const endMoment = moment(stateTimestamps.endDate); + // make endMoment less than current start Date + endMoment.startOf('day').subtract(3, 'days'); + // trigger handleChangeStart directly with endMoment + instance.handleChangeStart(endMoment); + // subtract 3 days from startDate as it will be adjusted to be one day before endDate + const expected = startMoment.startOf('day').subtract(2, 'days').format(); + + expect(wrapper.state('startDate').format()).toBe(expected); + }); + +}); diff --git a/x-pack/plugins/ml/public/settings/calendars/edit/utils.js b/x-pack/plugins/ml/public/settings/calendars/edit/utils.js new file mode 100644 index 0000000000000..1a0606578f1b5 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/edit/utils.js @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import { ml } from '../../../services/ml_api_service'; +import { isJobIdValid } from '../../../../common/util/job_utils'; + + +function getJobIds() { + return new Promise((resolve, reject) => { + ml.jobs.jobsSummary() + .then((resp) => { + resolve(resp.map((job) => job.id)); + }) + .catch((err) => { + const errorMessage = `Error fetching job summaries: ${err}`; + console.log(errorMessage); + reject(errorMessage); + }); + }); +} + +function getGroupIds() { + return new Promise((resolve, reject) => { + ml.jobs.groups() + .then((resp) => { + resolve(resp.map((group) => group.id)); + }) + .catch((err) => { + const errorMessage = `Error loading groups: ${err}`; + console.log(errorMessage); + reject(errorMessage); + }); + }); +} + +function getCalendars() { + return new Promise((resolve, reject) => { + ml.calendars() + .then((resp) => { + resolve(resp); + }) + .catch((err) => { + const errorMessage = `Error loading calendars: ${err}`; + console.log(errorMessage); + reject(errorMessage); + }); + }); +} + +export function getCalendarSettingsData() { + return new Promise(async (resolve, reject) => { + try { + const data = await Promise.all([getJobIds(), getGroupIds(), getCalendars()]); + + const formattedData = { + jobIds: data[0], + groupIds: data[1], + calendars: data[2] + }; + resolve(formattedData); + } catch (error) { + console.log(error); + reject(error); + } + }); +} + +export function validateCalendarId(calendarId) { + let valid = true; + + if (calendarId === '' || calendarId === undefined) { + valid = false; + } else if (isJobIdValid(calendarId) === false) { + valid = false; + } + + return valid; +} + +export function generateTempId() { + return Math.random().toString(36).substr(2, 9); +} diff --git a/x-pack/plugins/ml/public/settings/calendars/index.js b/x-pack/plugins/ml/public/settings/calendars/index.js new file mode 100644 index 0000000000000..bcc62f4c5b10e --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/index.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import './list'; +import './edit'; diff --git a/x-pack/plugins/ml/public/settings/calendars/list/__snapshots__/calendars_list.test.js.snap b/x-pack/plugins/ml/public/settings/calendars/list/__snapshots__/calendars_list.test.js.snap new file mode 100644 index 0000000000000..2be04c34494ec --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/__snapshots__/calendars_list.test.js.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CalendarsList Renders calendar list with calendars 1`] = ` + + + + + +`; diff --git a/x-pack/plugins/ml/public/settings/calendars/list/_index.scss b/x-pack/plugins/ml/public/settings/calendars/list/_index.scss new file mode 100644 index 0000000000000..7c77179197bc4 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/_index.scss @@ -0,0 +1 @@ +@import 'list'; diff --git a/x-pack/plugins/ml/public/settings/calendars/list/_list.scss b/x-pack/plugins/ml/public/settings/calendars/list/_list.scss new file mode 100644 index 0000000000000..4587859f6b33d --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/_list.scss @@ -0,0 +1,9 @@ +.mlCalendarList { + + .mlCalendarList__content { + max-width: map-get($euiBreakpoints, 'xl'); + margin-top: $euiSize; + margin-bottom: $euiSize; + } + +} diff --git a/x-pack/plugins/ml/public/settings/calendars/list/calendars_list.js b/x-pack/plugins/ml/public/settings/calendars/list/calendars_list.js new file mode 100644 index 0000000000000..3ccddf7251d78 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/calendars_list.js @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import React, { + Component +} from 'react'; + +import { + EuiConfirmModal, + EuiOverlayMask, + EuiPage, + EuiPageContent, + EUI_MODAL_CONFIRM_BUTTON, +} from '@elastic/eui'; + +import { CalendarsListTable } from './table/'; +import { ml } from '../../../services/ml_api_service'; +import { toastNotifications } from 'ui/notify'; +import { checkPermission } from '../../../privilege/check_privilege'; +import { mlNodesAvailable } from '../../../ml_nodes_check/check_ml_nodes'; +import { deleteCalendars } from './delete_calendars'; + +export class CalendarsList extends Component { + constructor(props) { + super(props); + this.state = { + loading: true, + calendars: [], + isDestroyModalVisible: false, + calendarId: null, + selectedForDeletion: [], + canCreateCalendar: checkPermission('canCreateCalendar'), + canDeleteCalendar: checkPermission('canDeleteCalendar'), + nodesAvailable: mlNodesAvailable() + }; + } + + loadCalendars = async () => { + try { + const calendars = await ml.calendars(); + + this.setState({ + calendars, + loading: false, + isDestroyModalVisible: false, + }); + } catch (error) { + console.log(error); + this.setState({ loading: false }); + toastNotifications.addDanger('An error occurred loading the list of calendars.'); + } + } + + closeDestroyModal = () => { + this.setState({ isDestroyModalVisible: false, calendarId: null }); + } + + showDestroyModal = () => { + this.setState({ isDestroyModalVisible: true }); + } + + setSelectedCalendarList = (selectedCalendars) => { + this.setState({ selectedForDeletion: selectedCalendars }); + } + + deleteCalendars = () => { + const { selectedForDeletion } = this.state; + + this.closeDestroyModal(); + deleteCalendars(selectedForDeletion, this.loadCalendars); + } + + addRequiredFieldsToList = (calendarsList = []) => { + for (let i = 0; i < calendarsList.length; i++) { + const eventLength = calendarsList[i].events.length; + calendarsList[i].job_ids_string = calendarsList[i].job_ids.join(', '); + calendarsList[i].events_length = `${eventLength} ${eventLength === 1 ? 'event' : 'events'}`; + } + + return calendarsList; + } + + componentDidMount() { + this.loadCalendars(); + } + + render() { + const { + calendars, + selectedForDeletion, + loading, + canCreateCalendar, + canDeleteCalendar, + nodesAvailable + } = this.state; + let destroyModal = ''; + + if (this.state.isDestroyModalVisible) { + destroyModal = ( + + +

+ { + `Delete ${selectedForDeletion.length === 1 ? 'this' : 'these'} + calendar${selectedForDeletion.length === 1 ? '' : 's'}? + ${selectedForDeletion.map((c) => c.calendar_id).join(', ')}` + } +

+
+
+ ); + } + + return ( + + + 0} + /> + + {destroyModal} + + ); + } +} diff --git a/x-pack/plugins/ml/public/settings/calendars/list/calendars_list.test.js b/x-pack/plugins/ml/public/settings/calendars/list/calendars_list.test.js new file mode 100644 index 0000000000000..3d55c1e47c06c --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/calendars_list.test.js @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +jest.mock('../../../privilege/check_privilege', () => ({ + checkPermission: () => true +})); +jest.mock('../../../license/check_license', () => ({ + hasLicenseExpired: () => false +})); +jest.mock('../../../privilege/get_privileges', () => ({ + getPrivileges: () => {} +})); +jest.mock('../../../ml_nodes_check/check_ml_nodes', () => ({ + mlNodesAvailable: () => true +})); +jest.mock('ui/chrome', () => ({ + getBasePath: jest.fn() +})); +jest.mock('../../../services/ml_api_service', () => ({ + ml: { + calendars: () => { + return Promise.resolve([]); + }, + delete: jest.fn(), + } +})); + +import { shallow, mount } from 'enzyme'; +import React from 'react'; +import { ml } from '../../../services/ml_api_service'; + +import { CalendarsList } from './calendars_list'; + +const testingState = { + loading: false, + calendars: [ + { + 'calendar_id': 'farequote-calendar', + 'job_ids': ['farequote'], + 'description': 'test ', + 'events': [{ + 'description': 'Downtime feb 9 2017 10:10 to 10:30', + 'start_time': 1486656600000, + 'end_time': 1486657800000, + 'calendar_id': 'farequote-calendar', + 'event_id': 'Ee-YgGcBxHgQWEhCO_xj' + }] + }, + { + 'calendar_id': 'this-is-a-new-calendar', + 'job_ids': ['test'], + 'description': 'new calendar', + 'events': [{ + 'description': 'New event!', + 'start_time': 1544076000000, + 'end_time': 1544162400000, + 'calendar_id': 'this-is-a-new-calendar', + 'event_id': 'ehWKhGcBqHkXuWNrIrSV' + }] + }], + isDestroyModalVisible: false, + calendarId: null, + selectedForDeletion: [], + canCreateCalendar: true, + canDeleteCalendar: true, + nodesAvailable: true, +}; + +describe('CalendarsList', () => { + + test('loads calendars on mount', () => { + ml.calendars = jest.fn(); + shallow( + + ); + + expect(ml.calendars).toHaveBeenCalled(); + }); + + test('Renders calendar list with calendars', () => { + const wrapper = shallow( + + ); + + wrapper.instance().setState(testingState); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + }); + + test('Sets selected calendars list on checkbox change', () => { + const wrapper = mount( + + ); + + const instance = wrapper.instance(); + const spy = jest.spyOn(instance, 'setSelectedCalendarList'); + instance.setState(testingState); + wrapper.update(); + + const checkbox = wrapper.find('input[type="checkbox"]').first(); + checkbox.simulate('change'); + expect(spy).toHaveBeenCalled(); + }); + +}); diff --git a/x-pack/plugins/ml/public/settings/calendars/list/delete_calendars.js b/x-pack/plugins/ml/public/settings/calendars/list/delete_calendars.js new file mode 100644 index 0000000000000..36dfe87a53bab --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/delete_calendars.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { toastNotifications } from 'ui/notify'; +import { ml } from '../../../services/ml_api_service'; + + +export async function deleteCalendars(calendarsToDelete, callback) { + if (calendarsToDelete === undefined || calendarsToDelete.length === 0) { + return; + } + + // Delete each of the specified calendars in turn, waiting for each response + // before deleting the next to minimize load on the cluster. + const messageId = `${(calendarsToDelete.length > 1) ? + `${calendarsToDelete.length} calendars` : calendarsToDelete[0].calendar_id}`; + toastNotifications.add(`Deleting ${messageId}`); + + for(const calendar of calendarsToDelete) { + const calendarId = calendar.calendar_id; + try { + await ml.deleteCalendar({ calendarId }); + } catch (error) { + console.log('Error deleting calendar:', error); + let errorMessage = `An error occurred deleting calendar ${calendar.calendar_id}`; + if (error.message) { + errorMessage += ` : ${error.message}`; + } + toastNotifications.addDanger(errorMessage); + } + } + + toastNotifications.addSuccess(`${messageId} deleted`); + callback(); +} diff --git a/x-pack/plugins/ml/public/settings/calendars/list/directive.js b/x-pack/plugins/ml/public/settings/calendars/list/directive.js new file mode 100644 index 0000000000000..4ecfb85882a10 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/directive.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + + +import 'ngreact'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml', ['react']); + +import { checkFullLicense } from '../../../license/check_license'; +import { checkGetJobsPrivilege } from '../../../privilege/check_privilege'; +import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; +import { initPromise } from '../../../util/promise'; + +import uiRoutes from 'ui/routes'; + +const template = ` + +
+ +
+`; + +uiRoutes + .when('/settings/calendars_list', { + template, + resolve: { + CheckLicense: checkFullLicense, + privileges: checkGetJobsPrivilege, + mlNodeCount: getMlNodeCount, + initPromise: initPromise(false) + } + }); + + +import { CalendarsList } from './calendars_list'; + +module.directive('mlCalendarsList', function () { + return { + restrict: 'E', + replace: false, + scope: {}, + link: function (scope, element) { + ReactDOM.render( + React.createElement(CalendarsList), + element[0] + ); + } + }; +}); diff --git a/x-pack/plugins/ml/public/settings/calendars/list/index.js b/x-pack/plugins/ml/public/settings/calendars/list/index.js new file mode 100644 index 0000000000000..fd75a9ceb9b49 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import './directive'; diff --git a/x-pack/plugins/ml/public/settings/calendars/list/table/__snapshots__/table.test.js.snap b/x-pack/plugins/ml/public/settings/calendars/list/table/__snapshots__/table.test.js.snap new file mode 100644 index 0000000000000..4b3a3dab2f100 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/table/__snapshots__/table.test.js.snap @@ -0,0 +1,110 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CalendarsListTable renders the table with all calendars 1`] = ` + + + New + , + + Delete + , + ], + } + } + selection={ + Object { + "onSelectionChange": [Function], + } + } + sorting={ + Object { + "sort": Object { + "direction": "asc", + "field": "calendar_id", + }, + } + } + /> + +`; diff --git a/x-pack/plugins/ml/public/settings/calendars/list/table/index.js b/x-pack/plugins/ml/public/settings/calendars/list/table/index.js new file mode 100644 index 0000000000000..c5c8489517452 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/table/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +export { CalendarsListTable } from './table'; diff --git a/x-pack/plugins/ml/public/settings/calendars/list/table/table.js b/x-pack/plugins/ml/public/settings/calendars/list/table/table.js new file mode 100644 index 0000000000000..ea5b02b568ede --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/table/table.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + + + +import PropTypes from 'prop-types'; +import React from 'react'; + +import { + EuiButton, + EuiLink, + EuiInMemoryTable, +} from '@elastic/eui'; + +import chrome from 'ui/chrome'; + + +export function CalendarsListTable({ + calendarsList, + onDeleteClick, + setSelectedCalendarList, + loading, + canCreateCalendar, + canDeleteCalendar, + mlNodesAvailable, + itemsSelected +}) { + + const sorting = { + sort: { + field: 'calendar_id', + direction: 'asc', + } + }; + + const pagination = { + initialPageSize: 20, + pageSizeOptions: [10, 20] + }; + + const columns = [ + { + field: 'calendar_id', + name: 'ID', + sortable: true, + truncateText: true, + render: (id) => ( + + {id} + + ) + }, + { + field: 'job_ids_string', + name: 'Jobs', + sortable: true, + truncateText: true, + }, + { + field: 'events_length', + name: 'Events', + sortable: true + } + ]; + + const tableSelection = { + onSelectionChange: (selection) => setSelectedCalendarList(selection) + }; + + const search = { + toolsRight: [ + ( + + New + + ), + ( + + Delete + + ) + ], + box: { + incremental: true, + }, + filters: [] + }; + + return ( + + + + ); +} + +CalendarsListTable.propTypes = { + calendarsList: PropTypes.array.isRequired, + onDeleteClick: PropTypes.func.isRequired, + loading: PropTypes.bool.isRequired, + canCreateCalendar: PropTypes.bool.isRequired, + canDeleteCalendar: PropTypes.bool.isRequired, + mlNodesAvailable: PropTypes.bool.isRequired, + setSelectedCalendarList: PropTypes.func.isRequired, + itemsSelected: PropTypes.bool.isRequired, +}; diff --git a/x-pack/plugins/ml/public/settings/calendars/list/table/table.test.js b/x-pack/plugins/ml/public/settings/calendars/list/table/table.test.js new file mode 100644 index 0000000000000..0c0b9605a6e09 --- /dev/null +++ b/x-pack/plugins/ml/public/settings/calendars/list/table/table.test.js @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +import { shallow, mount } from 'enzyme'; +import React from 'react'; + +import { CalendarsListTable } from './table'; + + +jest.mock('ui/chrome', () => ({ + getBasePath: jest.fn() +})); + +const calendars = [ + { + 'calendar_id': 'farequote-calendar', + 'job_ids': ['farequote'], + 'description': 'test ', + 'events': [] }, + { + 'calendar_id': 'this-is-a-new-calendar', + 'job_ids': ['test'], + 'description': 'new calendar', + 'events': [] }]; + +const props = { + calendarsList: calendars, + canCreateCalendar: true, + canDeleteCalendar: true, + itemsSelected: false, + loading: false, + mlNodesAvailable: true, + onDeleteClick: () => { }, + setSelectedCalendarList: () => { } +}; + +describe('CalendarsListTable', () => { + + test('renders the table with all calendars', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + test('New button enabled if permission available', () => { + const wrapper = mount( + + ); + + const buttons = wrapper.find('[data-testid="new_calendar_button"]'); + const button = buttons.find('EuiButton'); + + expect(button.prop('isDisabled')).toEqual(false); + }); + + test('New button disabled if no permission available', () => { + const disableProps = { + ...props, + canCreateCalendar: false + }; + + const wrapper = mount( + + ); + + const buttons = wrapper.find('[data-testid="new_calendar_button"]'); + const button = buttons.find('EuiButton'); + + expect(button.prop('isDisabled')).toEqual(true); + }); + + + test('New button disabled if no ML nodes available', () => { + const disableProps = { + ...props, + mlNodesAvailable: false + }; + + const wrapper = mount( + + ); + + const buttons = wrapper.find('[data-testid="new_calendar_button"]'); + const button = buttons.find('EuiButton'); + + expect(button.prop('isDisabled')).toEqual(true); + }); + +}); diff --git a/x-pack/plugins/ml/public/settings/index.js b/x-pack/plugins/ml/public/settings/index.js index 414587ae8d9ce..5faba118e050c 100644 --- a/x-pack/plugins/ml/public/settings/index.js +++ b/x-pack/plugins/ml/public/settings/index.js @@ -7,5 +7,5 @@ import './settings_controller'; -import './scheduled_events'; +import './calendars'; import './filter_lists'; diff --git a/x-pack/plugins/ml/public/settings/scheduled_events/calendars_list/__tests__/calendars_list_controller.js b/x-pack/plugins/ml/public/settings/scheduled_events/calendars_list/__tests__/calendars_list_controller.js index 30121de63ac92..7e519be3ddaeb 100644 --- a/x-pack/plugins/ml/public/settings/scheduled_events/calendars_list/__tests__/calendars_list_controller.js +++ b/x-pack/plugins/ml/public/settings/scheduled_events/calendars_list/__tests__/calendars_list_controller.js @@ -9,7 +9,7 @@ import ngMock from 'ng_mock'; import expect from 'expect.js'; -describe('ML - Calendars List Controller', () => { +xdescribe('ML - Calendars List Controller', () => { beforeEach(() => { ngMock.module('kibana'); }); diff --git a/x-pack/plugins/ml/public/settings/scheduled_events/components/import_events_modal/__tests__/import_events_modal_controller.js b/x-pack/plugins/ml/public/settings/scheduled_events/components/import_events_modal/__tests__/import_events_modal_controller.js index 5db68ce991f42..7984eb93e0801 100644 --- a/x-pack/plugins/ml/public/settings/scheduled_events/components/import_events_modal/__tests__/import_events_modal_controller.js +++ b/x-pack/plugins/ml/public/settings/scheduled_events/components/import_events_modal/__tests__/import_events_modal_controller.js @@ -11,7 +11,7 @@ import expect from 'expect.js'; const mockModalInstance = { close: function () { }, dismiss: function () { } }; -describe('ML - Import Events Modal Controller', () => { +xdescribe('ML - Import Events Modal Controller', () => { beforeEach(() => { ngMock.module('kibana'); }); diff --git a/x-pack/plugins/ml/public/settings/scheduled_events/components/new_event_modal/__tests__/new_event_modal_controller.js b/x-pack/plugins/ml/public/settings/scheduled_events/components/new_event_modal/__tests__/new_event_modal_controller.js index 6c7b3cb85ba23..a6f29dc0939d8 100644 --- a/x-pack/plugins/ml/public/settings/scheduled_events/components/new_event_modal/__tests__/new_event_modal_controller.js +++ b/x-pack/plugins/ml/public/settings/scheduled_events/components/new_event_modal/__tests__/new_event_modal_controller.js @@ -11,7 +11,7 @@ import expect from 'expect.js'; const mockModalInstance = { close: function () { }, dismiss: function () { } }; -describe('ML - New Event Modal Controller', () => { +xdescribe('ML - New Event Modal Controller', () => { beforeEach(() => { ngMock.module('kibana'); }); diff --git a/x-pack/plugins/ml/public/settings/scheduled_events/new_calendar/__tests__/create_calendar_controller.js b/x-pack/plugins/ml/public/settings/scheduled_events/new_calendar/__tests__/create_calendar_controller.js index a8605d5f8d359..829354a2927e7 100644 --- a/x-pack/plugins/ml/public/settings/scheduled_events/new_calendar/__tests__/create_calendar_controller.js +++ b/x-pack/plugins/ml/public/settings/scheduled_events/new_calendar/__tests__/create_calendar_controller.js @@ -9,7 +9,7 @@ import ngMock from 'ng_mock'; import expect from 'expect.js'; -describe('ML - Create Calendar Controller', () => { +xdescribe('ML - Create Calendar Controller', () => { beforeEach(() => { ngMock.module('kibana'); });