diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/units.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/units.ts index fdf879807bc6..0a172974bd34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/units.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/units.ts @@ -7,6 +7,10 @@ import { i18n } from '@kbn/i18n'; +export const ALL_DAYS_LABEL = i18n.translate('xpack.enterpriseSearch.units.allDaysLabel', { + defaultMessage: 'All days', +}); + export const MINUTES_UNIT_LABEL = i18n.translate('xpack.enterpriseSearch.units.minutesLabel', { defaultMessage: 'Minutes', }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts index e7b4e543b5eb..fab241163f47 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts @@ -51,9 +51,9 @@ const defaultIndexing = { rules: [], schedule: { full: 'P1D', - incremental: 'P2H', - delete: 'P10M', - permissions: 'P3H', + incremental: 'PT2H', + delete: 'PT10M', + permissions: 'PT3H', estimates: { full: { nextStart: '2021-09-30T15:37:38+00:00', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 4a3b6a11c707..2cec9f617cd2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -768,8 +768,8 @@ export const BETWEEN_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSea defaultMessage: 'between', }); -export const EVERY_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.everyLabel', { - defaultMessage: 'every', +export const ON_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.onLabel', { + defaultMessage: 'on', }); export const AND = i18n.translate('xpack.enterpriseSearch.workplaceSearch.and', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 9956eae229a0..f81672e71e01 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -143,8 +143,9 @@ interface SyncIndexItem { permissions?: T; } -interface IndexingSchedule extends SyncIndexItem { +export interface IndexingSchedule extends SyncIndexItem { estimates: SyncIndexItem; + blockedWindows?: BlockedWindow[]; } export type SyncJobType = 'full' | 'incremental' | 'delete' | 'permissions'; @@ -162,7 +163,7 @@ export type DayOfWeek = typeof DAYS_OF_WEEK_VALUES[number]; export interface BlockedWindow { jobType: SyncJobType; - day: DayOfWeek; + day: DayOfWeek | 'all'; start: Moment; end: Moment; } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.test.tsx index 9f8f0b08f3ca..703b1f9d8c5f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiComboBox, EuiDatePicker, EuiSuperSelect } from '@elastic/eui'; +import { EuiDatePickerRange, EuiSelect, EuiSuperSelect } from '@elastic/eui'; import { BlockedWindowItem } from './blocked_window_item'; @@ -20,8 +20,8 @@ describe('BlockedWindowItem', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiComboBox)).toHaveLength(1); + expect(wrapper.find(EuiSelect)).toHaveLength(1); expect(wrapper.find(EuiSuperSelect)).toHaveLength(1); - expect(wrapper.find(EuiDatePicker)).toHaveLength(2); + expect(wrapper.find(EuiDatePickerRange)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.tsx index bcc2aa72668f..5aec23b5faae 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.tsx @@ -7,19 +7,25 @@ import React from 'react'; +import moment from 'moment'; + import { EuiButton, - EuiComboBox, EuiDatePicker, + EuiDatePickerRange, EuiFlexGroup, EuiFlexItem, + EuiIconTip, + EuiSelect, + EuiSelectOption, EuiSpacer, EuiSuperSelect, EuiText, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; -import { DAYS_OF_WEEK_LABELS } from '../../../../../shared/constants'; -import { BLOCK_LABEL, BETWEEN_LABEL, EVERY_LABEL, AND, REMOVE_BUTTON } from '../../../../constants'; +import { ALL_DAYS_LABEL, DAYS_OF_WEEK_LABELS } from '../../../../../shared/constants'; +import { BLOCK_LABEL, BETWEEN_LABEL, ON_LABEL, REMOVE_BUTTON } from '../../../../constants'; import { BlockedWindow, DAYS_OF_WEEK_VALUES } from '../../../../types'; import { @@ -31,6 +37,7 @@ import { INCREMENTAL_SYNC_DESCRIPTION, DELETION_SYNC_DESCRIPTION, PERMISSIONS_SYNC_DESCRIPTION, + UTC_TITLE, } from '../../constants'; interface Props { @@ -80,10 +87,11 @@ const syncOptions = [ }, ]; -const dayPickerOptions = DAYS_OF_WEEK_VALUES.map((day) => ({ - label: DAYS_OF_WEEK_LABELS[day.toUpperCase() as keyof typeof DAYS_OF_WEEK_LABELS], +const daySelectOptions = DAYS_OF_WEEK_VALUES.map((day) => ({ + text: DAYS_OF_WEEK_LABELS[day.toUpperCase() as keyof typeof DAYS_OF_WEEK_LABELS], value: day, -})); +})) as EuiSelectOption[]; +daySelectOptions.push({ text: ALL_DAYS_LABEL, value: 'all' }); export const BlockedWindowItem: React.FC = ({ blockedWindow }) => { const handleSyncTypeChange = () => '#TODO'; @@ -99,7 +107,7 @@ export const BlockedWindowItem: React.FC = ({ blockedWindow }) => { = ({ blockedWindow }) => { /> - {BETWEEN_LABEL} + {ON_LABEL} - - + + - {AND} + {BETWEEN_LABEL} - - + + } + endDateControl={ + + } /> - {EVERY_LABEL} - - - + + + } /> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx index 3ca34f496047..914ec9dfe6ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx @@ -104,7 +104,7 @@ export const Frequency: React.FC = ({ tabId }) => { action={actions} /> {docsLinks} - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.test.tsx index ce295b467a09..b3421539cb7b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.test.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import moment from 'moment'; -import { EuiFieldNumber, EuiSuperSelect } from '@elastic/eui'; +import { EuiFieldNumber } from '@elastic/eui'; import { FrequencyItem } from './frequency_item'; @@ -31,37 +31,26 @@ describe('FrequencyItem', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiSuperSelect)).toHaveLength(1); - expect(wrapper.find(EuiFieldNumber)).toHaveLength(1); + expect(wrapper.find(EuiFieldNumber)).toHaveLength(3); }); describe('ISO8601 formatting', () => { it('handles minutes display', () => { const wrapper = shallow(); - expect(wrapper.find(EuiFieldNumber).prop('value')).toEqual(1563); - expect(wrapper.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('minutes'); + expect(wrapper.find('[data-test-subj="durationMinutes"]').prop('value')).toEqual(3); }); it('handles hours display', () => { const wrapper = shallow(); - expect(wrapper.find(EuiFieldNumber).prop('value')).toEqual(26); - expect(wrapper.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('hours'); + expect(wrapper.find('[data-test-subj="durationHours"]').prop('value')).toEqual(2); }); it('handles days display', () => { const wrapper = shallow(); - expect(wrapper.find(EuiFieldNumber).prop('value')).toEqual(3); - expect(wrapper.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('days'); - }); - - it('handles seconds display (defaults to 1 minute)', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiFieldNumber).prop('value')).toEqual(1); - expect(wrapper.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('minutes'); + expect(wrapper.find('[data-test-subj="durationDays"]').prop('value')).toEqual(3); }); it('handles "nextStart" that is in past', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx index 38f85ff2acca..618f5c73d609 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx @@ -15,7 +15,6 @@ import { EuiFlexItem, EuiIconTip, EuiSpacer, - EuiSuperSelect, EuiText, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -36,30 +35,12 @@ interface Props { estimate: SyncEstimate; } -const unitOptions = [ - { - value: 'minutes', - inputDisplay: MINUTES_UNIT_LABEL, - }, - { - value: 'hours', - inputDisplay: HOURS_UNIT_LABEL, - }, - { - value: 'days', - inputDisplay: DAYS_UNIT_LABEL, - }, -]; - export const FrequencyItem: React.FC = ({ label, description, duration, estimate }) => { - const [interval, unit] = formatDuration(duration); const { lastRun, nextStart, duration: durationEstimate } = estimate; const estimateDisplay = durationEstimate && moment.duration(durationEstimate).humanize(); const nextStartIsPast = moment().isAfter(nextStart); const nextStartTime = nextStartIsPast ? NEXT_SYNC_RUNNING_MESSAGE : moment(nextStart).fromNow(); - const onChange = () => '#TODO'; - const frequencyItemLabel = ( = ({ label, description, duration, e {frequencyItemLabel} - - + + + + + - - + + @@ -139,24 +135,3 @@ export const FrequencyItem: React.FC = ({ label, description, duration, e ); }; - -// In most cases, the user will use the form to set the sync frequency, in which case the duration -// will be in the format of "PT3D" (ISO 8601). However, if an operator has set the sync frequency via -// the API, the duration could be a complex format, such as "P1DT2H3M4S". It was decided that in this -// case, we should omit seconds and go with the least common denominator from minutes. -// -// Example: "P1DT2H3M4S" -> "1563 Minutes" -const formatDuration = (duration: string): [interval: number, unit: string] => { - const momentDuration = moment.duration(duration); - if (duration.includes('M')) { - return [Math.round(momentDuration.asMinutes()), unitOptions[0].value]; - } - if (duration.includes('H')) { - return [Math.round(momentDuration.asHours()), unitOptions[1].value]; - } - if (duration.includes('D')) { - return [Math.round(momentDuration.asDays()), unitOptions[2].value]; - } - - return [1, unitOptions[0].value]; -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.test.ts index c51ef6cf2bf3..298c315f4cf9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.test.ts @@ -26,7 +26,11 @@ jest.mock('../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); -import { SynchronizationLogic, emptyBlockedWindow } from './synchronization_logic'; +import { + SynchronizationLogic, + emptyBlockedWindow, + stripScheduleSeconds, +} from './synchronization_logic'; describe('SynchronizationLogic', () => { const { http } = mockHttpValues; @@ -38,9 +42,12 @@ describe('SynchronizationLogic', () => { const defaultValues = { navigatingBetweenTabs: false, hasUnsavedObjectsAndAssetsChanges: false, + hasUnsavedFrequencyChanges: true, contentExtractionChecked: true, thumbnailsChecked: true, blockedWindows: [], + schedule: contentSource.indexing.schedule, + cachedSchedule: contentSource.indexing.schedule, }; beforeEach(() => { @@ -219,4 +226,17 @@ describe('SynchronizationLogic', () => { }); }); }); + + describe('stripScheduleSeconds', () => { + it('handles case where permissions not present', () => { + const schedule = { + full: 'P3D', + incremental: 'P5D', + delete: 'PT2H', + }; + const stripped = stripScheduleSeconds(schedule as any); + + expect(stripped.permissions).toBeUndefined(); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts index 4106ab70cf20..3aaa7f5fdfbf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts @@ -6,6 +6,7 @@ */ import { kea, MakeLogicType } from 'kea'; +import { cloneDeep, isEqual } from 'lodash'; import moment from 'moment'; export type TabId = 'source_sync_frequency' | 'blocked_time_windows'; @@ -19,7 +20,7 @@ import { BLOCKED_TIME_WINDOWS_PATH, getContentSourcePath, } from '../../../../routes'; -import { BlockedWindow } from '../../../../types'; +import { BlockedWindow, IndexingSchedule } from '../../../../types'; import { SYNC_ENABLED_MESSAGE, @@ -46,6 +47,8 @@ interface SynchronizationValues { thumbnailsChecked: boolean; contentExtractionChecked: boolean; blockedWindows: BlockedWindow[]; + cachedSchedule: IndexingSchedule; + schedule: IndexingSchedule; } export const emptyBlockedWindow: BlockedWindow = { @@ -77,7 +80,7 @@ export const SynchronizationLogic = kea< }, ], blockedWindows: [ - [], + props.contentSource.indexing.schedule.blockedWindows || [], { addBlockedWindow: (state, _) => [...state, emptyBlockedWindow], }, @@ -94,6 +97,8 @@ export const SynchronizationLogic = kea< setContentExtractionChecked: (_, contentExtractionChecked) => contentExtractionChecked, }, ], + cachedSchedule: [stripScheduleSeconds(props.contentSource.indexing.schedule)], + schedule: [stripScheduleSeconds(props.contentSource.indexing.schedule)], }), selectors: ({ selectors }) => ({ hasUnsavedObjectsAndAssetsChanges: [ @@ -118,6 +123,10 @@ export const SynchronizationLogic = kea< ); }, ], + hasUnsavedFrequencyChanges: [ + () => [selectors.cachedSchedule, selectors.schedule], + (cachedSchedule, schedule) => isEqual(cachedSchedule, schedule), + ], }), listeners: ({ actions, values, props }) => ({ handleSelectedTabChanged: async (tabId, breakpoint) => { @@ -187,3 +196,22 @@ export const SynchronizationLogic = kea< }, }), }); + +const SECONDS_IN_MS = 1000; +const getDurationMS = (time: string): number => moment.duration(time).seconds() * SECONDS_IN_MS; +const getISOStringWithoutSeconds = (time: string): string => + moment.duration(time).subtract(getDurationMS(time)).toISOString(); + +// The API allows for setting schedule values with seconds. The UI feature does not allow for setting +// values with seconds. This function strips the seconds from the schedule values for equality checks +// to determine if the user has unsaved changes. +export const stripScheduleSeconds = (schedule: IndexingSchedule): IndexingSchedule => { + const _schedule = cloneDeep(schedule); + const { full, incremental, delete: _delete, permissions } = _schedule; + _schedule.full = getISOStringWithoutSeconds(full); + _schedule.incremental = getISOStringWithoutSeconds(incremental); + _schedule.delete = getISOStringWithoutSeconds(_delete); + if (permissions) _schedule.permissions = getISOStringWithoutSeconds(permissions); + + return _schedule; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts index 4e46100b591b..1f76d667949f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -739,3 +739,7 @@ export const NEXT_SYNC_RUNNING_MESSAGE = i18n.translate( defaultMessage: 'as soon as the currently running job finishes', } ); + +export const UTC_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.utcTitle', { + defaultMessage: 'All times are in UTC', +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index 28a5d621b117..69a6470b5b9c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -77,6 +77,20 @@ const sourceSettingsSchema = schema.object({ ), }) ), + schedule: schema.maybe( + schema.object({ + blocked_windows: schema.maybe( + schema.arrayOf( + schema.object({ + job_type: schema.string(), + day: schema.string(), + start: schema.string(), + end: schema.string(), + }) + ) + ), + }) + ), }) ), }),