diff --git a/src/platform/packages/private/kbn-reporting/common/constants.ts b/src/platform/packages/private/kbn-reporting/common/constants.ts index d4bf17798bf0d..9803499f777ed 100644 --- a/src/platform/packages/private/kbn-reporting/common/constants.ts +++ b/src/platform/packages/private/kbn-reporting/common/constants.ts @@ -13,6 +13,7 @@ import { LENS_APP_LOCATOR, VISUALIZE_APP_LOCATOR, } from '@kbn/deeplinks-analytics'; +import { LicenseType } from '@kbn/licensing-plugin/common/types'; export const ALLOWED_JOB_CONTENT_TYPES = [ 'application/json', @@ -40,6 +41,13 @@ export const LICENSE_TYPE_CLOUD_STANDARD = 'standard' as const; export const LICENSE_TYPE_GOLD = 'gold' as const; export const LICENSE_TYPE_PLATINUM = 'platinum' as const; export const LICENSE_TYPE_ENTERPRISE = 'enterprise' as const; +export const SCHEDULED_REPORT_VALID_LICENSES: LicenseType[] = [ + LICENSE_TYPE_TRIAL, + LICENSE_TYPE_CLOUD_STANDARD, + LICENSE_TYPE_GOLD, + LICENSE_TYPE_PLATINUM, + LICENSE_TYPE_ENTERPRISE, +]; /* * Notifications diff --git a/src/platform/packages/private/kbn-reporting/common/tsconfig.json b/src/platform/packages/private/kbn-reporting/common/tsconfig.json index 5d9636f8d165d..5ba99841a16e0 100644 --- a/src/platform/packages/private/kbn-reporting/common/tsconfig.json +++ b/src/platform/packages/private/kbn-reporting/common/tsconfig.json @@ -21,5 +21,6 @@ "@kbn/i18n", "@kbn/task-manager-plugin", "@kbn/deeplinks-analytics", + "@kbn/licensing-plugin", ] } diff --git a/src/platform/packages/private/kbn-reporting/common/types.ts b/src/platform/packages/private/kbn-reporting/common/types.ts index e24520964d26a..b9778b11b3659 100644 --- a/src/platform/packages/private/kbn-reporting/common/types.ts +++ b/src/platform/packages/private/kbn-reporting/common/types.ts @@ -11,7 +11,7 @@ import type { LayoutParams, PerformanceMetrics as ScreenshotMetrics, } from '@kbn/screenshotting-plugin/common'; -import type { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; +import type { ConcreteTaskInstance, RruleSchedule } from '@kbn/task-manager-plugin/server'; import { JOB_STATUS } from './constants'; import type { LocatorParams } from './url'; @@ -211,3 +211,24 @@ export interface LicenseCheckResults { showLinks: boolean; message: string; } + +export interface ScheduledReportApiJSON { + id: string; + created_at: string; + created_by: string; + enabled: boolean; + jobtype: string; + last_run: string | undefined; + next_run: string | undefined; + notification?: { + email?: { + to?: string[]; + cc?: string[]; + bcc?: string[]; + }; + }; + payload?: ReportApiJSON['payload']; + schedule: RruleSchedule; + space_id: string; + title: string; +} diff --git a/src/platform/packages/private/kbn-reporting/public/index.ts b/src/platform/packages/private/kbn-reporting/public/index.ts index 4d9fb13d89483..aa7212aa831e6 100644 --- a/src/platform/packages/private/kbn-reporting/public/index.ts +++ b/src/platform/packages/private/kbn-reporting/public/index.ts @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; + export type { ClientConfigType } from './types'; export { Job } from './job'; export * from './job_completion_notifications'; @@ -15,7 +17,7 @@ export { useCheckIlmPolicyStatus } from './hooks'; export { ReportingAPIClient } from './reporting_api_client'; export { checkLicense } from './license_check'; -import type { CoreSetup, CoreStart } from '@kbn/core/public'; +import type { CoreSetup, CoreStart, NotificationsStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { useKibana as _useKibana } from '@kbn/kibana-react-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; @@ -26,10 +28,13 @@ import type { SharePluginStart } from '@kbn/share-plugin/public'; export interface KibanaContext { http: CoreSetup['http']; application: CoreStart['application']; + settings: CoreStart['settings']; uiSettings: CoreStart['uiSettings']; docLinks: CoreStart['docLinks']; data: DataPublicPluginStart; share: SharePluginStart; + actions: ActionsPublicPluginSetup; + notifications: NotificationsStart; } export const useKibana = () => _useKibana(); diff --git a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx index bd4c0e8dcbb7c..c1ce996c5df2f 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx +++ b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx @@ -15,10 +15,42 @@ import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { FormattedMessage, InjectedIntl } from '@kbn/i18n-react'; import { ShareContext, type ExportShare } from '@kbn/share-plugin/public'; import { LocatorParams } from '@kbn/reporting-common/types'; +import { ReportParamsGetter, ReportParamsGetterOptions } from '../../types'; import { getSearchCsvJobParams, CsvSearchModeParams } from '../shared/get_search_csv_job_params'; import type { ExportModalShareOpts } from '.'; import { checkLicense } from '../..'; +export const getCsvReportParams: ReportParamsGetter< + ReportParamsGetterOptions & { forShareUrl?: boolean }, + CsvSearchModeParams +> = ({ sharingData, forShareUrl = false }) => { + const getSearchSource = sharingData.getSearchSource as ({ + addGlobalTimeFilter, + absoluteTime, + }: { + addGlobalTimeFilter?: boolean; + absoluteTime?: boolean; + }) => SerializedSearchSourceFields; + + if (sharingData.isTextBased) { + // csv v2 uses locator params + return { + isEsqlMode: true, + locatorParams: sharingData.locatorParams as LocatorParams[], + }; + } + + // csv v1 uses search source and columns + return { + isEsqlMode: false, + columns: sharingData.columns as string[] | undefined, + searchSource: getSearchSource({ + addGlobalTimeFilter: true, + absoluteTime: !forShareUrl, + }), + }; +}; + export const reportingCsvExportProvider = ({ apiClient, startServices$, @@ -27,33 +59,8 @@ export const reportingCsvExportProvider = ({ objectType, sharingData, }: ShareContext): ReturnType => { - const getSearchSource = sharingData.getSearchSource as ({ - addGlobalTimeFilter, - absoluteTime, - }: { - addGlobalTimeFilter?: boolean; - absoluteTime?: boolean; - }) => SerializedSearchSourceFields; - - const getSearchModeParams = (forShareUrl?: boolean): CsvSearchModeParams => { - if (sharingData.isTextBased) { - // csv v2 uses locator params - return { - isEsqlMode: true, - locatorParams: sharingData.locatorParams as LocatorParams[], - }; - } - - // csv v1 uses search source and columns - return { - isEsqlMode: false, - columns: sharingData.columns as string[] | undefined, - searchSource: getSearchSource({ - addGlobalTimeFilter: true, - absoluteTime: !forShareUrl, - }), - }; - }; + const getSearchModeParams = (forShareUrl?: boolean): CsvSearchModeParams => + getCsvReportParams({ sharingData, forShareUrl }); const generateReportingJobCSV = ({ intl }: { intl: InjectedIntl }) => { const { reportType, decoratedJobParams } = getSearchCsvJobParams({ @@ -132,7 +139,7 @@ export const reportingCsvExportProvider = ({ name: panelTitle, exportType: reportType, label: 'CSV', - icon: 'documents', + icon: 'tableDensityNormal', generateAssetExport: generateReportingJobCSV, helpText: ( () => { - const { - objectType, - sharingData: { title, locatorParams }, - optimizedForPrinting, - } = opts; - +const getBaseParams = (objectType: string) => { const el = document.querySelector('[data-shared-items-container]'); const { height, width } = el ? el.getBoundingClientRect() : { height: 768, width: 1024 }; const dimensions = { height, width }; - const layoutId = optimizedForPrinting ? ('print' as const) : ('preserve_layout' as const); - const layout = { id: layoutId, dimensions }; - const baseParams = { objectType, layout, title }; + return { + objectType, + layout: { + id: 'preserve_layout' as 'preserve_layout' | 'print', + dimensions, + }, + }; +}; + +interface PngPdfReportBaseParams { + layout: { dimensions: { height: number; width: number }; id: 'preserve_layout' | 'print' }; + objectType: string; + locatorParams: any; +} + +export const getPngReportParams: ReportParamsGetter< + ReportParamsGetterOptions, + PngPdfReportBaseParams +> = ({ sharingData }): PngPdfReportBaseParams => { + return { + ...getBaseParams('pngV2'), + locatorParams: sharingData.locatorParams, + }; +}; - if (type === 'printablePdfV2') { - // multi locator for PDF V2 - return { ...baseParams, locatorParams: [locatorParams] }; +export const getPdfReportParams: ReportParamsGetter< + ReportParamsGetterOptions & { optimizedForPrinting?: boolean }, + PngPdfReportBaseParams +> = ({ sharingData, optimizedForPrinting = false }) => { + const params = { + ...getBaseParams('printablePdfV2'), + locatorParams: [sharingData.locatorParams], + }; + if (optimizedForPrinting) { + params.layout.id = 'print'; } - // single locator for PNG V2 - return { ...baseParams, locatorParams }; + return params; +}; + +const getJobParams = (opts: JobParamsProviderOptions, type: 'pngV2' | 'printablePdfV2') => () => { + const { objectType, sharingData, optimizedForPrinting } = opts; + let baseParams: PngPdfReportBaseParams; + if (type === 'pngV2') { + baseParams = getPngReportParams({ sharingData }); + } else { + baseParams = getPdfReportParams({ + sharingData, + optimizedForPrinting, + }); + } + return { + ...baseParams, + objectType, + title: sharingData.title, + }; }; export const reportingPDFExportProvider = ({ diff --git a/src/platform/packages/private/kbn-reporting/public/tsconfig.json b/src/platform/packages/private/kbn-reporting/public/tsconfig.json index 0f1d7d545cf34..2f9153920e31f 100644 --- a/src/platform/packages/private/kbn-reporting/public/tsconfig.json +++ b/src/platform/packages/private/kbn-reporting/public/tsconfig.json @@ -27,5 +27,6 @@ "@kbn/home-plugin", "@kbn/management-plugin", "@kbn/ui-actions-plugin", + "@kbn/actions-plugin", ] } diff --git a/src/platform/packages/private/kbn-reporting/public/types.ts b/src/platform/packages/private/kbn-reporting/public/types.ts index 756c5e23eb57b..1e90e3c677a42 100644 --- a/src/platform/packages/private/kbn-reporting/public/types.ts +++ b/src/platform/packages/private/kbn-reporting/public/types.ts @@ -43,3 +43,13 @@ export interface ClientConfigType { }; statefulSettings: { enabled: boolean }; } + +export interface ReportParamsGetterOptions { + objectType?: string; + sharingData: any; +} + +export type ReportParamsGetter< + O extends ReportParamsGetterOptions = ReportParamsGetterOptions, + T = unknown +> = (options: O) => T; diff --git a/src/platform/packages/private/kbn-reporting/server/check_license.ts b/src/platform/packages/private/kbn-reporting/server/check_license.ts index 6a079b4ee61dd..afcf87bbd03db 100644 --- a/src/platform/packages/private/kbn-reporting/server/check_license.ts +++ b/src/platform/packages/private/kbn-reporting/server/check_license.ts @@ -7,14 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ILicense, LicenseType } from '@kbn/licensing-plugin/server'; -import { - LICENSE_TYPE_CLOUD_STANDARD, - LICENSE_TYPE_ENTERPRISE, - LICENSE_TYPE_GOLD, - LICENSE_TYPE_PLATINUM, - LICENSE_TYPE_TRIAL, -} from '@kbn/reporting-common'; +import { ILicense } from '@kbn/licensing-plugin/server'; +import { SCHEDULED_REPORT_VALID_LICENSES } from '@kbn/reporting-common'; import type { ExportType } from '.'; import { ExportTypesRegistry } from './export_types_registry'; @@ -25,14 +19,6 @@ export interface LicenseCheckResult { jobTypes?: string[]; } -const scheduledReportValidLicenses: LicenseType[] = [ - LICENSE_TYPE_TRIAL, - LICENSE_TYPE_CLOUD_STANDARD, - LICENSE_TYPE_GOLD, - LICENSE_TYPE_PLATINUM, - LICENSE_TYPE_ENTERPRISE, -]; - const messages = { getUnavailable: () => { return 'You cannot use Reporting because license information is not available at this time.'; @@ -95,7 +81,7 @@ const makeScheduledReportsFeature = () => { }; } - if (!scheduledReportValidLicenses.includes(license.type)) { + if (!SCHEDULED_REPORT_VALID_LICENSES.includes(license.type)) { return { showLinks: false, enableLinks: false, diff --git a/src/platform/packages/shared/response-ops/recurring-schedule-form/components/custom_recurring_schedule.tsx b/src/platform/packages/shared/response-ops/recurring-schedule-form/components/custom_recurring_schedule.tsx index fd5ba53ae7f7c..337c56c667e7f 100644 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/components/custom_recurring_schedule.tsx +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/components/custom_recurring_schedule.tsx @@ -42,134 +42,155 @@ const styles = { }; export interface CustomRecurringScheduleProps { - startDate: string; + startDate?: string; + readOnly?: boolean; + compressed?: boolean; + minFrequency?: Frequency; } -export const CustomRecurringSchedule = memo(({ startDate }: CustomRecurringScheduleProps) => { - const [{ recurringSchedule }] = useFormData<{ recurringSchedule: RecurringSchedule }>({ - watch: [ - 'recurringSchedule.frequency', - 'recurringSchedule.interval', - 'recurringSchedule.customFrequency', - ], - }); +export const CustomRecurringSchedule = memo( + ({ + startDate, + readOnly = false, + compressed = false, + minFrequency = Frequency.YEARLY, + }: CustomRecurringScheduleProps) => { + const [{ recurringSchedule }] = useFormData<{ recurringSchedule: RecurringSchedule }>({ + watch: [ + 'recurringSchedule.frequency', + 'recurringSchedule.interval', + 'recurringSchedule.customFrequency', + ], + }); - const parsedSchedule = useMemo(() => { - return parseSchedule(recurringSchedule); - }, [recurringSchedule]); + const parsedSchedule = useMemo(() => { + return parseSchedule(recurringSchedule); + }, [recurringSchedule]); - const frequencyOptions = useMemo( - () => RECURRING_SCHEDULE_FORM_CUSTOM_FREQUENCY(parsedSchedule?.interval), - [parsedSchedule?.interval] - ); + const frequencyOptions = useMemo(() => { + const options = RECURRING_SCHEDULE_FORM_CUSTOM_FREQUENCY(parsedSchedule?.interval); + if (minFrequency != null) { + return options.filter(({ value }) => Number(value) >= minFrequency); + } + return options; + }, [minFrequency, parsedSchedule?.interval]); - const bymonthOptions = useMemo(() => { - if (!startDate) return []; - const date = moment(startDate); - const { dayOfWeek, nthWeekdayOfMonth, isLastOfMonth } = getWeekdayInfo(date, 'ddd'); - return [ - { - id: 'day', - label: RECURRING_SCHEDULE_FORM_CUSTOM_REPEAT_MONTHLY_ON_DAY(date), - }, - { - id: 'weekday', - label: - RECURRING_SCHEDULE_FORM_WEEKDAY_SHORT(dayOfWeek)[isLastOfMonth ? 0 : nthWeekdayOfMonth], - }, - ]; - }, [startDate]); + const bymonthOptions = useMemo(() => { + if (!startDate) return []; + const date = moment(startDate); + const { dayOfWeek, nthWeekdayOfMonth, isLastOfMonth } = getWeekdayInfo(date, 'ddd'); + return [ + { + id: 'day', + label: RECURRING_SCHEDULE_FORM_CUSTOM_REPEAT_MONTHLY_ON_DAY(date), + }, + { + id: 'weekday', + label: + RECURRING_SCHEDULE_FORM_WEEKDAY_SHORT(dayOfWeek)[isLastOfMonth ? 0 : nthWeekdayOfMonth], + }, + ]; + }, [startDate]); - const defaultByWeekday = useMemo(() => getInitialByWeekday([], moment(startDate)), [startDate]); + const defaultByWeekday = useMemo(() => getInitialByWeekday([], moment(startDate)), [startDate]); - return ( - <> - {parsedSchedule?.frequency !== Frequency.DAILY ? ( - <> - - - - - {RECURRING_SCHEDULE_FORM_INTERVAL_EVERY} - - ), + return ( + <> + {parsedSchedule?.frequency !== Frequency.DAILY ? ( + <> + + + + + {RECURRING_SCHEDULE_FORM_INTERVAL_EVERY} + + ), + readOnly, + }, + }} + /> + + + + + + + + ) : null} + {Number(parsedSchedule?.customFrequency) === Frequency.WEEKLY || + parsedSchedule?.frequency === Frequency.DAILY ? ( + { + if ( + Object.values(value as MultiButtonGroupFieldValue).every((v) => v === false) + ) { + return { + message: RECURRING_SCHEDULE_FORM_BYWEEKDAY_REQUIRED, + }; + } }, - }} - /> - - - - - - - - ) : null} - {Number(parsedSchedule?.customFrequency) === Frequency.WEEKLY || - parsedSchedule?.frequency === Frequency.DAILY ? ( - { - if ( - Object.values(value as MultiButtonGroupFieldValue).every((v) => v === false) - ) { - return { - message: RECURRING_SCHEDULE_FORM_BYWEEKDAY_REQUIRED, - }; - } }, + ], + defaultValue: defaultByWeekday, + }} + componentProps={{ + 'data-test-subj': 'byweekday-field', + compressed, + euiFieldProps: { + 'data-test-subj': 'customRecurringScheduleByWeekdayButtonGroup', + legend: 'Repeat on weekday', + options: WEEKDAY_OPTIONS, + isDisabled: readOnly, }, - ], - defaultValue: defaultByWeekday, - }} - componentProps={{ - 'data-test-subj': 'byweekday-field', - euiFieldProps: { - 'data-test-subj': 'customRecurringScheduleByWeekdayButtonGroup', - legend: 'Repeat on weekday', - options: WEEKDAY_OPTIONS, - }, - }} - /> - ) : null} + }} + /> + ) : null} - {Number(parsedSchedule?.customFrequency) === Frequency.MONTHLY ? ( - - ) : null} - - ); -}); + {Number(parsedSchedule?.customFrequency) === Frequency.MONTHLY ? ( + + ) : null} + + ); + } +); CustomRecurringSchedule.displayName = 'CustomRecurringSchedule'; diff --git a/src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_form_fields.tsx b/src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_form_fields.tsx index ed5843ca508cb..b51c337910d9b 100644 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_form_fields.tsx +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_form_fields.tsx @@ -23,6 +23,7 @@ import { EuiFlexItem, EuiFormLabel, EuiHorizontalRule, + EuiSelectOption, EuiSpacer, EuiSplitPanel, } from '@elastic/eui'; @@ -39,30 +40,26 @@ import { parseSchedule } from '../utils/parse_schedule'; import { getPresets } from '../utils/get_presets'; import { getWeekdayInfo } from '../utils/get_weekday_info'; import { RecurringSchedule } from '../types'; -import { - RECURRING_SCHEDULE_FORM_FREQUENCY_DAILY, - RECURRING_SCHEDULE_FORM_FREQUENCY_WEEKLY_ON, - RECURRING_SCHEDULE_FORM_FREQUENCY_NTH_WEEKDAY, - RECURRING_SCHEDULE_FORM_FREQUENCY_YEARLY_ON, - RECURRING_SCHEDULE_FORM_FREQUENCY_CUSTOM, - RECURRING_SCHEDULE_FORM_TIMEZONE, - RECURRING_SCHEDULE_FORM_COUNT_AFTER, - RECURRING_SCHEDULE_FORM_COUNT_OCCURRENCE, - RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY_PREFIX, -} from '../translations'; +import * as i18n from '../translations'; /** * Using EuiForm in `div` mode since this is meant to be integrated in a larger form */ const UseField = getUseField({ component: Field }); -export const toMoment = (value: string): Moment => moment(value); -export const toString = (value: Moment): string => value.toISOString(); +export const toMoment = (value?: string): Moment | undefined => (value ? moment(value) : undefined); +export const toString = (value?: Moment): string => value?.toISOString() ?? ''; export interface RecurringScheduleFieldsProps { - startDate: string; + startDate?: string; endDate?: string; timezone?: string[]; + hideTimezone?: boolean; + supportsEndOptions?: boolean; allowInfiniteRecurrence?: boolean; + minFrequency?: Frequency; + showTimeInSummary?: boolean; + readOnly?: boolean; + compressed?: boolean; } /** @@ -73,7 +70,13 @@ export const RecurringScheduleFormFields = memo( startDate, endDate, timezone, + minFrequency = Frequency.YEARLY, + hideTimezone = false, + supportsEndOptions = true, allowInfiniteRecurrence = true, + showTimeInSummary = false, + readOnly = false, + compressed = false, }: RecurringScheduleFieldsProps) => { const [formData] = useFormData<{ recurringSchedule: RecurringSchedule }>({ watch: [ @@ -91,44 +94,53 @@ export const RecurringScheduleFormFields = memo( const [today] = useState(moment()); const { options, presets } = useMemo(() => { - if (!startDate) { - return { options: DEFAULT_FREQUENCY_OPTIONS, presets: DEFAULT_PRESETS }; - } - const date = moment(startDate); - const { dayOfWeek, nthWeekdayOfMonth, isLastOfMonth } = getWeekdayInfo(date); - return { - options: [ + let _options: Array = + DEFAULT_FREQUENCY_OPTIONS; + let _presets: Record> = DEFAULT_PRESETS; + if (startDate != null) { + const date = moment(startDate); + const { dayOfWeek, nthWeekdayOfMonth, isLastOfMonth } = getWeekdayInfo(date); + _options = [ { - text: RECURRING_SCHEDULE_FORM_FREQUENCY_DAILY, + text: i18n.RECURRING_SCHEDULE_FORM_FREQUENCY_DAILY, value: Frequency.DAILY, 'data-test-subj': 'recurringScheduleOptionDaily', }, { - text: RECURRING_SCHEDULE_FORM_FREQUENCY_WEEKLY_ON(dayOfWeek), + text: i18n.RECURRING_SCHEDULE_FORM_FREQUENCY_WEEKLY_ON(dayOfWeek), value: Frequency.WEEKLY, 'data-test-subj': 'recurringScheduleOptionWeekly', }, { - text: RECURRING_SCHEDULE_FORM_FREQUENCY_NTH_WEEKDAY(dayOfWeek)[ + text: i18n.RECURRING_SCHEDULE_FORM_FREQUENCY_NTH_WEEKDAY(dayOfWeek)[ isLastOfMonth ? 0 : nthWeekdayOfMonth ], value: Frequency.MONTHLY, 'data-test-subj': 'recurringScheduleOptionMonthly', }, { - text: RECURRING_SCHEDULE_FORM_FREQUENCY_YEARLY_ON(date), + text: i18n.RECURRING_SCHEDULE_FORM_FREQUENCY_YEARLY_ON(date), value: Frequency.YEARLY, 'data-test-subj': 'recurringScheduleOptionYearly', }, { - text: RECURRING_SCHEDULE_FORM_FREQUENCY_CUSTOM, + text: i18n.RECURRING_SCHEDULE_FORM_FREQUENCY_CUSTOM, value: 'CUSTOM', 'data-test-subj': 'recurringScheduleOptionCustom', }, - ], - presets: getPresets(date), + ]; + _presets = getPresets(date); + } + if (minFrequency != null) { + _options = _options.filter( + (frequency) => typeof frequency.value !== 'number' || frequency.value >= minFrequency + ); + } + return { + options: _options, + presets: _presets, }; - }, [startDate]); + }, [minFrequency, startDate]); const parsedSchedule = useMemo(() => parseSchedule(formData.recurringSchedule), [formData]); @@ -140,102 +152,139 @@ export const RecurringScheduleFormFields = memo( componentProps={{ 'data-test-subj': 'frequency-field', euiFieldProps: { + compressed, 'data-test-subj': 'recurringScheduleRepeatSelect', options, + disabled: readOnly, }, }} /> {(parsedSchedule?.frequency === Frequency.DAILY || parsedSchedule?.frequency === 'CUSTOM') && ( - + )} - - {parsedSchedule?.ends === RecurrenceEnd.ON_DATE ? ( + + {supportsEndOptions && ( <> - - - - - - {timezone ? ( - - - {RECURRING_SCHEDULE_FORM_TIMEZONE} + + {parsedSchedule?.ends === RecurrenceEnd.ON_DATE ? ( + <> + + + + { + if (!value) { + return { + message: i18n.RECURRING_SCHEDULE_FORM_UNTIL_REQUIRED_MESSAGE, + }; + } + }, + }, + ], + serializer: toString, + deserializer: toMoment, + }} + componentProps={{ + 'data-test-subj': 'until-field', + compressed, + euiFieldProps: { + showTimeSelect: false, + minDate: today, + readOnly, + placeholder: i18n.RECURRING_SCHEDULE_FORM_UNTIL_PLACEHOLDER, + }, + }} + /> + + {timezone && !hideTimezone ? ( + + + {i18n.RECURRING_SCHEDULE_FORM_TIMEZONE} + + } + compressed={compressed} + /> + + ) : null} + + + ) : null} + {parsedSchedule?.ends === RecurrenceEnd.AFTER_X ? ( + + {i18n.RECURRING_SCHEDULE_FORM_COUNT_AFTER} + + ), + append: ( + + {i18n.RECURRING_SCHEDULE_FORM_COUNT_OCCURRENCE} - } - /> - - ) : null} - + ), + readOnly, + }, + }} + /> + ) : null} - ) : null} - {parsedSchedule?.ends === RecurrenceEnd.AFTER_X ? ( - - {RECURRING_SCHEDULE_FORM_COUNT_AFTER} - - ), - append: ( - - {RECURRING_SCHEDULE_FORM_COUNT_OCCURRENCE} - - ), - }, - }} - /> - ) : null} + )} - {RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY_PREFIX( - recurringSummary(moment(startDate), parsedSchedule, presets) + {i18n.RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY_PREFIX( + recurringSummary({ + startDate: startDate ? moment(startDate) : undefined, + recurringSchedule: parsedSchedule, + presets, + showTime: showTimeInSummary, + }) )} diff --git a/src/platform/packages/shared/response-ops/recurring-schedule-form/constants.ts b/src/platform/packages/shared/response-ops/recurring-schedule-form/constants.ts index e9cdc300a048c..c016ed3e9953b 100644 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/constants.ts +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/constants.ts @@ -100,6 +100,16 @@ export const ISO_WEEKDAYS_TO_RRULE: Record = { 7: 'SU', }; +export const RRULE_TO_ISO_WEEKDAYS: Record = { + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, + SU: 7, +}; + export const WEEKDAY_OPTIONS = ISO_WEEKDAYS.map((n) => ({ id: String(n), label: moment().isoWeekday(n).format('ddd'), diff --git a/src/platform/packages/shared/response-ops/recurring-schedule-form/translations.ts b/src/platform/packages/shared/response-ops/recurring-schedule-form/translations.ts index 2cd42ef75d507..8a560239dd8b7 100644 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/translations.ts +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/translations.ts @@ -105,6 +105,20 @@ export const RECURRING_SCHEDULE_FORM_ENDS = i18n.translate( } ); +export const RECURRING_SCHEDULE_FORM_UNTIL_REQUIRED_MESSAGE = i18n.translate( + 'responseOpsRecurringScheduleForm.untilRequiredMessage', + { + defaultMessage: 'End date required', + } +); + +export const RECURRING_SCHEDULE_FORM_UNTIL_PLACEHOLDER = i18n.translate( + 'responseOpsRecurringScheduleForm.untilPlaceholder', + { + defaultMessage: 'Select an end date', + } +); + export const RECURRING_SCHEDULE_FORM_ENDS_NEVER = i18n.translate( 'responseOpsRecurringScheduleForm.ends.never', { @@ -261,14 +275,16 @@ export const RECURRING_SCHEDULE_FORM_OCURRENCES_SUMMARY = (count: number) => export const RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY = ( frequencySummary: string | null, onSummary: string | null, - untilSummary: string | null + untilSummary: string | null, + time: string | null ) => i18n.translate('responseOpsRecurringScheduleForm.recurrenceSummary', { - defaultMessage: 'every {frequencySummary}{on}{until}', + defaultMessage: 'every {frequencySummary}{on}{until}{time}', values: { frequencySummary: frequencySummary ? `${frequencySummary} ` : '', on: onSummary ? `${onSummary} ` : '', until: untilSummary ? `${untilSummary}` : '', + time: time ? `${time}` : '', }, }); @@ -293,3 +309,9 @@ export const RECURRING_SCHEDULE_FORM_YEARLY_BY_MONTH_SUMMARY = (date: string) => defaultMessage: 'on {date}', values: { date }, }); + +export const RECURRING_SCHEDULE_FORM_TIME_SUMMARY = (time: string) => + i18n.translate('responseOpsRecurringScheduleForm.timeSummary', { + defaultMessage: 'at {time}', + values: { time }, + }); diff --git a/src/platform/packages/shared/response-ops/recurring-schedule-form/types.ts b/src/platform/packages/shared/response-ops/recurring-schedule-form/types.ts index 29d2abdf19f7f..e9f2d0c00e3a6 100644 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/types.ts +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/types.ts @@ -23,6 +23,10 @@ export interface RecurringSchedule { customFrequency?: RecurrenceFrequency; byweekday?: Record; bymonth?: string; + bymonthweekday?: string; + bymonthday?: number; + byhour?: number; + byminute?: number; } export type RRuleParams = Partial & Pick; diff --git a/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/convert_to_rrule.test.ts b/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/convert_to_rrule.test.ts index f162156a4d34f..db1f76a4c9a77 100644 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/convert_to_rrule.test.ts +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/convert_to_rrule.test.ts @@ -17,7 +17,7 @@ describe('convertToRRule', () => { const startDate = moment(today); test('should convert a maintenance window that is not recurring', () => { - const rRule = convertToRRule(startDate, timezone, undefined); + const rRule = convertToRRule({ startDate, timezone }); expect(rRule).toEqual({ dtstart: startDate.toISOString(), @@ -28,10 +28,14 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a daily schedule', () => { - const rRule = convertToRRule(startDate, timezone, { - byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, - ends: 'never', - frequency: Frequency.DAILY, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, + ends: 'never', + frequency: Frequency.DAILY, + }, }); expect(rRule).toEqual({ @@ -45,11 +49,15 @@ describe('convertToRRule', () => { test('should convert a maintenance window that is recurring on a daily schedule until', () => { const until = moment(today).add(1, 'month').toISOString(); - const rRule = convertToRRule(startDate, timezone, { - byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, - ends: 'until', - until, - frequency: Frequency.DAILY, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, + ends: 'until', + until, + frequency: Frequency.DAILY, + }, }); expect(rRule).toEqual({ @@ -63,11 +71,15 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a daily schedule after x', () => { - const rRule = convertToRRule(startDate, timezone, { - byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, - ends: 'afterx', - count: 3, - frequency: Frequency.DAILY, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, + ends: 'afterx', + count: 3, + frequency: Frequency.DAILY, + }, }); expect(rRule).toEqual({ @@ -81,9 +93,13 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a weekly schedule', () => { - const rRule = convertToRRule(startDate, timezone, { - ends: 'never', - frequency: Frequency.WEEKLY, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + ends: 'never', + frequency: Frequency.WEEKLY, + }, }); expect(rRule).toEqual({ @@ -96,9 +112,13 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a monthly schedule', () => { - const rRule = convertToRRule(startDate, timezone, { - ends: 'never', - frequency: Frequency.MONTHLY, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + ends: 'never', + frequency: Frequency.MONTHLY, + }, }); expect(rRule).toEqual({ @@ -111,9 +131,13 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a yearly schedule', () => { - const rRule = convertToRRule(startDate, timezone, { - ends: 'never', - frequency: Frequency.YEARLY, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + ends: 'never', + frequency: Frequency.YEARLY, + }, }); expect(rRule).toEqual({ @@ -127,11 +151,15 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a custom daily schedule', () => { - const rRule = convertToRRule(startDate, timezone, { - customFrequency: Frequency.DAILY, - ends: 'never', - frequency: 'CUSTOM', - interval: 1, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + customFrequency: Frequency.DAILY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }, }); expect(rRule).toEqual({ @@ -143,12 +171,16 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a custom weekly schedule', () => { - const rRule = convertToRRule(startDate, timezone, { - byweekday: { 1: false, 2: false, 3: true, 4: true, 5: false, 6: false, 7: false }, - customFrequency: Frequency.WEEKLY, - ends: 'never', - frequency: 'CUSTOM', - interval: 1, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + byweekday: { 1: false, 2: false, 3: true, 4: true, 5: false, 6: false, 7: false }, + customFrequency: Frequency.WEEKLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }, }); expect(rRule).toEqual({ @@ -161,12 +193,16 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a custom monthly by day schedule', () => { - const rRule = convertToRRule(startDate, timezone, { - bymonth: 'day', - customFrequency: Frequency.MONTHLY, - ends: 'never', - frequency: 'CUSTOM', - interval: 1, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + bymonth: 'day', + customFrequency: Frequency.MONTHLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }, }); expect(rRule).toEqual({ @@ -179,12 +215,16 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a custom monthly by weekday schedule', () => { - const rRule = convertToRRule(startDate, timezone, { - bymonth: 'weekday', - customFrequency: Frequency.MONTHLY, - ends: 'never', - frequency: 'CUSTOM', - interval: 1, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + bymonth: 'weekday', + customFrequency: Frequency.MONTHLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 1, + }, }); expect(rRule).toEqual({ @@ -197,11 +237,15 @@ describe('convertToRRule', () => { }); test('should convert a maintenance window that is recurring on a custom yearly schedule', () => { - const rRule = convertToRRule(startDate, timezone, { - customFrequency: Frequency.YEARLY, - ends: 'never', - frequency: 'CUSTOM', - interval: 3, + const rRule = convertToRRule({ + startDate, + timezone, + recurringSchedule: { + customFrequency: Frequency.YEARLY, + ends: 'never', + frequency: 'CUSTOM', + interval: 3, + }, }); expect(rRule).toEqual({ diff --git a/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/convert_to_rrule.ts b/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/convert_to_rrule.ts index ba6cf234ad0b2..147d248d532ab 100644 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/convert_to_rrule.ts +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/convert_to_rrule.ts @@ -15,11 +15,17 @@ import { parseSchedule } from './parse_schedule'; import { getNthByWeekday } from './get_nth_by_weekday'; import type { RRuleParams, RecurringSchedule } from '../types'; -export const convertToRRule = ( - startDate: Moment, - timezone: string, - recurringSchedule?: RecurringSchedule -): RRuleParams => { +export const convertToRRule = ({ + startDate, + timezone, + recurringSchedule, + includeTime = false, +}: { + startDate: Moment; + timezone: string; + recurringSchedule?: RecurringSchedule; + includeTime?: boolean; +}): RRuleParams => { const presets = getPresets(startDate); const parsedSchedule = parseSchedule(recurringSchedule); @@ -27,6 +33,9 @@ export const convertToRRule = ( const rRule: RRuleParams = { dtstart: startDate.toISOString(), tzid: timezone, + ...(Boolean(includeTime) + ? { byhour: [startDate.get('hour')], byminute: [startDate.get('minute')] } + : {}), }; if (!parsedSchedule) diff --git a/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/recurring_summary.test.ts b/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/recurring_summary.test.ts index e989673315bf1..97f1e6f7c788f 100644 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/recurring_summary.test.ts +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/recurring_summary.test.ts @@ -19,169 +19,169 @@ describe('convertToRRule', () => { const presets = getPresets(startDate); test('should return an empty string if the form is undefined', () => { - const summary = recurringSummary(startDate, undefined, presets); + const summary = recurringSummary({ startDate, presets }); expect(summary).toEqual(''); }); test('should return the summary for maintenance window that is recurring on a daily schedule', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, ends: 'never', frequency: Frequency.DAILY, }, - presets - ); + presets, + }); expect(summary).toEqual('every Wednesday'); }); test('should return the summary for maintenance window that is recurring on a daily schedule until', () => { const until = moment(today).add(1, 'month').toISOString(); - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, ends: 'until', until, frequency: Frequency.DAILY, }, - presets - ); + presets, + }); expect(summary).toEqual('every Wednesday until April 22, 2023'); }); test('should return the summary for maintenance window that is recurring on a daily schedule after x', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false }, ends: 'afterx', count: 3, frequency: Frequency.DAILY, }, - presets - ); + presets, + }); expect(summary).toEqual('every Wednesday for 3 occurrences'); }); test('should return the summary for maintenance window that is recurring on a weekly schedule', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { ends: 'never', frequency: Frequency.WEEKLY, }, - presets - ); + presets, + }); expect(summary).toEqual('every week on Wednesday'); }); test('should return the summary for maintenance window that is recurring on a monthly schedule', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { ends: 'never', frequency: Frequency.MONTHLY, }, - presets - ); + presets, + }); expect(summary).toEqual('every month on the 4th Wednesday'); }); test('should return the summary for maintenance window that is recurring on a yearly schedule', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { ends: 'never', frequency: Frequency.YEARLY, }, - presets - ); + presets, + }); expect(summary).toEqual('every year on March 22'); }); test('should return the summary for maintenance window that is recurring on a custom daily schedule', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { customFrequency: Frequency.DAILY, ends: 'never', frequency: 'CUSTOM', interval: 1, }, - presets - ); + presets, + }); expect(summary).toEqual('every day'); }); test('should return the summary for maintenance window that is recurring on a custom weekly schedule', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { byweekday: { 1: false, 2: false, 3: true, 4: true, 5: false, 6: false, 7: false }, customFrequency: Frequency.WEEKLY, ends: 'never', frequency: 'CUSTOM', interval: 1, }, - presets - ); + presets, + }); expect(summary).toEqual('every week on Wednesday, Thursday'); }); test('should return the summary for maintenance window that is recurring on a custom monthly by day schedule', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { bymonth: 'day', customFrequency: Frequency.MONTHLY, ends: 'never', frequency: 'CUSTOM', interval: 1, }, - presets - ); + presets, + }); expect(summary).toEqual('every month on day 22'); }); test('should return the summary for maintenance window that is recurring on a custom monthly by weekday schedule', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { bymonth: 'weekday', customFrequency: Frequency.MONTHLY, ends: 'never', frequency: 'CUSTOM', interval: 1, }, - presets - ); + presets, + }); expect(summary).toEqual('every month on the 4th Wednesday'); }); test('should return the summary for maintenance window that is recurring on a custom yearly schedule', () => { - const summary = recurringSummary( + const summary = recurringSummary({ startDate, - { + recurringSchedule: { customFrequency: Frequency.YEARLY, ends: 'never', frequency: 'CUSTOM', interval: 3, }, - presets - ); + presets, + }); expect(summary).toEqual('every 3 years on March 22'); }); diff --git a/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/recurring_summary.ts b/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/recurring_summary.ts index 311b85cffe0bb..00c36d517e14d 100644 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/recurring_summary.ts +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/recurring_summary.ts @@ -22,14 +22,21 @@ import { RECURRING_SCHEDULE_FORM_UNTIL_DATE_SUMMARY, RECURRING_SCHEDULE_FORM_OCURRENCES_SUMMARY, RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY, + RECURRING_SCHEDULE_FORM_TIME_SUMMARY, } from '../translations'; import type { RecurrenceFrequency, RecurringSchedule } from '../types'; -export const recurringSummary = ( - startDate: Moment, - recurringSchedule: RecurringSchedule | undefined, - presets: Record> -) => { +export const recurringSummary = ({ + startDate, + recurringSchedule, + presets, + showTime = false, +}: { + startDate?: Moment; + recurringSchedule?: RecurringSchedule; + presets: Record>; + showTime?: boolean; +}) => { if (!recurringSchedule) return ''; let schedule = recurringSchedule; @@ -63,19 +70,26 @@ export const recurringSummary = ( const bymonth = schedule.bymonth; if (bymonth) { if (bymonth === 'weekday') { - const nthWeekday = getNthByWeekday(startDate); - const nth = nthWeekday.startsWith('-1') ? 0 : Number(nthWeekday[1]); - monthlySummary = RECURRING_SCHEDULE_FORM_WEEKDAY_SHORT(toWeekdayName(nthWeekday))[nth]; - monthlySummary = monthlySummary[0].toLocaleLowerCase() + monthlySummary.slice(1); + const nthWeekday = startDate ? getNthByWeekday(startDate) : schedule.bymonthweekday; + if (nthWeekday) { + const nth = nthWeekday.startsWith('-1') ? 0 : Number(nthWeekday[1]); + monthlySummary = RECURRING_SCHEDULE_FORM_WEEKDAY_SHORT(toWeekdayName(nthWeekday))[nth]; + monthlySummary = monthlySummary[0].toLocaleLowerCase() + monthlySummary.slice(1); + } } else if (bymonth === 'day') { - monthlySummary = RECURRING_SCHEDULE_FORM_MONTHLY_BY_DAY_SUMMARY(startDate.date()); + const monthDay = startDate?.date() ?? schedule.bymonthday; + if (monthDay) { + monthlySummary = RECURRING_SCHEDULE_FORM_MONTHLY_BY_DAY_SUMMARY(monthDay); + } } } // yearly - const yearlyByMonthSummary = RECURRING_SCHEDULE_FORM_YEARLY_BY_MONTH_SUMMARY( - monthDayDate(moment().month(startDate.month()).date(startDate.date())) - ); + const yearlyByMonthSummary = startDate + ? RECURRING_SCHEDULE_FORM_YEARLY_BY_MONTH_SUMMARY( + monthDayDate(moment().month(startDate.month()).date(startDate.date())) + ) + : null; const onSummary = dailyWithWeekdays ? dailyWeekdaySummary @@ -93,10 +107,23 @@ export const recurringSummary = ( ? RECURRING_SCHEDULE_FORM_OCURRENCES_SUMMARY(schedule.count) : null; + let time: string | null = null; + if (showTime) { + const date = + startDate ?? + (schedule.byhour && schedule.byminute + ? moment().hour(schedule.byhour).minute(schedule.byminute) + : null); + if (date) { + time = RECURRING_SCHEDULE_FORM_TIME_SUMMARY(date.format('HH:mm')); + } + } + const every = RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY( !dailyWithWeekdays ? frequencySummary : null, onSummary, - untilSummary + untilSummary, + time ).trim(); return every; diff --git a/src/platform/plugins/shared/share/public/index.ts b/src/platform/plugins/shared/share/public/index.ts index ef0537f26d205..64a17742f905b 100644 --- a/src/platform/plugins/shared/share/public/index.ts +++ b/src/platform/plugins/shared/share/public/index.ts @@ -41,3 +41,5 @@ export type { DownloadableContent } from './lib/download_as'; export function plugin(ctx: PluginInitializerContext) { return new SharePlugin(ctx); } + +export { useShareTypeContext } from './components/context'; diff --git a/x-pack/platform/plugins/private/reporting/kibana.jsonc b/x-pack/platform/plugins/private/reporting/kibana.jsonc index 1a3b40c96ca46..16cc791bc83ef 100644 --- a/x-pack/platform/plugins/private/reporting/kibana.jsonc +++ b/x-pack/platform/plugins/private/reporting/kibana.jsonc @@ -29,7 +29,8 @@ "taskManager", "screenshotMode", "share", - "features" + "features", + "actions" ], "optionalPlugins": [ "security", diff --git a/x-pack/platform/plugins/private/reporting/public/management/apis/get_reporting_health.ts b/x-pack/platform/plugins/private/reporting/public/management/apis/get_reporting_health.ts new file mode 100644 index 0000000000000..3d2cb94d46f7d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/apis/get_reporting_health.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from '@kbn/core/public'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import { ReportingHealthInfo } from '@kbn/reporting-common/types'; + +export const getReportingHealth = async ({ + http, +}: { + http: HttpSetup; +}): Promise => { + const res = await http.get<{ + is_sufficiently_secure: boolean; + has_permanent_encryption_key: boolean; + are_notifications_enabled: boolean; + }>(INTERNAL_ROUTES.HEALTH); + return { + isSufficientlySecure: res.is_sufficiently_secure, + hasPermanentEncryptionKey: res.has_permanent_encryption_key, + areNotificationsEnabled: res.are_notifications_enabled, + }; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/apis/schedule_report.ts b/x-pack/platform/plugins/private/reporting/public/management/apis/schedule_report.ts new file mode 100644 index 0000000000000..a04f6d5ec5e74 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/apis/schedule_report.ts @@ -0,0 +1,34 @@ +/* + * 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 { HttpSetup } from '@kbn/core-http-browser'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import type { RruleSchedule } from '@kbn/task-manager-plugin/server'; +import type { RawNotification } from '../../../server/saved_objects/scheduled_report/schemas/latest'; +import type { ScheduledReportingJobResponse } from '../../../server/types'; + +export interface ScheduleReportRequestParams { + reportTypeId: string; + jobParams: string; + schedule?: RruleSchedule; + notification?: RawNotification; +} + +export const scheduleReport = ({ + http, + params: { reportTypeId, ...params }, +}: { + http: HttpSetup; + params: ScheduleReportRequestParams; +}) => { + return http.post( + `${INTERNAL_ROUTES.SCHEDULE_PREFIX}/${reportTypeId}`, + { + body: JSON.stringify(params), + } + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/responsive_form_group.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/responsive_form_group.tsx new file mode 100644 index 0000000000000..c4d40901e0049 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/responsive_form_group.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiDescribedFormGroup, type EuiDescribedFormGroupProps } from '@elastic/eui'; +import { css } from '@emotion/react'; + +/** + * A collapsible version of EuiDescribedFormGroup. Use the `narrow` prop + * to obtain a vertical layout suitable for smaller forms + */ +export const ResponsiveFormGroup = ({ + narrow = true, + ...rest +}: EuiDescribedFormGroupProps & { narrow?: boolean }) => { + const props: EuiDescribedFormGroupProps = { + ...rest, + ...(narrow + ? { + fullWidth: true, + css: css` + flex-direction: column; + align-items: stretch; + `, + gutterSize: 's', + } + : {}), + }; + return ; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.tsx new file mode 100644 index 0000000000000..5cf81fcc80887 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { ReportingAPIClient } from '@kbn/reporting-public'; +import { ReportTypeData, ScheduledReport } from '../../types'; +import { ScheduledReportFlyoutContent } from './scheduled_report_flyout_content'; + +export interface ScheduledReportFlyoutProps { + apiClient: ReportingAPIClient; + scheduledReport: Partial; + availableReportTypes: ReportTypeData[]; + onClose: () => void; +} + +export const ScheduledReportFlyout = ({ + apiClient, + scheduledReport, + availableReportTypes, + onClose, +}: ScheduledReportFlyoutProps) => { + return ( + + + + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.test.tsx new file mode 100644 index 0000000000000..8375c8ca3702e --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.test.tsx @@ -0,0 +1,354 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { PropsWithChildren } from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { type ReportingAPIClient, useKibana } from '@kbn/reporting-public'; +import { ReportTypeData, ScheduledReport } from '../../types'; +import { getReportingHealth } from '../apis/get_reporting_health'; +import { coreMock } from '@kbn/core/public/mocks'; +import { testQueryClient } from '../test_utils/test_query_client'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ScheduledReportFlyoutContent } from './scheduled_report_flyout_content'; +import { scheduleReport } from '../apis/schedule_report'; +import { ScheduledReportApiJSON } from '../../../server/types'; +import userEvent from '@testing-library/user-event'; + +// Mock Kibana hooks and context +jest.mock('@kbn/reporting-public', () => ({ + useKibana: jest.fn(), +})); + +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + useUiSetting: () => 'UTC', +})); + +jest.mock( + '@kbn/response-ops-recurring-schedule-form/components/recurring_schedule_form_fields', + () => ({ + RecurringScheduleFormFields: () =>
, + }) +); + +jest.mock('../apis/get_reporting_health'); +const mockGetReportingHealth = jest.mocked(getReportingHealth); +mockGetReportingHealth.mockResolvedValue({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: true, +}); + +jest.mock('../apis/schedule_report'); +const mockScheduleReport = jest.mocked(scheduleReport); +mockScheduleReport.mockResolvedValue({ + job: { + id: '8c5529c0-67ed-41c4-8a1b-9a97bdc11d27', + jobtype: 'printable_pdf_v2', + created_at: '2025-06-17T15:50:52.879Z', + created_by: 'elastic', + meta: { + isDeprecated: false, + layout: 'preserve_layout', + objectType: 'dashboard', + }, + schedule: { + rrule: { + tzid: 'UTC', + byhour: [17], + byminute: [50], + freq: 3, + interval: 1, + byweekday: ['TU'], + }, + }, + } as unknown as ScheduledReportApiJSON, +}); + +const objectType = 'dashboard'; +const sharingData = { + title: 'Title', + reportingDisabled: false, + locatorParams: { + id: 'DASHBOARD_APP_LOCATOR', + params: { + dashboardId: 'f09d5bbe-da16-4975-a04c-ad03c84e586b', + preserveSavedFilters: true, + viewMode: 'view', + useHash: false, + timeRange: { + from: 'now-15m', + to: 'now', + }, + }, + }, +}; +const scheduledReport = { + title: 'Title', + reportTypeId: 'printablePdfV2', +} as ScheduledReport; +const availableFormats: ReportTypeData[] = [ + { + id: 'printablePdfV2', + label: 'PDF', + }, + { + id: 'pngV2', + label: 'PNG', + }, + { + id: 'csv_searchsource', + label: 'CSV', + }, +]; + +const mockApiClient = { + getDecoratedJobParams: jest.fn().mockImplementation((params) => params), +} as unknown as ReportingAPIClient; + +const mockOnClose = jest.fn(); + +const TestProviders = ({ children }: PropsWithChildren) => ( + {children} +); + +const coreServices = coreMock.createStart(); +const mockSuccessToast = jest.fn(); +const mockErrorToast = jest.fn(); +coreServices.notifications.toasts.addSuccess = mockSuccessToast; +coreServices.notifications.toasts.addError = mockErrorToast; +const mockValidateEmailAddresses = jest.fn().mockResolvedValue([]); + +describe('ScheduledReportFlyoutContent', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + ...coreServices, + actions: { + validateEmailAddresses: mockValidateEmailAddresses, + }, + }, + }); + jest.clearAllMocks(); + testQueryClient.clear(); + }); + + it('should not render the flyout footer when the form is in readOnly mode', () => { + render( + + + + ); + + expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); + }); + + it('should show a callout in case of errors while fetching reporting health', async () => { + mockGetReportingHealth.mockRejectedValueOnce({}); + render( + + + + ); + + expect( + await screen.findByText('Reporting health is a prerequisite to create scheduled exports') + ).toBeInTheDocument(); + }); + + it('should show a callout in case of unmet prerequisites in the reporting health', async () => { + mockGetReportingHealth.mockResolvedValueOnce({ + isSufficientlySecure: false, + hasPermanentEncryptionKey: false, + areNotificationsEnabled: false, + }); + render( + + + + ); + + expect(await screen.findByText('Cannot schedule reports')).toBeInTheDocument(); + }); + + it('should render the initial form fields when all the prerequisites are met', async () => { + render( + + + + ); + + expect(await screen.findByText('Report name')).toBeInTheDocument(); + expect(await screen.findByText('File type')).toBeInTheDocument(); + expect(await screen.findByText('Send by email')).toBeInTheDocument(); + }); + + it('should render the To field and sensitive info callout when Send by email is toggled on', async () => { + render( + + + + ); + + const toggle = await screen.findByText('Send by email'); + await userEvent.click(toggle); + + expect(await screen.findByText('To')).toBeInTheDocument(); + expect(await screen.findByText('Sensitive information')).toBeInTheDocument(); + }); + + it('should show a warning callout when the notification email connector is missing', async () => { + mockGetReportingHealth.mockResolvedValueOnce({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: false, + }); + render( + + + + ); + + expect(await screen.findByText("Email connector hasn't been created yet")).toBeInTheDocument(); + }); + + it('should submit the form successfully and call onClose', async () => { + render( + + + + ); + + const submitButton = await screen.findByRole('button', { name: 'Schedule exports' }); + await userEvent.click(submitButton); + + await waitFor(() => expect(mockScheduleReport).toHaveBeenCalled()); + expect(mockSuccessToast).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should show error toast and not call onClose on form submission failure', async () => { + mockScheduleReport.mockRejectedValueOnce(new Error('Failed to schedule report')); + + render( + + + + ); + + const submitButton = await screen.findByRole('button', { name: 'Schedule exports' }); + await userEvent.click(submitButton); + + await waitFor(() => expect(mockErrorToast).toHaveBeenCalled()); + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('should not submit if required fields are empty', async () => { + render( + + + + ); + + const submitButton = await screen.findByRole('button', { name: 'Schedule exports' }); + await userEvent.click(submitButton); + + await waitFor(() => expect(mockScheduleReport).not.toHaveBeenCalled()); + }); + + it('should show validation error on invalid email', async () => { + mockValidateEmailAddresses.mockReturnValue([{ valid: false, reason: 'notAllowed' }]); + + render( + + + + ); + + await userEvent.click(await screen.findByText('Send by email')); + const emailField = await screen.findByTestId('emailRecipientsCombobox'); + const emailInput = within(emailField).getByTestId('comboBoxSearchInput'); + fireEvent.change(emailInput, { target: { value: 'unallowed@email.com' } }); + fireEvent.keyDown(emailInput, { key: 'Enter', code: 'Enter' }); + + const submitButton = await screen.findByRole('button', { name: 'Schedule exports' }); + await userEvent.click(submitButton); + + expect(mockValidateEmailAddresses).toHaveBeenCalled(); + expect(emailInput).not.toBeValid(); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.tsx new file mode 100644 index 0000000000000..08fee818068ba --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.tsx @@ -0,0 +1,379 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import moment from 'moment'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiLink, + EuiLoadingSpinner, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { ReportingAPIClient, useKibana } from '@kbn/reporting-public'; +import type { ReportingSharingData } from '@kbn/reporting-public/share/share_context_menu'; +import { REPORTING_MANAGEMENT_HOME } from '@kbn/reporting-common'; +import { + FIELD_TYPES, + Form, + FormSchema, + getUseField, + useForm, + useFormData, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { convertToRRule } from '@kbn/response-ops-recurring-schedule-form/utils/convert_to_rrule'; +import type { Rrule } from '@kbn/task-manager-plugin/server/task'; +import { mountReactNode } from '@kbn/core-mount-utils-browser-internal'; +import { RecurringScheduleFormFields } from '@kbn/response-ops-recurring-schedule-form/components/recurring_schedule_form_fields'; +import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { Frequency } from '@kbn/rrule'; +import { ResponsiveFormGroup } from './responsive_form_group'; +import { getReportParams } from '../report_params'; +import { getScheduledReportFormSchema } from '../schemas/scheduled_report_form_schema'; +import { useDefaultTimezone } from '../hooks/use_default_timezone'; +import { useScheduleReport } from '../hooks/use_schedule_report'; +import { useGetReportingHealthQuery } from '../hooks/use_get_reporting_health_query'; +import { ReportTypeData, ScheduledReport } from '../../types'; +import * as i18n from '../translations'; +import { SCHEDULED_REPORT_FORM_ID } from '../constants'; + +const FormField = getUseField({ + component: Field, +}); + +export type FormData = Pick< + ScheduledReport, + | 'title' + | 'reportTypeId' + | 'recurringSchedule' + | 'sendByEmail' + | 'emailRecipients' + | 'optimizedForPrinting' +>; + +export interface ScheduledReportFlyoutContentProps { + apiClient: ReportingAPIClient; + objectType?: string; + sharingData?: ReportingSharingData; + scheduledReport: Partial; + availableReportTypes?: ReportTypeData[]; + onClose: () => void; + readOnly?: boolean; +} + +export const ScheduledReportFlyoutContent = ({ + apiClient, + objectType, + sharingData, + scheduledReport, + availableReportTypes, + onClose, + readOnly = false, +}: ScheduledReportFlyoutContentProps) => { + if (!readOnly && (!objectType || !sharingData)) { + throw new Error('Cannot schedule an export without an objectType or sharingData'); + } + const { + http, + actions: { validateEmailAddresses }, + notifications: { toasts }, + } = useKibana().services; + const { + data: reportingHealth, + isLoading: isReportingHealthLoading, + isError: isReportingHealthError, + } = useGetReportingHealthQuery({ http }); + const reportingPageLink = useMemo( + () => ( + + {i18n.REPORTING_PAGE_LINK_TEXT} + + ), + [http.basePath] + ); + const { mutateAsync: scheduleReport, isLoading: isScheduleExportLoading } = useScheduleReport({ + http, + }); + const { defaultTimezone } = useDefaultTimezone(); + const now = useMemo(() => moment().tz(defaultTimezone), [defaultTimezone]); + const defaultStartDateValue = useMemo(() => now.toISOString(), [now]); + const schema = useMemo( + () => + getScheduledReportFormSchema( + validateEmailAddresses, + availableReportTypes + ) as FormSchema, + [availableReportTypes, validateEmailAddresses] + ); + const recurring = true; + const startDate = defaultStartDateValue; + const timezone = defaultTimezone; + const { form } = useForm({ + defaultValue: scheduledReport, + options: { stripEmptyFields: true }, + schema, + onSubmit: async (formData) => { + try { + const { + title, + reportTypeId, + recurringSchedule, + optimizedForPrinting, + sendByEmail, + emailRecipients, + } = formData; + // Remove start date since it's not supported for now + const { dtstart, ...rrule } = convertToRRule({ + startDate: now, + timezone, + recurringSchedule, + includeTime: true, + }); + await scheduleReport({ + reportTypeId, + jobParams: getReportParams({ + apiClient, + // The assertion at the top of the component ensures these are defined when scheduling + sharingData: sharingData!, + objectType: objectType!, + title, + reportTypeId, + ...(reportTypeId === 'printablePdfV2' ? { optimizedForPrinting } : {}), + }), + schedule: { rrule: rrule as Rrule }, + notification: sendByEmail ? { email: { to: emailRecipients } } : undefined, + }); + toasts.addSuccess({ + title: i18n.SCHEDULED_REPORT_FORM_SUCCESS_TOAST_TITLE, + text: mountReactNode( + <> + {i18n.SCHEDULED_REPORT_FORM_SUCCESS_TOAST_MESSAGE} {reportingPageLink}. + + ), + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + toasts.addError(error, { + title: i18n.SCHEDULED_REPORT_FORM_FAILURE_TOAST_TITLE, + toastMessage: i18n.SCHEDULED_REPORT_FORM_FAILURE_TOAST_MESSAGE, + }); + // Forward error to signal whether to close the flyout or not + throw error; + } + }, + }); + const [{ reportTypeId, sendByEmail }] = useFormData({ + form, + watch: ['reportTypeId', 'sendByEmail'], + }); + + const isRecurring = recurring || false; + const isEmailActive = sendByEmail || false; + + const onSubmit = async () => { + try { + if (await form.validate()) { + await form.submit(); + onClose(); + } + } catch (e) { + // Keep the flyout open in case of schedule error + } + }; + + const hasUnmetPrerequisites = + !reportingHealth?.isSufficientlySecure || !reportingHealth?.hasPermanentEncryptionKey; + + return ( + <> + + +

{i18n.SCHEDULED_REPORT_FLYOUT_TITLE}

+
+
+ + {isReportingHealthLoading ? ( + + ) : isReportingHealthError ? ( + +

{i18n.CANNOT_LOAD_REPORTING_HEALTH_MESSAGE}

+
+ ) : hasUnmetPrerequisites ? ( + +

{i18n.UNMET_REPORTING_PREREQUISITES_MESSAGE}

+
+ ) : ( +
+ {i18n.SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE}} + > + + ({ inputDisplay: f.label, value: f.id })) ?? + [], + readOnly, + }, + }} + /> + {reportTypeId === 'printablePdfV2' && ( + + )} + + {i18n.SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE}} + > + {isRecurring && ( + + )} + + {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE}} + description={ +

+ {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION} {reportingPageLink}. +

+ } + > + + {reportingHealth.areNotificationsEnabled ? ( + isEmailActive && ( + <> + + + + +

{i18n.SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_MESSAGE}

+
+
+ + ) + ) : ( + <> + + +

{i18n.SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_MESSAGE}

+
+ + )} +
+
+ )} +
+ {!readOnly && ( + + + + + {i18n.SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL} + + + + + {i18n.SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL} + + + + + )} + + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_share_wrapper.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_share_wrapper.tsx new file mode 100644 index 0000000000000..e981eb85b3136 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_share_wrapper.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useShareTypeContext } from '@kbn/share-plugin/public'; +import React, { useMemo } from 'react'; +import { ReportingAPIClient, useKibana } from '@kbn/reporting-public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { ReportingSharingData } from '@kbn/reporting-public/share/share_context_menu'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { supportedReportTypes } from '../report_params'; +import { queryClient } from '../../query_client'; +import type { ReportingPublicPluginSetupDependencies } from '../../plugin'; +import { ScheduledReportFlyoutContent } from './scheduled_report_flyout_content'; +import { ReportTypeId } from '../../types'; + +export interface ScheduledReportMenuItem { + apiClient: ReportingAPIClient; + services: ReportingPublicPluginSetupDependencies; + sharingData: ReportingSharingData; + onClose: () => void; +} + +export const ScheduledReportFlyoutShareWrapper = ({ + apiClient, + services: reportingServices, + sharingData, + onClose, +}: ScheduledReportMenuItem) => { + const upstreamServices = useKibana().services; + const services = useMemo( + () => ({ + ...reportingServices, + ...upstreamServices, + }), + [reportingServices, upstreamServices] + ); + const { shareMenuItems, objectType } = useShareTypeContext('integration', 'export'); + + const availableReportTypes = useMemo(() => { + return shareMenuItems + .filter((item) => supportedReportTypes.includes(item.config.exportType as ReportTypeId)) + .map((item) => ({ + id: item.config.exportType, + label: item.config.label, + })); + }, [shareMenuItems]); + + const scheduledReport = useMemo( + () => ({ + title: sharingData.title, + }), + [sharingData] + ); + + if (!services) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/constants.ts b/x-pack/platform/plugins/private/reporting/public/management/constants.ts new file mode 100644 index 0000000000000..cfd481dac44d3 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/constants.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const SCHEDULED_REPORT_FORM_ID = 'scheduledReportForm'; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.ts b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.ts new file mode 100644 index 0000000000000..71fec7f635bcb --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; +import moment from 'moment'; + +export const useDefaultTimezone = () => { + const kibanaTz: string = useUiSetting('dateFormat:tz'); + if (!kibanaTz || kibanaTz === 'Browser') { + return { defaultTimezone: moment.tz?.guess() ?? 'UTC', isBrowser: true }; + } + return { defaultTimezone: kibanaTz, isBrowser: false }; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.ts b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.ts new file mode 100644 index 0000000000000..13e2326138b8a --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.ts @@ -0,0 +1,20 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; +import { HttpSetup } from '@kbn/core/public'; +import { getReportingHealth } from '../apis/get_reporting_health'; +import { queryKeys } from '../query_keys'; + +export const getKey = queryKeys.getHealth; + +export const useGetReportingHealthQuery = ({ http }: { http: HttpSetup }) => { + return useQuery({ + queryKey: getKey(), + queryFn: () => getReportingHealth({ http }), + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.ts b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.ts new file mode 100644 index 0000000000000..5a1408fefc84f --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from '@kbn/core/public'; +import { useMutation } from '@tanstack/react-query'; +import { mutationKeys } from '../mutation_keys'; +import { scheduleReport, ScheduleReportRequestParams } from '../apis/schedule_report'; + +export const getKey = mutationKeys.scheduleReport; + +export const useScheduleReport = ({ http }: { http: HttpSetup }) => { + return useMutation({ + mutationKey: getKey(), + mutationFn: (params: ScheduleReportRequestParams) => scheduleReport({ http, params }), + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/integrations/scheduled_report_share_integration.tsx b/x-pack/platform/plugins/private/reporting/public/management/integrations/scheduled_report_share_integration.tsx new file mode 100644 index 0000000000000..fa7fb54c8957c --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/integrations/scheduled_report_share_integration.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { ShareContext } from '@kbn/share-plugin/public'; +import type { ExportShareDerivatives } from '@kbn/share-plugin/public/types'; +import type { ReportingSharingData } from '@kbn/reporting-public/share/share_context_menu'; +import { EuiButton } from '@elastic/eui'; +import type { ReportingAPIClient } from '@kbn/reporting-public'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { SCHEDULED_REPORT_VALID_LICENSES } from '@kbn/reporting-common'; +import { getKey as getReportingHealthQueryKey } from '../hooks/use_get_reporting_health_query'; +import { queryClient } from '../../query_client'; +import { ScheduledReportFlyoutShareWrapper } from '../components/scheduled_report_flyout_share_wrapper'; +import { SCHEDULE_EXPORT_BUTTON_LABEL } from '../translations'; +import type { ReportingPublicPluginSetupDependencies } from '../../plugin'; +import { getReportingHealth } from '../apis/get_reporting_health'; + +export interface CreateScheduledReportProviderOptions { + apiClient: ReportingAPIClient; + services: ReportingPublicPluginSetupDependencies; +} + +export const shouldRegisterScheduledReportShareIntegration = async (http: HttpSetup) => { + const { isSufficientlySecure, hasPermanentEncryptionKey } = await queryClient.fetchQuery({ + queryKey: getReportingHealthQueryKey(), + queryFn: () => getReportingHealth({ http }), + }); + return isSufficientlySecure && hasPermanentEncryptionKey; +}; + +export const createScheduledReportShareIntegration = ({ + apiClient, + services, +}: CreateScheduledReportProviderOptions): ExportShareDerivatives => { + return { + id: 'scheduledReports', + groupId: 'exportDerivatives', + shareType: 'integration', + config: (shareOpts: ShareContext): ReturnType => { + const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData }; + return { + label: ({ openFlyout }) => ( + + {SCHEDULE_EXPORT_BUTTON_LABEL} + + ), + flyoutContent: ({ closeFlyout }) => { + return ( + + ); + }, + flyoutSizing: { size: 'm', maxWidth: 500 }, + }; + }, + prerequisiteCheck: ({ license }) => { + if (!license || !license.type) { + return false; + } + return SCHEDULED_REPORT_VALID_LICENSES.includes(license.type); + }, + }; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx b/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx index 4352557e57617..986a34d3c55b6 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx @@ -8,7 +8,7 @@ import * as React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import type { CoreStart } from '@kbn/core/public'; +import type { CoreStart, NotificationsStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; @@ -21,25 +21,43 @@ import { ReportingAPIClient, KibanaContext, } from '@kbn/reporting-public'; +import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from '../query_client'; import { ReportListing } from '.'; import { PolicyStatusContextProvider } from '../lib/default_status_context'; -export async function mountManagementSection( - coreStart: CoreStart, - license$: LicensingPluginStart['license$'], - dataService: DataPublicPluginStart, - shareService: SharePluginStart, - config: ClientConfigType, - apiClient: ReportingAPIClient, - params: ManagementAppMountParams -) { +export async function mountManagementSection({ + coreStart, + license$, + dataService, + shareService, + config, + apiClient, + params, + actionsService, + notificationsService, +}: { + coreStart: CoreStart; + license$: LicensingPluginStart['license$']; + dataService: DataPublicPluginStart; + shareService: SharePluginStart; + config: ClientConfigType; + apiClient: ReportingAPIClient; + params: ManagementAppMountParams; + actionsService: ActionsPublicPluginSetup; + notificationsService: NotificationsStart; +}) { const services: KibanaContext = { http: coreStart.http, application: coreStart.application, + settings: coreStart.settings, uiSettings: coreStart.uiSettings, docLinks: coreStart.docLinks, data: dataService, share: shareService, + actions: actionsService, + notifications: notificationsService, }; render( @@ -47,15 +65,17 @@ export async function mountManagementSection( - + + + diff --git a/x-pack/platform/plugins/private/reporting/public/management/mutation_keys.ts b/x-pack/platform/plugins/private/reporting/public/management/mutation_keys.ts new file mode 100644 index 0000000000000..b1d2acc130f74 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/mutation_keys.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mutationKeys = { + root: 'reporting', + scheduleReport: () => [mutationKeys.root, 'scheduleReport'] as const, +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts b/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts new file mode 100644 index 0000000000000..efa86cccb03bb --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const queryKeys = { + root: 'reporting', + getHealth: () => [queryKeys.root, 'health'] as const, +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/report_params.ts b/x-pack/platform/plugins/private/reporting/public/management/report_params.ts new file mode 100644 index 0000000000000..3221e4ae5c240 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/report_params.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import rison from '@kbn/rison'; +import { + getPdfReportParams, + getPngReportParams, +} from '@kbn/reporting-public/share/share_context_menu/register_pdf_png_modal_reporting'; +import { getCsvReportParams } from '@kbn/reporting-public/share/share_context_menu/register_csv_modal_reporting'; +import type { ReportingAPIClient } from '@kbn/reporting-public'; +import type { ReportTypeId } from '../types'; + +const reportParamsProviders = { + pngV2: getPngReportParams, + printablePdfV2: getPdfReportParams, + csv_searchsource: getCsvReportParams, +} as const; + +export const supportedReportTypes = Object.keys(reportParamsProviders) as ReportTypeId[]; + +export interface GetReportParamsOptions { + apiClient: ReportingAPIClient; + reportTypeId: ReportTypeId; + objectType: string; + sharingData: any; + title: string; +} + +export const getReportParams = ({ + apiClient, + reportTypeId, + objectType, + sharingData, + title, +}: GetReportParamsOptions) => { + const getParams = reportParamsProviders[reportTypeId]; + if (!getParams) { + throw new Error(`No params provider found for report type ${reportTypeId}`); + } + return rison.encode( + apiClient.getDecoratedJobParams({ + ...getParams({ + objectType, + sharingData, + }), + objectType, + title, + }) + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/schemas/scheduled_report_form_schema.ts b/x-pack/platform/plugins/private/reporting/public/management/schemas/scheduled_report_form_schema.ts new file mode 100644 index 0000000000000..2f1cee3e75f67 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/schemas/scheduled_report_form_schema.ts @@ -0,0 +1,61 @@ +/* + * 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 { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { getRecurringScheduleFormSchema } from '@kbn/response-ops-recurring-schedule-form/schemas/recurring_schedule_form_schema'; +import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { ReportTypeData, ReportTypeId } from '../../types'; +import { getEmailsValidator } from '../validators/emails_validator'; +import * as i18n from '../translations'; + +const { emptyField } = fieldValidators; + +export const getScheduledReportFormSchema = ( + validateEmailAddresses: ActionsPublicPluginSetup['validateEmailAddresses'], + availableReportTypes?: ReportTypeData[] +) => ({ + title: { + type: FIELD_TYPES.TEXT, + label: i18n.SCHEDULED_REPORT_FORM_FILE_NAME_LABEL, + validations: [ + { + validator: emptyField(i18n.SCHEDULED_REPORT_FORM_FILE_NAME_REQUIRED_MESSAGE), + }, + ], + }, + reportTypeId: { + type: FIELD_TYPES.SUPER_SELECT, + label: i18n.SCHEDULED_REPORT_FORM_FILE_TYPE_LABEL, + defaultValue: (availableReportTypes?.[0]?.id as ReportTypeId) ?? '', + validations: [ + { + validator: emptyField(i18n.SCHEDULED_REPORT_FORM_FILE_TYPE_REQUIRED_MESSAGE), + }, + ], + }, + recurringSchedule: getRecurringScheduleFormSchema({ allowInfiniteRecurrence: false }), + sendByEmail: { + type: FIELD_TYPES.TOGGLE, + label: i18n.SCHEDULED_REPORT_FORM_SEND_BY_EMAIL_LABEL, + defaultValue: false, + }, + emailRecipients: { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_LABEL, + defaultValue: [], + validations: [ + { + validator: emptyField(i18n.SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_REQUIRED_MESSAGE), + }, + { + isBlocking: false, + validator: getEmailsValidator(validateEmailAddresses), + }, + ], + }, +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx b/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx index 3e7a3c8cb10fd..910a32f7a5aed 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx @@ -32,6 +32,7 @@ export const ReportListingStateful: FC = (props) => { const ilmPolicyContextValue = useIlmPolicyStatus(); const hasIlmPolicy = ilmPolicyContextValue?.status !== 'policy-not-found'; const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); + return ( <> {}, + }, +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/translations.ts b/x-pack/platform/plugins/private/reporting/public/management/translations.ts new file mode 100644 index 0000000000000..e2771b76c068d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/translations.ts @@ -0,0 +1,297 @@ +/* + * 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'; + +export const SCHEDULE_EXPORT_BUTTON_LABEL = i18n.translate( + 'xpack.reporting.scheduleExportButtonLabel', + { + defaultMessage: 'Schedule export', + } +); + +export const SCHEDULED_REPORT_FLYOUT_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingFlyout.title', + { + defaultMessage: 'Schedule exports', + } +); + +export const SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingFlyout.submitButtonLabel', + { + defaultMessage: 'Schedule exports', + } +); + +export const SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingFlyout.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_NAME_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileNameLabel', + { + defaultMessage: 'Report name', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_NAME_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileNameRequiredMessage', + { + defaultMessage: 'Report file name is required', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_TYPE_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileTypeLabel', + { + defaultMessage: 'File type', + } +); + +export const SCHEDULED_REPORT_FORM_OPTIMIZED_FOR_PRINTING_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.optimizedForPrintingLabel', + { + defaultMessage: 'Print format', + } +); + +export const SCHEDULED_REPORT_FORM_OPTIMIZED_FOR_PRINTING_DESCRIPTION = i18n.translate( + 'xpack.reporting.scheduledReportingForm.optimizedForDescription', + { + defaultMessage: 'Uses multiple pages, showing at most 2 visualizations per page', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_TYPE_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileTypeRequiredMessage', + { + defaultMessage: 'File type is required', + } +); + +export const SCHEDULED_REPORT_FORM_START_DATE_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.startDateLabel', + { + defaultMessage: 'Date', + } +); + +export const SCHEDULED_REPORT_FORM_START_DATE_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.startDateRequiredMessage', + { + defaultMessage: 'Date is required', + } +); + +export const SCHEDULED_REPORT_FORM_START_DATE_TOO_EARLY_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.startDateTooEarlyMessage', + { + defaultMessage: 'Start date must be in the future', + } +); + +export const SCHEDULED_REPORT_FORM_TIMEZONE_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.timezoneLabel', + { + defaultMessage: 'Timezone', + } +); +export const SCHEDULED_REPORT_FORM_TIMEZONE_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.timezoneRequiredMessage', + { + defaultMessage: 'Timezone is required', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_NAME_SUFFIX = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileNameSuffix', + { + defaultMessage: '+ @timestamp', + } +); + +export const SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.detailsSectionTitle', + { + defaultMessage: 'Details', + } +); + +export const SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.scheduleSectionTitle', + { + defaultMessage: 'Schedule', + } +); + +export const SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.exportsSectionTitle', + { + defaultMessage: 'Exports', + } +); + +export const SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION = i18n.translate( + 'xpack.reporting.scheduledReportingForm.exportsSectionDescription', + { + defaultMessage: + "On the scheduled date, we'll create a snapshot of this data point and will post the downloadable report on the ", + } +); + +export const REPORTING_PAGE_LINK_TEXT = i18n.translate( + 'xpack.reporting.scheduledReportingForm.reportingPageLinkText', + { + defaultMessage: 'Reporting page', + } +); + +export const SCHEDULED_REPORT_FORM_RECURRING_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.recurringLabel', + { + defaultMessage: 'Make recurring', + } +); + +export const SCHEDULED_REPORT_FORM_SEND_BY_EMAIL_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.sendByEmailLabel', + { + defaultMessage: 'Send by email', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailRecipientsLabel', + { + defaultMessage: 'To', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailRecipientsRequiredMessage', + { + defaultMessage: 'Provide at least one recipient', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_HINT = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailRecipientsHint', + { + defaultMessage: + "On the scheduled date, we'll also email the report to the addresses you specify here.", + } +); + +export const SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.missingEmailConnectorTitle', + { + defaultMessage: "Email connector hasn't been created yet", + } +); + +export const SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.missingEmailConnectorMessage', + { + defaultMessage: 'A default email connector must be configured in order to send notifications.', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailSensitiveInfoTitle', + { + defaultMessage: 'Sensitive information', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailSensitiveInfoMessage', + { + defaultMessage: 'Report may contain sensitive information', + } +); + +export const SCHEDULED_REPORT_FORM_SUCCESS_TOAST_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.successToastTitle', + { + defaultMessage: 'Export scheduled', + } +); + +export const SCHEDULED_REPORT_FORM_CREATE_EMAIL_CONNECTOR_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.createEmailConnectorLabel', + { + defaultMessage: 'Create Email connector', + } +); + +export const SCHEDULED_REPORT_FORM_SUCCESS_TOAST_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.successToastMessage', + { + defaultMessage: 'Find your schedule information and your exports in the ', + } +); + +export const SCHEDULED_REPORT_FORM_FAILURE_TOAST_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.failureToastTitle', + { + defaultMessage: 'Schedule error', + } +); + +export const SCHEDULED_REPORT_FORM_FAILURE_TOAST_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.failureToastMessage', + { + defaultMessage: 'Sorry, we couldn’t schedule your export. Please try again.', + } +); + +export const CANNOT_LOAD_REPORTING_HEALTH_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.cannotLoadReportingHealthTitle', + { + defaultMessage: 'Cannot load reporting health', + } +); + +export const UNMET_REPORTING_PREREQUISITES_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.unmetReportingPrerequisitesTitle', + { + defaultMessage: 'Cannot schedule reports', + } +); + +export const UNMET_REPORTING_PREREQUISITES_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.unmetReportingPrerequisitesMessage', + { + defaultMessage: + 'One or more prerequisites for scheduling reports was not met. Contact your administrator to know more.', + } +); + +export const CANNOT_LOAD_REPORTING_HEALTH_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.cannotLoadReportingHealthMessage', + { + defaultMessage: 'Reporting health is a prerequisite to create scheduled exports', + } +); + +export function getInvalidEmailAddress(email: string) { + return i18n.translate('xpack.reporting.components.email.error.invalidEmail', { + defaultMessage: 'Email address {email} is not valid', + values: { email }, + }); +} + +export function getNotAllowedEmailAddress(email: string) { + return i18n.translate('xpack.reporting.components.email.error.notAllowed', { + defaultMessage: 'Email address {email} is not allowed', + values: { email }, + }); +} diff --git a/x-pack/platform/plugins/private/reporting/public/management/utils.ts b/x-pack/platform/plugins/private/reporting/public/management/utils.ts index cf1698bbd9fea..3e9098e0c9a3a 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/utils.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/utils.ts @@ -8,6 +8,18 @@ import type { IconType } from '@elastic/eui'; import { JOB_STATUS } from '@kbn/reporting-common'; import { Job } from '@kbn/reporting-public'; +import type { Rrule } from '@kbn/task-manager-plugin/server/task'; +import { Frequency } from '@kbn/rrule'; +import type { + RecurrenceFrequency, + RecurringSchedule, +} from '@kbn/response-ops-recurring-schedule-form/types'; +import { + RRULE_TO_ISO_WEEKDAYS, + RecurrenceEnd, +} from '@kbn/response-ops-recurring-schedule-form/constants'; +import { ScheduledReportApiJSON } from '@kbn/reporting-common/types'; +import type { ScheduledReport } from '../types'; /** * This is not the most forward-compatible way of mapping to an {@link IconType} for an application. @@ -47,3 +59,76 @@ export const jobHasIssues = (job: Job): boolean => { [JOB_STATUS.WARNINGS, JOB_STATUS.FAILED].some((status) => job.status === status) ); }; + +const isCustomRrule = (rRule: Rrule) => { + const freq = rRule.freq; + // interval is greater than 1 + if (rRule.interval && rRule.interval > 1) { + return true; + } + // frequency is daily and no weekdays are selected + if (freq && freq === Frequency.DAILY && !rRule.byweekday) { + return true; + } + // frequency is weekly and there are multiple weekdays selected + if (freq && freq === Frequency.WEEKLY && rRule.byweekday && rRule.byweekday.length > 1) { + return true; + } + // frequency is monthly and by month day is selected + if (freq && freq === Frequency.MONTHLY && rRule.bymonthday) { + return true; + } + return false; +}; + +export const transformScheduledReport = (report: ScheduledReportApiJSON): ScheduledReport => { + const { title, schedule, notification } = report; + const rRule = schedule.rrule; + + const isCustomFrequency = isCustomRrule(rRule); + const frequency = rRule.freq as RecurrenceFrequency; + + const recurringSchedule: RecurringSchedule = { + frequency: isCustomFrequency ? 'CUSTOM' : frequency, + interval: rRule.interval, + ends: RecurrenceEnd.NEVER, + }; + + if (isCustomFrequency) { + recurringSchedule.customFrequency = frequency; + } + + if (frequency !== Frequency.MONTHLY && rRule.byweekday) { + recurringSchedule.byweekday = rRule.byweekday.reduce>((acc, day) => { + const isoWeekDay = RRULE_TO_ISO_WEEKDAYS[day]; + if (isoWeekDay != null) { + acc[isoWeekDay] = true; + } + return acc; + }, {}); + } + if (frequency === Frequency.MONTHLY) { + if (rRule.byweekday?.length) { + recurringSchedule.bymonth = 'weekday'; + recurringSchedule.bymonthweekday = rRule.byweekday[0]; + } else if (rRule.bymonthday?.length) { + recurringSchedule.bymonth = 'day'; + recurringSchedule.bymonthday = rRule.bymonthday[0]; + } + } + + if (rRule.byhour?.length && rRule.byminute?.length) { + recurringSchedule.byhour = rRule.byhour[0]; + recurringSchedule.byminute = rRule.byminute[0]; + } + + return { + title, + recurringSchedule, + reportTypeId: report.jobtype as ScheduledReport['reportTypeId'], + timezone: schedule.rrule.tzid, + recurring: true, + sendByEmail: Boolean(notification?.email), + emailRecipients: [...(notification?.email?.to || [])], + }; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.ts b/x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.ts new file mode 100644 index 0000000000000..04fac1ed67a07 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { InvalidEmailReason } from '@kbn/actions-plugin/common'; +import type { ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import { getInvalidEmailAddress, getNotAllowedEmailAddress } from '../translations'; +import type { ScheduledReport } from '../../types'; + +export const getEmailsValidator = + ( + validateEmailAddresses: ActionsPublicPluginSetup['validateEmailAddresses'] + ): ValidationFunc => + ({ value, path }) => { + const validatedEmails = validateEmailAddresses(Array.isArray(value) ? value : [value]); + for (const validatedEmail of validatedEmails) { + if (!validatedEmail.valid) { + return { + path, + message: + validatedEmail.reason === InvalidEmailReason.notAllowed + ? getNotAllowedEmailAddress(value) + : getInvalidEmailAddress(value), + }; + } + } + }; diff --git a/x-pack/platform/plugins/private/reporting/public/management/validators/start_date_validator.ts b/x-pack/platform/plugins/private/reporting/public/management/validators/start_date_validator.ts new file mode 100644 index 0000000000000..4555b4f1edf9e --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/validators/start_date_validator.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Moment } from 'moment'; +import { ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { SCHEDULED_REPORT_FORM_START_DATE_TOO_EARLY_MESSAGE } from '../translations'; +import { ScheduledReport } from '../../types'; + +export const getStartDateValidator = + (today: Moment): ValidationFunc => + ({ value }) => { + if (value.isBefore(today)) { + return { + message: SCHEDULED_REPORT_FORM_START_DATE_TOO_EARLY_MESSAGE, + }; + } + }; diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 4ba306e084302..d9589d650ffb8 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -15,7 +15,12 @@ import { i18n } from '@kbn/i18n'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public'; -import type { SharePluginSetup, SharePluginStart, ExportShare } from '@kbn/share-plugin/public'; +import type { + SharePluginSetup, + SharePluginStart, + ExportShare, + ExportShareDerivatives, +} from '@kbn/share-plugin/public'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { durationToNumber } from '@kbn/reporting-common'; @@ -30,6 +35,7 @@ import { } from '@kbn/reporting-public/share'; import { ReportingCsvPanelAction } from '@kbn/reporting-csv-share-panel'; import { InjectedIntl } from '@kbn/i18n-react'; +import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import type { ReportingSetup, ReportingStart } from '.'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; import { StartServices } from './types'; @@ -41,6 +47,7 @@ export interface ReportingPublicPluginSetupDependencies { screenshotMode: ScreenshotModePluginSetup; share: SharePluginSetup; intl: InjectedIntl; + actions: ActionsPublicPluginSetup; } export interface ReportingPublicPluginStartDependencies { @@ -108,6 +115,7 @@ export class ReportingPublicPlugin screenshotMode: screenshotModeSetup, share: shareSetup, uiActions: uiActionsSetup, + actions: actionsSetup, } = setupDeps; const startServices$: Observable = from(getStartServices()).pipe( @@ -157,15 +165,17 @@ export class ReportingPublicPlugin const { docTitle } = coreStart.chrome; docTitle.change(this.title); - const umountAppCallback = await mountManagementSection( + const umountAppCallback = await mountManagementSection({ coreStart, - licensing.license$, - data, - share, - this.config, + license$: licensing.license$, + dataService: data, + shareService: share, + config: this.config, apiClient, - params - ); + params, + actionsService: actionsSetup, + notificationsService: coreStart.notifications, + }); return () => { docTitle.reset(); @@ -234,6 +244,22 @@ export class ReportingPublicPlugin ); } + import('./management/integrations/scheduled_report_share_integration').then( + async ({ + shouldRegisterScheduledReportShareIntegration, + createScheduledReportShareIntegration, + }) => { + if (await shouldRegisterScheduledReportShareIntegration(core.http)) { + shareSetup.registerShareIntegration( + createScheduledReportShareIntegration({ + apiClient, + services: { ...core, ...setupDeps }, + }) + ); + } + } + ); + this.startServices$ = startServices$; return this.getContract(apiClient, startServices$); } diff --git a/x-pack/platform/plugins/private/reporting/public/query_client.ts b/x-pack/platform/plugins/private/reporting/public/query_client.ts new file mode 100644 index 0000000000000..83745302b278f --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/query_client.ts @@ -0,0 +1,16 @@ +/* + * 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 { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/x-pack/platform/plugins/private/reporting/public/types.ts b/x-pack/platform/plugins/private/reporting/public/types.ts index 986cbcdab06fa..d69136efc1e47 100644 --- a/x-pack/platform/plugins/private/reporting/public/types.ts +++ b/x-pack/platform/plugins/private/reporting/public/types.ts @@ -8,6 +8,7 @@ import type { CoreStart } from '@kbn/core/public'; import { JOB_STATUS } from '@kbn/reporting-common'; import type { JobId, ReportOutput, ReportSource, TaskRunResult } from '@kbn/reporting-common/types'; +import { RecurringSchedule } from '@kbn/response-ops-recurring-schedule-form/types'; import { ReportingPublicPluginStartDependencies } from './plugin'; /* @@ -49,3 +50,28 @@ export interface JobSummarySet { completed?: JobSummary[]; failed?: JobSummary[]; } + +export type ReportTypeId = 'pngV2' | 'printablePdfV2' | 'csv_searchsource'; + +export interface ScheduledReport { + title: string; + reportTypeId: ReportTypeId; + optimizedForPrinting?: boolean; + recurring: boolean; + recurringSchedule: RecurringSchedule; + sendByEmail: boolean; + emailRecipients: string[]; + /** + * @internal Still unsupported by the schedule API + */ + startDate?: string; + /** + * @internal Still unsupported by the schedule API + */ + timezone?: string; +} + +export interface ReportTypeData { + label: string; + id: string; +} diff --git a/x-pack/platform/plugins/private/reporting/tsconfig.json b/x-pack/platform/plugins/private/reporting/tsconfig.json index fcd2883be3b6d..97f9602f4a192 100644 --- a/x-pack/platform/plugins/private/reporting/tsconfig.json +++ b/x-pack/platform/plugins/private/reporting/tsconfig.json @@ -58,6 +58,9 @@ "@kbn/notifications-plugin", "@kbn/spaces-utils", "@kbn/logging-mocks", + "@kbn/core-http-browser", + "@kbn/response-ops-recurring-schedule-form", + "@kbn/core-mount-utils-browser-internal", ], "exclude": [ "target/**/*", diff --git a/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx b/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx index dc9561083a6dd..2670993aa2e2b 100644 --- a/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx +++ b/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx @@ -167,11 +167,11 @@ export const CreateMaintenanceWindowForm = React.memo = React > {i18n.CREATE_FORM_RECURRING_SUMMARY_PREFIX( - recurringSummary(startDate, recurringSchedule, presets) + recurringSummary({ startDate, recurringSchedule, presets }) )} diff --git a/x-pack/test/functional/apps/discover/group1/reporting.ts b/x-pack/test/functional/apps/discover/group1/reporting.ts index ae66cbee3ebeb..314b9e835e09a 100644 --- a/x-pack/test/functional/apps/discover/group1/reporting.ts +++ b/x-pack/test/functional/apps/discover/group1/reporting.ts @@ -103,7 +103,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await toasts.dismissAll(); await exports.clickExportTopNavButton(); + await reporting.selectExportItem('CSV'); await reporting.clickGenerateReportButton(); + await exports.closeExportFlyout(); + await exports.clickExportTopNavButton(); const url = await reporting.getReportURL(timeout); const res = await reporting.getResponse(url ?? ''); @@ -116,6 +119,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const getReportPostUrl = async () => { // click 'Copy POST URL' await exports.clickExportTopNavButton(); + await reporting.selectExportItem('CSV'); + await reporting.clickGenerateReportButton(); await reporting.copyReportingPOSTURLValueToClipboard(); const clipboardValue = decodeURIComponent(await browser.getClipboardValue()); @@ -141,15 +146,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await reporting.openExportPopover(); - expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); - await exports.closeExportFlyout(); + expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); + await reporting.openExportPopover(); }); it('becomes available when saved', async () => { await discover.saveSearch('my search - expectEnabledGenerateReportButton'); await reporting.openExportPopover(); - expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); - await exports.closeExportFlyout(); + expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); + await reporting.openExportPopover(); }); }); diff --git a/x-pack/test/functional/apps/discover/group2/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/group2/feature_controls/discover_security.ts index 566c382a1b76d..0cfb8e58d2ff1 100644 --- a/x-pack/test/functional/apps/discover/group2/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/group2/feature_controls/discover_security.ts @@ -142,6 +142,7 @@ export default function (ctx: FtrProviderContext) { it('shows CSV reports', async () => { await exports.clickExportTopNavButton(); + await exports.clickPopoverItem('CSV'); await testSubjects.existOrFail('generateReportButton'); await exports.closeExportFlyout(); }); diff --git a/x-pack/test/reporting_functional/services/scenarios.ts b/x-pack/test/reporting_functional/services/scenarios.ts index b526a1b26dfb1..14f1e29e208ff 100644 --- a/x-pack/test/reporting_functional/services/scenarios.ts +++ b/x-pack/test/reporting_functional/services/scenarios.ts @@ -114,6 +114,7 @@ export function createScenarios( const tryDiscoverCsvSuccess = async () => { await PageObjects.reporting.openExportPopover(); + await PageObjects.exports.clickPopoverItem('CSV'); expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); }; const tryGeneratePdfFail = async () => { diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts b/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts index 72954ec033c93..03a1ac4f49e8e 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts @@ -34,8 +34,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // close any open notification toasts await toasts.dismissAll(); - await PageObjects.reporting.openExportPopover(); + await PageObjects.exports.clickExportTopNavButton(); + await PageObjects.reporting.selectExportItem('CSV'); await PageObjects.reporting.clickGenerateReportButton(); + await PageObjects.exports.closeExportFlyout(); + await PageObjects.exports.clickExportTopNavButton(); const url = await PageObjects.reporting.getReportURL(timeout); // TODO: Fetch CSV client side in Serverless since `PageObjects.reporting.getResponse()` @@ -90,7 +93,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await PageObjects.reporting.openExportPopover(); - expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); + expect(await PageObjects.exports.isPopoverItemEnabled('CSV')).to.be(true); + await PageObjects.reporting.openExportPopover(); }); it('becomes available when saved', async () => { @@ -99,7 +103,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { true ); await PageObjects.reporting.openExportPopover(); - expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); + expect(await PageObjects.exports.isPopoverItemEnabled('CSV')).to.be(true); + await PageObjects.reporting.openExportPopover(); }); });