From c68ffaf05448c80aa6e06bcd8cd1c55a0944259f Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Sat, 31 May 2025 11:02:16 +0200 Subject: [PATCH 01/30] Draft scheduled report flyout UI --- .../private/kbn-reporting/public/index.ts | 4 + .../components/custom_recurring_schedule.tsx | 241 +++++------ .../components/recurring_schedule_field.tsx | 72 ++++ .../plugins/private/reporting/kibana.jsonc | 3 +- .../management/apis/get_reporting_health.ts | 13 + .../scheduled_report_flyout.test.tsx | 115 +++++ .../components/scheduled_report_flyout.tsx | 70 ++++ .../scheduled_report_flyout_content.test.tsx | 141 +++++++ .../scheduled_report_flyout_content.tsx | 392 ++++++++++++++++++ .../hooks/use_get_reporting_health_query.ts | 19 + .../management/mount_management_section.tsx | 54 ++- .../reporting/public/management/query_keys.ts | 11 + .../stateful/report_listing_stateful.tsx | 33 +- .../public/management/translations.ts | 204 +++++++++ .../management/validators/emails_validator.ts | 31 ++ .../validators/start_date_validator.ts | 21 + .../private/reporting/public/plugin.ts | 18 +- .../plugins/private/reporting/public/types.ts | 18 + 18 files changed, 1316 insertions(+), 144 deletions(-) create mode 100644 src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_field.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/apis/get_reporting_health.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/query_keys.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/translations.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/validators/start_date_validator.ts diff --git a/src/platform/packages/private/kbn-reporting/public/index.ts b/src/platform/packages/private/kbn-reporting/public/index.ts index 4d9fb13d89483..9683c3a8fca9b 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 { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; + export type { ClientConfigType } from './types'; export { Job } from './job'; export * from './job_completion_notifications'; @@ -26,10 +28,12 @@ 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; } export const useKibana = () => _useKibana(); 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..9b71d2219899c 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 @@ -43,133 +43,140 @@ const styles = { export interface CustomRecurringScheduleProps { startDate: string; + readOnly?: boolean; } -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 }: 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( + () => RECURRING_SCHEDULE_FORM_CUSTOM_FREQUENCY(parsedSchedule?.interval), + [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', + euiFieldProps: { + 'data-test-subj': 'customRecurringScheduleByWeekdayButtonGroup', + legend: 'Repeat on weekday', + options: WEEKDAY_OPTIONS, + disabled: 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_field.tsx b/src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_field.tsx new file mode 100644 index 0000000000000..54fe0172c7bf3 --- /dev/null +++ b/src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_field.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { memo } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FormProps } from '@kbn/alerting-plugin/public/pages/maintenance_windows/components/schema'; +import { RecurringScheduleForm } from './recurring_schedule_form'; +import type { RecurringSchedule } from '../types'; + +export interface RecurringScheduleFieldProps { + path?: string; + startDate?: string; + endDate?: string; + timezone: string[]; + hideTimezone?: boolean; + readOnly?: boolean; + allowInfiniteRecurrence?: boolean; +} + +/** + * An adapter to use the recurring schedule form inside other `hook_form_lib` forms + * + * Define an empty field in your form schema + * ```tsx + * const schema = { + * recurringField: {}, + * }; + * ``` + * and use the RecurringScheduleField component with a `path` corresponding to the field name + * ```tsx + * + * ``` + */ +export const RecurringScheduleField = memo( + ({ + path = 'recurringSchedule', + startDate, + endDate, + timezone, + hideTimezone, + allowInfiniteRecurrence = true, + readOnly = false, + }: RecurringScheduleFieldProps) => { + return ( + path={path}> + {({ value, setValue, setErrors }) => ( + { + setErrors(errors.map((message) => ({ path, message }))); + }} + startDate={startDate} + endDate={endDate} + timezone={timezone} + hideTimezone={hideTimezone} + readOnly={readOnly} + allowInfiniteRecurrence={allowInfiniteRecurrence} + /> + )} + + ); + } +); + +RecurringScheduleField.displayName = 'RecurringScheduleField'; 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..6cdc9368ecca4 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/apis/get_reporting_health.ts @@ -0,0 +1,13 @@ +/* + * 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 getReportingHealth = () => + Promise.resolve({ + hasPermanentEncryptionKey: true, + isSufficientlySecure: true, + hasEmailConnector: true, + }); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.test.tsx new file mode 100644 index 0000000000000..c554cc7487ced --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.test.tsx @@ -0,0 +1,115 @@ +/* + * 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. + */ + +/* eslint-disable no-console */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ScheduledReportFlyout } from './scheduled_report_flyout'; +import * as getReportingHealthModule from '../apis/get_reporting_health'; +import { ReportFormat, ScheduledReport } from '../../types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +jest.mock('./scheduled_report_flyout_content', () => ({ + ScheduledReportForm: () =>
ScheduledReportForm
, +})); + +const mockScheduledReport: ScheduledReport = { + id: '1', + jobParams: { foo: 'bar' }, +} as any; +const mockFormats: ReportFormat[] = [ + { + id: 'printablePdfV2', + label: 'PDF', + }, + { + id: 'pngV2', + label: 'PNG', + }, + { + id: 'csv_searchsource', + label: 'CSV', + }, +]; +const mockOnClose = jest.fn(); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + logger: { + log: console.log, + warn: console.warn, + error: () => {}, + }, +}); + +describe('ScheduledReportFlyout', () => { + beforeEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + }); + + it('renders loading state', () => { + jest + .spyOn(getReportingHealthModule, 'getReportingHealth') + .mockImplementation(() => new Promise(() => {})); + + render( + + + + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders error state', async () => { + jest.spyOn(getReportingHealthModule, 'getReportingHealth').mockImplementation(async () => { + throw new Error('Test error'); + }); + + render( + + + + ); + + expect(await screen.findByText('Cannot load reporting health')).toBeInTheDocument(); + }); + + it('renders form with reporting health', async () => { + jest.spyOn(getReportingHealthModule, 'getReportingHealth').mockResolvedValue({ + hasPermanentEncryptionKey: true, + isSufficientlySecure: true, + hasEmailConnector: true, + }); + + render( + + + + ); + + expect(await screen.findByText('ScheduledReportForm')).toBeInTheDocument(); + }); +}); 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..30da1ff17a201 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiCallOut, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiLoadingSpinner, + EuiTitle, +} from '@elastic/eui'; +import { SetRequired } from 'type-fest'; +import { + CANNOT_LOAD_REPORTING_HEALTH_MESSAGE, + CANNOT_LOAD_REPORTING_HEALTH_TITLE, + SCHEDULED_REPORT_FLYOUT_TITLE, +} from '../translations'; +import { ReportFormat, ScheduledReport } from '../../types'; +import { useGetReportingHealthQuery } from '../hooks/use_get_reporting_health_query'; +import { ScheduledReportForm } from './scheduled_report_flyout_content'; + +export interface ScheduledReportFlyoutProps { + scheduledReport: SetRequired, 'jobParams'>; + availableFormats: ReportFormat[]; + onClose: () => void; + readOnly?: boolean; +} + +export const ScheduledReportFlyout = ({ + scheduledReport, + availableFormats, + onClose, + readOnly = false, +}: ScheduledReportFlyoutProps) => { + const { data: reportingHealth, isLoading, isError } = useGetReportingHealthQuery(); + + return ( + + + +

{SCHEDULED_REPORT_FLYOUT_TITLE}

+
+
+ + {isLoading || isError ? ( + + {isLoading && } + {isError && ( + +

{CANNOT_LOAD_REPORTING_HEALTH_MESSAGE}

+
+ )} +
+ ) : ( + + )} +
+ ); +}; 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..f323676f01889 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.test.tsx @@ -0,0 +1,141 @@ +/* + * 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 { render, screen, fireEvent } from '@testing-library/react'; +import { ScheduledReportForm } from './scheduled_report_flyout_content'; +import { ReportFormat, ScheduledReport } from '../../types'; +import { useKibana } from '@kbn/reporting-public'; +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_field', () => ({ + RecurringScheduleField: () =>
, +})); + +describe('ScheduledReportForm', () => { + const scheduledReport = { + jobParams: {}, + fileName: '', + fileType: 'pdf', + } as ScheduledReport; + + const availableFormats: ReportFormat[] = [ + { + id: 'printablePdfV2', + label: 'PDF', + }, + { + id: 'pngV2', + label: 'PNG', + }, + { + id: 'csv_searchsource', + label: 'CSV', + }, + ]; + + const onClose = jest.fn(); + + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + actions: { + validateEmailAddresses: jest.fn().mockResolvedValue([]), + }, + }, + }); + jest.clearAllMocks(); + }); + + it('renders form fields', () => { + render( + + ); + + expect(screen.getByText('Report name')).toBeInTheDocument(); + expect(screen.getByText('File type')).toBeInTheDocument(); + expect(screen.getByText('Date')).toBeInTheDocument(); + expect(screen.getByText('Timezone')).toBeInTheDocument(); + expect(screen.getByText('Make recurring')).toBeInTheDocument(); + expect(screen.getByText('Send by email')).toBeInTheDocument(); + }); + + it('shows recurring schedule fields when recurring is enabled', async () => { + render( + + ); + + const toggle = screen.getByText('Make recurring'); + fireEvent.click(toggle); + + expect(await screen.findByTestId('recurring-schedule-field')).toBeInTheDocument(); + }); + + it('shows email fields when send by email is enabled', async () => { + render( + + ); + + const toggle = screen.getByText('Send by email'); + fireEvent.click(toggle); + + expect(await screen.findByText('To')).toBeInTheDocument(); + expect(await screen.findByText('Sensitive information')).toBeInTheDocument(); + }); + + it('shows warning when email connector is missing', () => { + render( + + ); + + expect(screen.getByText('No email connector configured')).toBeInTheDocument(); + }); + + it('calls onClose when Cancel button is clicked', async () => { + render( + + ); + + const cancelBtn = screen.getByRole('button', { name: 'Cancel' }); + await userEvent.click(cancelBtn); + expect(onClose).toHaveBeenCalled(); + }); +}); 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..01d90efcde775 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_content.tsx @@ -0,0 +1,392 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiDescribedFormGroup, + EuiDescribedFormGroupProps, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFormLabel, + EuiSpacer, +} from '@elastic/eui'; +import moment from 'moment'; +import type { Moment } from 'moment'; +import { useKibana } from '@kbn/reporting-public'; +import { + FIELD_TYPES, + useForm, + getUseField, + Form, + useFormData, + type FormSchema, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { css } from '@emotion/react'; +import { TIMEZONE_OPTIONS as UI_TIMEZONE_OPTIONS } from '@kbn/core-ui-settings-common'; +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; +import { RecurringScheduleField } from '@kbn/response-ops-recurring-schedule-form/components/recurring_schedule_field'; +import { SetRequired } from 'type-fest'; +import { getStartDateValidator } from '../validators/start_date_validator'; +import { + SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL, + SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL, + SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION, + SCHEDULED_REPORT_FORM_FILE_NAME_LABEL, + SCHEDULED_REPORT_FORM_FILE_TYPE_LABEL, + SCHEDULED_REPORT_FORM_FILE_NAME_REQUIRED_MESSAGE, + SCHEDULED_REPORT_FORM_FILE_NAME_SUFFIX, + SCHEDULED_REPORT_FORM_RECURRING_LABEL, + SCHEDULED_REPORT_FORM_SEND_BY_EMAIL_LABEL, + SCHEDULED_REPORT_FORM_START_DATE_LABEL, + SCHEDULED_REPORT_FORM_TIMEZONE_LABEL, + SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_LABEL, + SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_HINT, + SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_TITLE, + SCHEDULED_REPORT_FORM_FILE_TYPE_REQUIRED_MESSAGE, + SCHEDULED_REPORT_FORM_START_DATE_REQUIRED_MESSAGE, + SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_REQUIRED_MESSAGE, + SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE, + SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE, + SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE, + SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_TITLE, +} from '../translations'; +import { ReportFormat, ScheduledReport } from '../../types'; +import { getEmailsValidator } from '../validators/emails_validator'; + +export const toMoment = (value: string): Moment => moment(value); +export const toString = (value: Moment): string => value.toISOString(); + +const FormField = getUseField({ + component: Field, +}); + +const { emptyField } = fieldValidators; + +export interface ScheduledReportFormProps { + scheduledReport: SetRequired, 'jobParams'>; + availableFormats: ReportFormat[]; + onClose: () => void; + readOnly?: boolean; + hasEmailConnector?: boolean; +} + +const TIMEZONE_OPTIONS = UI_TIMEZONE_OPTIONS.map((tz) => ({ + inputDisplay: tz, + value: tz, +})) ?? [{ text: 'UTC', value: 'UTC' }]; + +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 ; +}; + +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 }; +}; + +const formId = 'scheduledReportForm.recurringScheduleForm'; + +export const ScheduledReportForm = ({ + scheduledReport, + availableFormats, + onClose, + hasEmailConnector, + readOnly = false, +}: ScheduledReportFormProps) => { + const { + services: { + actions: { validateEmailAddresses }, + }, + } = useKibana(); + + const { defaultTimezone } = useDefaultTimezone(); + const today = useMemo(() => moment().tz(defaultTimezone), [defaultTimezone]); + const defaultStartDateValue = useMemo(() => today.toISOString(), [today]); + const schema = useMemo>( + () => ({ + fileName: { + type: FIELD_TYPES.TEXT, + label: SCHEDULED_REPORT_FORM_FILE_NAME_LABEL, + validations: [ + { + validator: emptyField(SCHEDULED_REPORT_FORM_FILE_NAME_REQUIRED_MESSAGE), + }, + ], + }, + fileType: { + type: FIELD_TYPES.SUPER_SELECT, + label: SCHEDULED_REPORT_FORM_FILE_TYPE_LABEL, + defaultValue: availableFormats[0].id, + validations: [ + { + validator: emptyField(SCHEDULED_REPORT_FORM_FILE_TYPE_REQUIRED_MESSAGE), + }, + ], + }, + startDate: { + type: FIELD_TYPES.DATE_PICKER, + label: SCHEDULED_REPORT_FORM_START_DATE_LABEL, + defaultValue: defaultStartDateValue, + serializer: toString, + deserializer: toMoment, + validations: [ + { + validator: emptyField(SCHEDULED_REPORT_FORM_START_DATE_REQUIRED_MESSAGE), + }, + { + validator: getStartDateValidator(today), + }, + ], + }, + timezone: { + type: FIELD_TYPES.SUPER_SELECT, + defaultValue: defaultTimezone, + validations: [ + { + validator: emptyField(SCHEDULED_REPORT_FORM_START_DATE_REQUIRED_MESSAGE), + }, + ], + }, + recurring: { + type: FIELD_TYPES.TOGGLE, + label: SCHEDULED_REPORT_FORM_RECURRING_LABEL, + defaultValue: false, + }, + recurringSchedule: {}, + sendByEmail: { + type: FIELD_TYPES.TOGGLE, + label: SCHEDULED_REPORT_FORM_SEND_BY_EMAIL_LABEL, + defaultValue: false, + }, + emailRecipients: { + type: FIELD_TYPES.COMBO_BOX, + label: SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_LABEL, + defaultValue: [], + validations: [ + { + validator: emptyField(SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_REQUIRED_MESSAGE), + }, + { + isBlocking: false, + validator: getEmailsValidator(validateEmailAddresses), + }, + ], + }, + }), + [availableFormats, defaultStartDateValue, defaultTimezone, today, validateEmailAddresses] + ); + const { form } = useForm({ + defaultValue: scheduledReport, + options: { stripEmptyFields: true }, + schema, + onSubmit: async () => { + // TODO create schedule + onClose(); + }, + }); + const [{ recurring, startDate, timezone, sendByEmail }] = useFormData({ + form, + watch: ['recurring', 'startDate', 'timezone', 'sendByEmail'], + }); + + const submitForm = async () => { + if (await form.validate()) { + await form.submit(); + } + }; + + const isRecurring = recurring || false; + const isEmailActive = sendByEmail || false; + + return ( + <> + +
+ {SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE}}> + + ({ inputDisplay: f.label, value: f.id })), + readOnly, + }, + }} + /> + + {SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE}}> + + + {SCHEDULED_REPORT_FORM_TIMEZONE_LABEL} + + ), + readOnly, + }, + }} + /> + + {isRecurring && ( + <> + + + + )} + + {SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE}} + description={

{SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION}

} + > + + {!hasEmailConnector && ( + <> + + +

Missing email connector message

+
+ + )} + {isEmailActive && ( + <> + + + + +

Sensitive info warning text

+
+
+ + )} +
+
+
+ + {!readOnly && ( + + + + + {SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL} + + + + + {SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL} + + + + + )} + + ); +}; 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..2edbf2114c59e --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getReportingHealth } from '../apis/get_reporting_health'; +import { queryKeys } from '../query_keys'; + +export const getKey = queryKeys.getHealth; + +export const useGetReportingHealthQuery = () => { + return useQuery({ + queryKey: getKey(), + queryFn: getReportingHealth, + }); +}; 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..3deac347b5063 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 @@ -21,25 +21,41 @@ import { ReportingAPIClient, KibanaContext, } from '@kbn/reporting-public'; +import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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 -) { +const queryClient = new QueryClient(); + +export async function mountManagementSection({ + coreStart, + license$, + dataService, + shareService, + config, + apiClient, + params, + actionsService, +}: { + coreStart: CoreStart; + license$: LicensingPluginStart['license$']; + dataService: DataPublicPluginStart; + shareService: SharePluginStart; + config: ClientConfigType; + apiClient: ReportingAPIClient; + params: ManagementAppMountParams; + actionsService: ActionsPublicPluginSetup; +}) { const services: KibanaContext = { http: coreStart.http, application: coreStart.application, + settings: coreStart.settings, uiSettings: coreStart.uiSettings, docLinks: coreStart.docLinks, data: dataService, share: shareService, + actions: actionsService, }; render( @@ -47,15 +63,17 @@ export async function mountManagementSection( - + + + 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/stateful/report_listing_stateful.tsx b/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx index 3e7a3c8cb10fd..f021c60b03271 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 @@ -5,9 +5,10 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; import { + EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, @@ -20,6 +21,22 @@ import { ListingPropsInternal } from '..'; import { useIlmPolicyStatus } from '../../lib/ilm_policy_status_context'; import { IlmPolicyLink, MigrateIlmPolicyCallOut, ReportDiagnostic } from '../components'; import { ReportListingTable } from '../report_listing_table'; +import { ScheduledReportFlyout } from '../components/scheduled_report_flyout'; + +const formats = [ + { + id: 'printablePdfV2', + label: 'PDF', + }, + { + id: 'pngV2', + label: 'PNG', + }, + { + id: 'csv_searchsource', + label: 'CSV', + }, +]; /** * Used in Stateful deployments only @@ -32,8 +49,17 @@ export const ReportListingStateful: FC = (props) => { const ilmPolicyContextValue = useIlmPolicyStatus(); const hasIlmPolicy = ilmPolicyContextValue?.status !== 'policy-not-found'; const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); + const [scheduledReportFlyoutOpen, setScheduledReportFlyoutOpen] = useState(false); + return ( <> + {scheduledReportFlyoutOpen && ( + setScheduledReportFlyoutOpen(false)} + scheduledReport={{ jobParams: '' }} + /> + )} = (props) => { defaultMessage="Get reports generated in Kibana applications." /> } + rightSideItems={[ + setScheduledReportFlyoutOpen(true)}> + Schedule export + , + ]} /> 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..036d2c855c58f --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/translations.ts @@ -0,0 +1,204 @@ +/* + * 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 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_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 and list it in Stack Management > Reporting for download", + } +); + +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 send an email to these recipients with the file attached", + } +); + +export const SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.missingEmailConnectorTitle', + { + defaultMessage: 'No email connector configured', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailSensitiveInfoTitle', + { + defaultMessage: 'Sensitive information', + } +); + +export const CANNOT_LOAD_REPORTING_HEALTH_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.cannotLoadReportingHealthTitle', + { + defaultMessage: 'Cannot load reporting health', + } +); + +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.stackConnectors.components.email.error.invalidEmail', { + defaultMessage: 'Email address {email} is not valid', + values: { email }, + }); +} + +export function getNotAllowedEmailAddress(email: string) { + return i18n.translate('xpack.stackConnectors.components.email.error.notAllowed', { + defaultMessage: 'Email address {email} is not allowed', + values: { email }, + }); +} 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..2c2f9b5a7c031 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -30,6 +30,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 +42,7 @@ export interface ReportingPublicPluginSetupDependencies { screenshotMode: ScreenshotModePluginSetup; share: SharePluginSetup; intl: InjectedIntl; + actions: ActionsPublicPluginSetup; } export interface ReportingPublicPluginStartDependencies { @@ -108,6 +110,7 @@ export class ReportingPublicPlugin screenshotMode: screenshotModeSetup, share: shareSetup, uiActions: uiActionsSetup, + actions: actionsSetup, } = setupDeps; const startServices$: Observable = from(getStartServices()).pipe( @@ -157,15 +160,16 @@ 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, + }); return () => { docTitle.reset(); diff --git a/x-pack/platform/plugins/private/reporting/public/types.ts b/x-pack/platform/plugins/private/reporting/public/types.ts index 986cbcdab06fa..dab7083c94b15 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,20 @@ export interface JobSummarySet { completed?: JobSummary[]; failed?: JobSummary[]; } + +export interface ScheduledReport { + jobParams: string; + fileName: string; + fileType: string; + startDate: string; + timezone: string; + recurring: boolean; + recurringSchedule: RecurringSchedule; + sendByEmail: boolean; + emailRecipients: string[]; +} + +export interface ReportFormat { + label: string; + id: string; +} From ff4f8975d22d9ad97fd497a572e05d1a6586b33f Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Tue, 17 Jun 2025 18:09:48 +0200 Subject: [PATCH 02/30] Implement scheduled reports flyout --- .../private/kbn-reporting/public/index.ts | 3 +- .../register_csv_modal_reporting.tsx | 61 +-- .../register_pdf_png_modal_reporting.tsx | 70 ++- .../private/kbn-reporting/public/types.ts | 12 +- .../components/recurring_schedule_field.tsx | 72 --- .../recurring_schedule_form_fields.tsx | 214 ++++----- .../recurring-schedule-form/translations.ts | 14 + .../utils/convert_to_rrule.test.ts | 148 ++++--- .../utils/convert_to_rrule.ts | 19 +- .../plugins/shared/share/public/index.ts | 3 + .../management/apis/get_reporting_health.ts | 26 +- .../public/management/apis/schedule_report.ts | 34 ++ .../components/responsive_form_group.tsx | 34 ++ .../scheduled_report_flyout.test.tsx | 115 ----- .../components/scheduled_report_flyout.tsx | 64 +-- .../scheduled_report_flyout_content.test.tsx | 181 ++++---- .../scheduled_report_flyout_content.tsx | 413 ++++-------------- .../scheduled_report_flyout_share_wrapper.tsx | 73 ++++ .../components/scheduled_report_form.test.tsx | 178 ++++++++ .../components/scheduled_report_form.tsx | 275 ++++++++++++ .../reporting/public/management/constants.ts | 8 + .../management/hooks/use_default_timezone.ts | 17 + .../hooks/use_get_reporting_health_query.ts | 5 +- .../management/hooks/use_schedule_report.ts | 20 + .../scheduled_report_share_integration.tsx | 60 +++ .../management/mount_management_section.tsx | 5 +- .../public/management/mutation_keys.ts | 11 + .../public/management/report_params.ts | 52 +++ .../schemas/scheduled_report_form_schema.ts | 61 +++ .../stateful/report_listing_stateful.tsx | 32 +- .../test_utils/test_query_client.ts | 23 + .../public/management/translations.ts | 84 +++- .../private/reporting/public/plugin.ts | 15 +- .../private/reporting/public/query_client.ts | 16 + .../plugins/private/reporting/public/types.ts | 9 +- .../create_maintenance_windows_form.tsx | 8 +- 36 files changed, 1530 insertions(+), 905 deletions(-) delete mode 100644 src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_field.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/apis/schedule_report.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/responsive_form_group.tsx delete mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_share_wrapper.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/constants.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/integrations/scheduled_report_share_integration.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/mutation_keys.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/report_params.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/schemas/scheduled_report_form_schema.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/test_utils/test_query_client.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/query_client.ts diff --git a/src/platform/packages/private/kbn-reporting/public/index.ts b/src/platform/packages/private/kbn-reporting/public/index.ts index 9683c3a8fca9b..fdac298b694a8 100644 --- a/src/platform/packages/private/kbn-reporting/public/index.ts +++ b/src/platform/packages/private/kbn-reporting/public/index.ts @@ -17,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'; @@ -34,6 +34,7 @@ export interface KibanaContext { 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..fa7a100c1b2ca 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({ diff --git a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_pdf_png_modal_reporting.tsx b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_pdf_png_modal_reporting.tsx index 146e1a80f7dab..077f25eb6151c 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_pdf_png_modal_reporting.tsx +++ b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_pdf_png_modal_reporting.tsx @@ -14,29 +14,69 @@ import { ShareContext } from '@kbn/share-plugin/public'; import React from 'react'; import { firstValueFrom } from 'rxjs'; import { ExportGenerationOpts, ExportShare } from '@kbn/share-plugin/public/types'; +import { ReportParamsGetter, ReportParamsGetterOptions } from '../../types'; import { ExportModalShareOpts, JobParamsProviderOptions, ReportingSharingData } from '.'; import { checkLicense } from '../../license_check'; -const getJobParams = (opts: JobParamsProviderOptions, type: 'pngV2' | 'printablePdfV2') => () => { - 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/types.ts b/src/platform/packages/private/kbn-reporting/public/types.ts index 756c5e23eb57b..5595006a5bd6d 100644 --- a/src/platform/packages/private/kbn-reporting/public/types.ts +++ b/src/platform/packages/private/kbn-reporting/public/types.ts @@ -11,7 +11,7 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { HomePublicPluginStart } from '@kbn/home-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ManagementStart } from '@kbn/management-plugin/public'; -import type { SharePluginStart } from '@kbn/share-plugin/public'; +import { SharePluginStart } from '@kbn/share-plugin/public'; import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; export interface ReportingPublicPluginStartDependencies { @@ -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/shared/response-ops/recurring-schedule-form/components/recurring_schedule_field.tsx b/src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_field.tsx deleted file mode 100644 index 54fe0172c7bf3..0000000000000 --- a/src/platform/packages/shared/response-ops/recurring-schedule-form/components/recurring_schedule_field.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React, { memo } from 'react'; -import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { FormProps } from '@kbn/alerting-plugin/public/pages/maintenance_windows/components/schema'; -import { RecurringScheduleForm } from './recurring_schedule_form'; -import type { RecurringSchedule } from '../types'; - -export interface RecurringScheduleFieldProps { - path?: string; - startDate?: string; - endDate?: string; - timezone: string[]; - hideTimezone?: boolean; - readOnly?: boolean; - allowInfiniteRecurrence?: boolean; -} - -/** - * An adapter to use the recurring schedule form inside other `hook_form_lib` forms - * - * Define an empty field in your form schema - * ```tsx - * const schema = { - * recurringField: {}, - * }; - * ``` - * and use the RecurringScheduleField component with a `path` corresponding to the field name - * ```tsx - * - * ``` - */ -export const RecurringScheduleField = memo( - ({ - path = 'recurringSchedule', - startDate, - endDate, - timezone, - hideTimezone, - allowInfiniteRecurrence = true, - readOnly = false, - }: RecurringScheduleFieldProps) => { - return ( - path={path}> - {({ value, setValue, setErrors }) => ( - { - setErrors(errors.map((message) => ({ path, message }))); - }} - startDate={startDate} - endDate={endDate} - timezone={timezone} - hideTimezone={hideTimezone} - readOnly={readOnly} - allowInfiniteRecurrence={allowInfiniteRecurrence} - /> - )} - - ); - } -); - -RecurringScheduleField.displayName = 'RecurringScheduleField'; 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..e1d022de97325 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 @@ -39,30 +39,23 @@ 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; endDate?: string; timezone?: string[]; + hideTimezone?: boolean; + supportsEndOptions?: boolean; allowInfiniteRecurrence?: boolean; + readOnly?: boolean; } /** @@ -73,7 +66,10 @@ export const RecurringScheduleFormFields = memo( startDate, endDate, timezone, + hideTimezone = false, + supportsEndOptions = true, allowInfiniteRecurrence = true, + readOnly = false, }: RecurringScheduleFieldsProps) => { const [formData] = useFormData<{ recurringSchedule: RecurringSchedule }>({ watch: [ @@ -99,29 +95,29 @@ export const RecurringScheduleFormFields = memo( return { 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', }, @@ -142,6 +138,7 @@ export const RecurringScheduleFormFields = memo( euiFieldProps: { 'data-test-subj': 'recurringScheduleRepeatSelect', options, + disabled: readOnly, }, }} /> @@ -149,92 +146,113 @@ export const RecurringScheduleFormFields = memo( 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', + euiFieldProps: { + showTimeSelect: false, + minDate: today, + readOnly, + placeholder: i18n.RECURRING_SCHEDULE_FORM_UNTIL_PLACEHOLDER, + }, + }} + /> + + {timezone && !hideTimezone ? ( + + + {i18n.RECURRING_SCHEDULE_FORM_TIMEZONE} + + } + /> + + ) : null} + + + ) : null} + {parsedSchedule?.ends === RecurrenceEnd.AFTER_X ? ( + + {i18n.RECURRING_SCHEDULE_FORM_COUNT_AFTER} - } - /> - - ) : null} - + ), + append: ( + + {i18n.RECURRING_SCHEDULE_FORM_COUNT_OCCURRENCE} + + ), + 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( + {i18n.RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY_PREFIX( recurringSummary(moment(startDate), parsedSchedule, presets) )} 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..d1f1fe1197cc9 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', { 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/plugins/shared/share/public/index.ts b/src/platform/plugins/shared/share/public/index.ts index ef0537f26d205..ed601f2d25a86 100644 --- a/src/platform/plugins/shared/share/public/index.ts +++ b/src/platform/plugins/shared/share/public/index.ts @@ -41,3 +41,6 @@ export type { DownloadableContent } from './lib/download_as'; export function plugin(ctx: PluginInitializerContext) { return new SharePlugin(ctx); } + +import { useShareTypeContext } from './components/context'; +export { useShareTypeContext }; 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 index 6cdc9368ecca4..3d2cb94d46f7d 100644 --- 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 @@ -5,9 +5,23 @@ * 2.0. */ -export const getReportingHealth = () => - Promise.resolve({ - hasPermanentEncryptionKey: true, - isSufficientlySecure: true, - hasEmailConnector: true, - }); +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.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.test.tsx deleted file mode 100644 index c554cc7487ced..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable no-console */ - -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { ScheduledReportFlyout } from './scheduled_report_flyout'; -import * as getReportingHealthModule from '../apis/get_reporting_health'; -import { ReportFormat, ScheduledReport } from '../../types'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -jest.mock('./scheduled_report_flyout_content', () => ({ - ScheduledReportForm: () =>
ScheduledReportForm
, -})); - -const mockScheduledReport: ScheduledReport = { - id: '1', - jobParams: { foo: 'bar' }, -} as any; -const mockFormats: ReportFormat[] = [ - { - id: 'printablePdfV2', - label: 'PDF', - }, - { - id: 'pngV2', - label: 'PNG', - }, - { - id: 'csv_searchsource', - label: 'CSV', - }, -]; -const mockOnClose = jest.fn(); - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - logger: { - log: console.log, - warn: console.warn, - error: () => {}, - }, -}); - -describe('ScheduledReportFlyout', () => { - beforeEach(() => { - jest.clearAllMocks(); - queryClient.clear(); - }); - - it('renders loading state', () => { - jest - .spyOn(getReportingHealthModule, 'getReportingHealth') - .mockImplementation(() => new Promise(() => {})); - - render( - - - - ); - - expect(screen.getByRole('progressbar')).toBeInTheDocument(); - }); - - it('renders error state', async () => { - jest.spyOn(getReportingHealthModule, 'getReportingHealth').mockImplementation(async () => { - throw new Error('Test error'); - }); - - render( - - - - ); - - expect(await screen.findByText('Cannot load reporting health')).toBeInTheDocument(); - }); - - it('renders form with reporting health', async () => { - jest.spyOn(getReportingHealthModule, 'getReportingHealth').mockResolvedValue({ - hasPermanentEncryptionKey: true, - isSufficientlySecure: true, - hasEmailConnector: true, - }); - - render( - - - - ); - - expect(await screen.findByText('ScheduledReportForm')).toBeInTheDocument(); - }); -}); 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 index 30da1ff17a201..5cf81fcc80887 100644 --- 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 @@ -6,65 +6,33 @@ */ import React from 'react'; -import { - EuiCallOut, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiLoadingSpinner, - EuiTitle, -} from '@elastic/eui'; -import { SetRequired } from 'type-fest'; -import { - CANNOT_LOAD_REPORTING_HEALTH_MESSAGE, - CANNOT_LOAD_REPORTING_HEALTH_TITLE, - SCHEDULED_REPORT_FLYOUT_TITLE, -} from '../translations'; -import { ReportFormat, ScheduledReport } from '../../types'; -import { useGetReportingHealthQuery } from '../hooks/use_get_reporting_health_query'; -import { ScheduledReportForm } from './scheduled_report_flyout_content'; +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 { - scheduledReport: SetRequired, 'jobParams'>; - availableFormats: ReportFormat[]; + apiClient: ReportingAPIClient; + scheduledReport: Partial; + availableReportTypes: ReportTypeData[]; onClose: () => void; - readOnly?: boolean; } export const ScheduledReportFlyout = ({ + apiClient, scheduledReport, - availableFormats, + availableReportTypes, onClose, - readOnly = false, }: ScheduledReportFlyoutProps) => { - const { data: reportingHealth, isLoading, isError } = useGetReportingHealthQuery(); - return ( - - -

{SCHEDULED_REPORT_FLYOUT_TITLE}

-
-
- - {isLoading || isError ? ( - - {isLoading && } - {isError && ( - -

{CANNOT_LOAD_REPORTING_HEALTH_MESSAGE}

-
- )} -
- ) : ( - - )} +
); }; 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 index f323676f01889..d66def9b6e63d 100644 --- 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 @@ -5,12 +5,15 @@ * 2.0. */ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { ScheduledReportForm } from './scheduled_report_flyout_content'; -import { ReportFormat, ScheduledReport } from '../../types'; -import { useKibana } from '@kbn/reporting-public'; -import userEvent from '@testing-library/user-event'; +import React, { PropsWithChildren } from 'react'; +import { render, screen } 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'; // Mock Kibana hooks and context jest.mock('@kbn/reporting-public', () => ({ @@ -21,18 +24,54 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({ useUiSetting: () => 'UTC', })); -jest.mock('@kbn/response-ops-recurring-schedule-form/components/recurring_schedule_field', () => ({ - RecurringScheduleField: () =>
, +jest.mock('./scheduled_report_form', () => ({ + ScheduledReportForm: () =>
, })); -describe('ScheduledReportForm', () => { +jest.mock('../apis/get_reporting_health'); +const mockGetReportingHealth = jest.mocked(getReportingHealth); +mockGetReportingHealth.mockResolvedValue({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: true, +}); + +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 mockApiClient = { + getDecoratedJobParams: jest.fn().mockImplementation((params) => params), +} as unknown as ReportingAPIClient; + +const mockOnClose = jest.fn(); + +const TestProviders = ({ children }: PropsWithChildren) => ( + {children} +); + +describe('ScheduledReportFlyoutContent', () => { const scheduledReport = { - jobParams: {}, - fileName: '', - fileType: 'pdf', + title: 'Title', + reportTypeId: 'printablePdfV2', } as ScheduledReport; - const availableFormats: ReportFormat[] = [ + const availableFormats: ReportTypeData[] = [ { id: 'printablePdfV2', label: 'PDF', @@ -47,95 +86,73 @@ describe('ScheduledReportForm', () => { }, ]; - const onClose = jest.fn(); - beforeEach(() => { (useKibana as jest.Mock).mockReturnValue({ services: { - actions: { - validateEmailAddresses: jest.fn().mockResolvedValue([]), - }, + ...coreMock.createStart(), }, }); jest.clearAllMocks(); + testQueryClient.clear(); }); - it('renders form fields', () => { - render( - - ); - - expect(screen.getByText('Report name')).toBeInTheDocument(); - expect(screen.getByText('File type')).toBeInTheDocument(); - expect(screen.getByText('Date')).toBeInTheDocument(); - expect(screen.getByText('Timezone')).toBeInTheDocument(); - expect(screen.getByText('Make recurring')).toBeInTheDocument(); - expect(screen.getByText('Send by email')).toBeInTheDocument(); - }); - - it('shows recurring schedule fields when recurring is enabled', async () => { + it('should not render the flyout footer when the form is in readOnly mode', () => { render( - + + + ); - const toggle = screen.getByText('Make recurring'); - fireEvent.click(toggle); - - expect(await screen.findByTestId('recurring-schedule-field')).toBeInTheDocument(); + expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); }); - it('shows email fields when send by email is enabled', async () => { + it('should show a callout in case of errors while fetching reporting health', async () => { + mockGetReportingHealth.mockRejectedValueOnce({}); render( - + + + ); - const toggle = screen.getByText('Send by email'); - fireEvent.click(toggle); - - expect(await screen.findByText('To')).toBeInTheDocument(); - expect(await screen.findByText('Sensitive information')).toBeInTheDocument(); + expect( + await screen.findByText('Reporting health is a prerequisite to create scheduled exports') + ).toBeInTheDocument(); }); - it('shows warning when email connector is missing', () => { - render( - - ); - - expect(screen.getByText('No email connector configured')).toBeInTheDocument(); - }); - - it('calls onClose when Cancel button is clicked', async () => { + it('should show a callout in case of unmet prerequisites in the reporting health', async () => { + mockGetReportingHealth.mockResolvedValueOnce({ + isSufficientlySecure: false, + hasPermanentEncryptionKey: false, + areNotificationsEnabled: false, + }); render( - + + + ); - const cancelBtn = screen.getByRole('button', { name: 'Cancel' }); - await userEvent.click(cancelBtn); - expect(onClose).toHaveBeenCalled(); + expect(await screen.findByText('Cannot schedule reports')).toBeInTheDocument(); }); }); 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 index 01d90efcde775..2c18317b81d47 100644 --- 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 @@ -5,372 +5,109 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useRef } from 'react'; import { EuiButton, EuiButtonEmpty, EuiCallOut, - EuiDescribedFormGroup, - EuiDescribedFormGroupProps, EuiFlexGroup, EuiFlexItem, EuiFlyoutBody, EuiFlyoutFooter, - EuiFormLabel, - EuiSpacer, + EuiFlyoutHeader, + EuiLoadingSpinner, + EuiTitle, } from '@elastic/eui'; -import moment from 'moment'; -import type { Moment } from 'moment'; -import { useKibana } from '@kbn/reporting-public'; -import { - FIELD_TYPES, - useForm, - getUseField, - Form, - useFormData, - type FormSchema, -} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; -import { css } from '@emotion/react'; -import { TIMEZONE_OPTIONS as UI_TIMEZONE_OPTIONS } from '@kbn/core-ui-settings-common'; -import { useUiSetting } from '@kbn/kibana-react-plugin/public'; -import { RecurringScheduleField } from '@kbn/response-ops-recurring-schedule-form/components/recurring_schedule_field'; -import { SetRequired } from 'type-fest'; -import { getStartDateValidator } from '../validators/start_date_validator'; +import { ReportingAPIClient, useKibana } from '@kbn/reporting-public'; +import type { ReportingSharingData } from '@kbn/reporting-public/share/share_context_menu'; +import { SCHEDULED_REPORT_FORM_ID } from '../constants'; import { + CANNOT_LOAD_REPORTING_HEALTH_MESSAGE, + CANNOT_LOAD_REPORTING_HEALTH_TITLE, SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL, SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL, - SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION, - SCHEDULED_REPORT_FORM_FILE_NAME_LABEL, - SCHEDULED_REPORT_FORM_FILE_TYPE_LABEL, - SCHEDULED_REPORT_FORM_FILE_NAME_REQUIRED_MESSAGE, - SCHEDULED_REPORT_FORM_FILE_NAME_SUFFIX, - SCHEDULED_REPORT_FORM_RECURRING_LABEL, - SCHEDULED_REPORT_FORM_SEND_BY_EMAIL_LABEL, - SCHEDULED_REPORT_FORM_START_DATE_LABEL, - SCHEDULED_REPORT_FORM_TIMEZONE_LABEL, - SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_LABEL, - SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_HINT, - SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_TITLE, - SCHEDULED_REPORT_FORM_FILE_TYPE_REQUIRED_MESSAGE, - SCHEDULED_REPORT_FORM_START_DATE_REQUIRED_MESSAGE, - SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_REQUIRED_MESSAGE, - SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE, - SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE, - SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE, - SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_TITLE, + SCHEDULED_REPORT_FLYOUT_TITLE, + UNMET_REPORTING_PREREQUISITES_MESSAGE, + UNMET_REPORTING_PREREQUISITES_TITLE, } from '../translations'; -import { ReportFormat, ScheduledReport } from '../../types'; -import { getEmailsValidator } from '../validators/emails_validator'; - -export const toMoment = (value: string): Moment => moment(value); -export const toString = (value: Moment): string => value.toISOString(); - -const FormField = getUseField({ - component: Field, -}); - -const { emptyField } = fieldValidators; +import { ReportTypeData, ScheduledReport } from '../../types'; +import { useGetReportingHealthQuery } from '../hooks/use_get_reporting_health_query'; +import { ScheduledReportForm, ScheduledReportFormImperativeApi } from './scheduled_report_form'; -export interface ScheduledReportFormProps { - scheduledReport: SetRequired, 'jobParams'>; - availableFormats: ReportFormat[]; +export interface ScheduledReportFlyoutContentProps { + apiClient: ReportingAPIClient; + objectType?: string; + sharingData?: ReportingSharingData; + scheduledReport: Partial; + availableReportTypes?: ReportTypeData[]; onClose: () => void; readOnly?: boolean; - hasEmailConnector?: boolean; } -const TIMEZONE_OPTIONS = UI_TIMEZONE_OPTIONS.map((tz) => ({ - inputDisplay: tz, - value: tz, -})) ?? [{ text: 'UTC', value: 'UTC' }]; - -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 ; -}; - -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 }; -}; - -const formId = 'scheduledReportForm.recurringScheduleForm'; - -export const ScheduledReportForm = ({ +export const ScheduledReportFlyoutContent = ({ + apiClient, + objectType, + sharingData, scheduledReport, - availableFormats, + availableReportTypes, onClose, - hasEmailConnector, readOnly = false, -}: ScheduledReportFormProps) => { +}: ScheduledReportFlyoutContentProps) => { + const { http } = useKibana().services; const { - services: { - actions: { validateEmailAddresses }, - }, - } = useKibana(); + data: reportingHealth, + isLoading: isReportingHealthLoading, + isError: isReportingHealthError, + } = useGetReportingHealthQuery({ http }); + const formRef = useRef(null); - const { defaultTimezone } = useDefaultTimezone(); - const today = useMemo(() => moment().tz(defaultTimezone), [defaultTimezone]); - const defaultStartDateValue = useMemo(() => today.toISOString(), [today]); - const schema = useMemo>( - () => ({ - fileName: { - type: FIELD_TYPES.TEXT, - label: SCHEDULED_REPORT_FORM_FILE_NAME_LABEL, - validations: [ - { - validator: emptyField(SCHEDULED_REPORT_FORM_FILE_NAME_REQUIRED_MESSAGE), - }, - ], - }, - fileType: { - type: FIELD_TYPES.SUPER_SELECT, - label: SCHEDULED_REPORT_FORM_FILE_TYPE_LABEL, - defaultValue: availableFormats[0].id, - validations: [ - { - validator: emptyField(SCHEDULED_REPORT_FORM_FILE_TYPE_REQUIRED_MESSAGE), - }, - ], - }, - startDate: { - type: FIELD_TYPES.DATE_PICKER, - label: SCHEDULED_REPORT_FORM_START_DATE_LABEL, - defaultValue: defaultStartDateValue, - serializer: toString, - deserializer: toMoment, - validations: [ - { - validator: emptyField(SCHEDULED_REPORT_FORM_START_DATE_REQUIRED_MESSAGE), - }, - { - validator: getStartDateValidator(today), - }, - ], - }, - timezone: { - type: FIELD_TYPES.SUPER_SELECT, - defaultValue: defaultTimezone, - validations: [ - { - validator: emptyField(SCHEDULED_REPORT_FORM_START_DATE_REQUIRED_MESSAGE), - }, - ], - }, - recurring: { - type: FIELD_TYPES.TOGGLE, - label: SCHEDULED_REPORT_FORM_RECURRING_LABEL, - defaultValue: false, - }, - recurringSchedule: {}, - sendByEmail: { - type: FIELD_TYPES.TOGGLE, - label: SCHEDULED_REPORT_FORM_SEND_BY_EMAIL_LABEL, - defaultValue: false, - }, - emailRecipients: { - type: FIELD_TYPES.COMBO_BOX, - label: SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_LABEL, - defaultValue: [], - validations: [ - { - validator: emptyField(SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_REQUIRED_MESSAGE), - }, - { - isBlocking: false, - validator: getEmailsValidator(validateEmailAddresses), - }, - ], - }, - }), - [availableFormats, defaultStartDateValue, defaultTimezone, today, validateEmailAddresses] - ); - const { form } = useForm({ - defaultValue: scheduledReport, - options: { stripEmptyFields: true }, - schema, - onSubmit: async () => { - // TODO create schedule + const onSubmit = async () => { + const submit = formRef.current?.submit; + if (!submit) { + return; + } + try { + await submit(); onClose(); - }, - }); - const [{ recurring, startDate, timezone, sendByEmail }] = useFormData({ - form, - watch: ['recurring', 'startDate', 'timezone', 'sendByEmail'], - }); - - const submitForm = async () => { - if (await form.validate()) { - await form.submit(); + } catch (e) { + // A validation error occurred } }; - const isRecurring = recurring || false; - const isEmailActive = sendByEmail || false; + const hasUnmetPrerequisites = + !reportingHealth?.isSufficientlySecure || !reportingHealth?.hasPermanentEncryptionKey; return ( <> + + +

{SCHEDULED_REPORT_FLYOUT_TITLE}

+
+
-
- {SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE}}> - - ({ inputDisplay: f.label, value: f.id })), - readOnly, - }, - }} - /> - - {SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE}}> - - - {SCHEDULED_REPORT_FORM_TIMEZONE_LABEL} - - ), - readOnly, - }, - }} - /> - - {isRecurring && ( - <> - - - - )} - - {SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE}} - description={

{SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION}

} - > - - {!hasEmailConnector && ( - <> - - -

Missing email connector message

-
- - )} - {isEmailActive && ( - <> - - - - -

Sensitive info warning text

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

{CANNOT_LOAD_REPORTING_HEALTH_MESSAGE}

+
+ ) : hasUnmetPrerequisites ? ( + +

{UNMET_REPORTING_PREREQUISITES_MESSAGE}

+
+ ) : ( + + )}
- {!readOnly && ( @@ -380,7 +117,13 @@ export const ScheduledReportForm = ({ - + {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..aa53f28098630 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_share_wrapper.tsx @@ -0,0 +1,73 @@ +/* + * 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 { queryClient } from '../../query_client'; +import type { ReportingPublicPluginSetupDependencies } from '../../plugin'; +import { ScheduledReportFlyoutContent } from './scheduled_report_flyout_content'; + +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.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/components/scheduled_report_form.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.test.tsx new file mode 100644 index 0000000000000..e41c3649a3836 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.test.tsx @@ -0,0 +1,178 @@ +/* + * 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 { render, screen, fireEvent } from '@testing-library/react'; +import { type ReportingAPIClient, useKibana } from '@kbn/reporting-public'; +import { ScheduledReportForm } from './scheduled_report_form'; +import { ReportTypeData, ScheduledReport } from '../../types'; +import { scheduleReport } from '../apis/schedule_report'; +import { coreMock } from '@kbn/core/public/mocks'; +import { testQueryClient } from '../test_utils/test_query_client'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ScheduledReportApiJSON } from '../../../server/types'; + +// 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/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 mockApiClient = { + getDecoratedJobParams: jest.fn().mockImplementation((params) => params), +} as unknown as ReportingAPIClient; + +const TestProviders = ({ children }: PropsWithChildren) => ( + {children} +); + +describe('ScheduledReportForm', () => { + const scheduledReport = { + title: 'Title', + reportTypeId: 'printablePdfV2', + } as ScheduledReport; + + const availableFormats: ReportTypeData[] = [ + { + id: 'printablePdfV2', + label: 'PDF', + }, + { + id: 'pngV2', + label: 'PNG', + }, + { + id: 'csv_searchsource', + label: 'CSV', + }, + ]; + + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + ...coreMock.createStart(), + actions: { + validateEmailAddresses: jest.fn().mockResolvedValue([]), + }, + }, + }); + jest.clearAllMocks(); + testQueryClient.clear(); + }); + + it('renders form fields', () => { + render( + + + + ); + + expect(screen.getByText('Report name')).toBeInTheDocument(); + expect(screen.getByText('File type')).toBeInTheDocument(); + expect(screen.getByText('Send by email')).toBeInTheDocument(); + }); + + it('shows email fields when send by email is enabled', async () => { + render( + + + + ); + + const toggle = screen.getByText('Send by email'); + fireEvent.click(toggle); + + expect(await screen.findByText('To')).toBeInTheDocument(); + expect(await screen.findByText('Sensitive information')).toBeInTheDocument(); + }); + + it('shows warning when email connector is missing', () => { + render( + + + + ); + + expect(screen.getByText("Email connector hasn't been created yet")).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.tsx new file mode 100644 index 0000000000000..71dbd1c467f95 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.tsx @@ -0,0 +1,275 @@ +/* + * 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, { forwardRef, useImperativeHandle, useMemo } from 'react'; +import { EuiCallOut, EuiFlexGroup, EuiLink, EuiSpacer } from '@elastic/eui'; +import moment from 'moment'; +import type { Moment } from 'moment'; +import { type ReportingAPIClient, useKibana } from '@kbn/reporting-public'; +import { + useForm, + getUseField, + Form, + useFormData, + type FormSchema, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { RecurringScheduleFormFields } from '@kbn/response-ops-recurring-schedule-form/components/recurring_schedule_form_fields'; +import { REPORTING_MANAGEMENT_HOME } from '@kbn/reporting-common'; +import { convertToRRule } from '@kbn/response-ops-recurring-schedule-form/utils/convert_to_rrule'; +import type { Rrule } from '@kbn/task-manager-plugin/server/task'; +import type { ReportingSharingData } from '@kbn/reporting-public/share/share_context_menu'; +import { mountReactNode } from '@kbn/core-mount-utils-browser-internal'; +import { getScheduledReportFormSchema } from '../schemas/scheduled_report_form_schema'; +import { useDefaultTimezone } from '../hooks/use_default_timezone'; +import { useScheduleReport } from '../hooks/use_schedule_report'; +import * as i18n from '../translations'; +import { ReportTypeData, ScheduledReport } from '../../types'; +import { getReportParams } from '../report_params'; +import { SCHEDULED_REPORT_FORM_ID } from '../constants'; +import { ResponsiveFormGroup } from './responsive_form_group'; + +export const toMoment = (value: string): Moment => moment(value); +export const toString = (value: Moment): string => value.toISOString(); + +const FormField = getUseField({ + component: Field, +}); + +export interface ScheduledReportFormProps { + apiClient: ReportingAPIClient; + sharingData?: ReportingSharingData; + scheduledReport: Partial; + availableReportTypes?: ReportTypeData[]; + readOnly?: boolean; + hasEmailConnector?: boolean; + objectType?: string; +} + +export type FormData = Pick< + ScheduledReport, + 'title' | 'reportTypeId' | 'recurringSchedule' | 'sendByEmail' | 'emailRecipients' +>; + +export interface ScheduledReportFormImperativeApi { + submit: () => Promise; +} + +export const ScheduledReportForm = forwardRef< + ScheduledReportFormImperativeApi, + ScheduledReportFormProps +>( + ( + { + apiClient, + sharingData, + scheduledReport, + availableReportTypes, + hasEmailConnector, + objectType, + readOnly = false, + }, + ref + ) => { + if (!readOnly && (!objectType || !sharingData)) { + throw new Error('Cannot schedule an export without an objectType or sharingData'); + } + const { + services: { + http, + actions: { validateEmailAddresses }, + notifications: { toasts }, + }, + } = useKibana(); + const reportingPageLink = useMemo( + () => ( + + {i18n.REPORTING_PAGE_LINK_TEXT} + + ), + [http.basePath] + ); + const { mutateAsync: scheduleReport } = useScheduleReport({ http }); + const { defaultTimezone } = useDefaultTimezone(); + const now = useMemo(() => moment().tz(defaultTimezone), [defaultTimezone]); + const defaultStartDateValue = useMemo(() => now.toISOString(), [now]); + const schema = useMemo>( + () => getScheduledReportFormSchema(validateEmailAddresses, availableReportTypes), + [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 { reportTypeId, recurringSchedule } = 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: formData.title, + reportTypeId: formData.reportTypeId, + }), + schedule: { rrule: rrule as Rrule }, + notification: formData.sendByEmail + ? { email: { to: formData.emailRecipients } } + : undefined, + }); + toasts.addSuccess({ + title: i18n.SCHEDULED_REPORT_FORM_SUCCESS_TOAST_TITLE, + text: mountReactNode( + <> + {i18n.SCHEDULED_REPORT_FORM_SUCCESS_TOAST_MESSAGE} {reportingPageLink}. + + ), + }); + } catch (error) { + toasts.addError(error, { + title: i18n.SCHEDULED_REPORT_FORM_FAILURE_TOAST_TITLE, + toastMessage: i18n.SCHEDULED_REPORT_FORM_FAILURE_TOAST_MESSAGE, + }); + } + }, + }); + const [{ sendByEmail }] = useFormData({ + form, + watch: ['sendByEmail'], + }); + + const submit = async () => { + const isValid = await form.validate(); + if (!isValid) { + throw new Error('Form validation failed'); + } + await form.submit(); + }; + + useImperativeHandle(ref, () => ({ + submit, + })); + + const isRecurring = recurring || false; + const isEmailActive = sendByEmail || false; + + return ( +
+ {i18n.SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE}}> + + ({ inputDisplay: f.label, value: f.id })) ?? [], + readOnly, + }, + }} + /> + + {i18n.SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE}}> + {isRecurring && ( + + )} + + {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE}} + description={ +

+ {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION} {reportingPageLink}. +

+ } + > + + {hasEmailConnector ? ( + isEmailActive && ( + <> + + + + +

{i18n.SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_MESSAGE}

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

{i18n.SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_MESSAGE}

+
+ + )} +
+
+ ); + } +); 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 index 2edbf2114c59e..13e2326138b8a 100644 --- 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 @@ -6,14 +6,15 @@ */ 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 = () => { +export const useGetReportingHealthQuery = ({ http }: { http: HttpSetup }) => { return useQuery({ queryKey: getKey(), - queryFn: getReportingHealth, + 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..b2e561e1b708d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/integrations/scheduled_report_share_integration.tsx @@ -0,0 +1,60 @@ +/* + * 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 { ScheduledReportFlyoutShareWrapper } from '../components/scheduled_report_flyout_share_wrapper'; +import { SCHEDULE_EXPORT_BUTTON_LABEL } from '../translations'; +import type { ReportingPublicPluginSetupDependencies } from '../../plugin'; + +export interface CreateScheduledReportProviderOptions { + apiClient: ReportingAPIClient; + services: ReportingPublicPluginSetupDependencies; +} + +export const createScheduledReportShareIntegration = ({ + apiClient, + services, +}: CreateScheduledReportProviderOptions): ExportShareDerivatives => { + return { + id: 'scheduledReports', + groupId: 'exportDerivatives', + shareType: 'integration', + config: ({ + objectType, + objectId, + isDirty, + onClose, + shareableUrl, + shareableUrlForSavedObject, + ...shareOpts + }: ShareContext): ReturnType => { + const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData }; + return { + label: ({ openFlyout }) => ( + + {SCHEDULE_EXPORT_BUTTON_LABEL} + + ), + flyoutContent: ({ closeFlyout }) => { + return ( + + ); + }, + }; + }, + }; +}; 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 3deac347b5063..85a84e1ab51fe 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 @@ -22,12 +22,11 @@ import { KibanaContext, } from '@kbn/reporting-public'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from '../query_client'; import { ReportListing } from '.'; import { PolicyStatusContextProvider } from '../lib/default_status_context'; -const queryClient = new QueryClient(); - export async function mountManagementSection({ coreStart, license$, 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/report_params.ts b/x-pack/platform/plugins/private/reporting/public/management/report_params.ts new file mode 100644 index 0000000000000..8d27e30f57f8a --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/report_params.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import 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 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 f021c60b03271..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 @@ -5,10 +5,9 @@ * 2.0. */ -import React, { FC, useState } from 'react'; +import React, { FC } from 'react'; import { - EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, @@ -21,22 +20,6 @@ import { ListingPropsInternal } from '..'; import { useIlmPolicyStatus } from '../../lib/ilm_policy_status_context'; import { IlmPolicyLink, MigrateIlmPolicyCallOut, ReportDiagnostic } from '../components'; import { ReportListingTable } from '../report_listing_table'; -import { ScheduledReportFlyout } from '../components/scheduled_report_flyout'; - -const formats = [ - { - id: 'printablePdfV2', - label: 'PDF', - }, - { - id: 'pngV2', - label: 'PNG', - }, - { - id: 'csv_searchsource', - label: 'CSV', - }, -]; /** * Used in Stateful deployments only @@ -49,17 +32,9 @@ export const ReportListingStateful: FC = (props) => { const ilmPolicyContextValue = useIlmPolicyStatus(); const hasIlmPolicy = ilmPolicyContextValue?.status !== 'policy-not-found'; const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); - const [scheduledReportFlyoutOpen, setScheduledReportFlyoutOpen] = useState(false); return ( <> - {scheduledReportFlyoutOpen && ( - setScheduledReportFlyoutOpen(false)} - scheduledReport={{ jobParams: '' }} - /> - )} = (props) => { defaultMessage="Get reports generated in Kibana applications." /> } - rightSideItems={[ - setScheduledReportFlyoutOpen(true)}> - Schedule export - , - ]} /> diff --git a/x-pack/platform/plugins/private/reporting/public/management/test_utils/test_query_client.ts b/x-pack/platform/plugins/private/reporting/public/management/test_utils/test_query_client.ts new file mode 100644 index 0000000000000..f6d918109ad31 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/test_utils/test_query_client.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable no-console */ + +import { QueryClient } from '@tanstack/react-query'; + +export const testQueryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + logger: { + log: console.log, + warn: console.warn, + error: () => {}, + }, +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/translations.ts b/x-pack/platform/plugins/private/reporting/public/management/translations.ts index 036d2c855c58f..0c528ed50515e 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/translations.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/translations.ts @@ -7,6 +7,13 @@ 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', { @@ -121,7 +128,14 @@ export const SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION = i18n.translate( 'xpack.reporting.scheduledReportingForm.exportsSectionDescription', { defaultMessage: - "On the scheduled date we'll create a snapshot and list it in Stack Management > Reporting for download", + "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', } ); @@ -157,14 +171,21 @@ export const SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_HINT = i18n.translate( 'xpack.reporting.scheduledReportingForm.emailRecipientsHint', { defaultMessage: - "On the scheduled date we'll send an email to these recipients with the file attached", + "On the scheduled date, we'll also email the report to the addresses you specify below.", } ); export const SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_TITLE = i18n.translate( 'xpack.reporting.scheduledReportingForm.missingEmailConnectorTitle', { - defaultMessage: 'No email connector configured', + 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.', } ); @@ -175,6 +196,48 @@ export const SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_TITLE = i18n.translate( } ); +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', { @@ -182,6 +245,21 @@ export const CANNOT_LOAD_REPORTING_HEALTH_TITLE = i18n.translate( } ); +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', { diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 2c2f9b5a7c031..17c7ae2c7c604 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'; @@ -31,6 +36,7 @@ import { import { ReportingCsvPanelAction } from '@kbn/reporting-csv-share-panel'; import { InjectedIntl } from '@kbn/i18n-react'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import { createScheduledReportShareIntegration } from './management/integrations/scheduled_report_share_integration'; import type { ReportingSetup, ReportingStart } from '.'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; import { StartServices } from './types'; @@ -238,6 +244,13 @@ export class ReportingPublicPlugin ); } + 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 dab7083c94b15..b5ce5bbe17267 100644 --- a/x-pack/platform/plugins/private/reporting/public/types.ts +++ b/x-pack/platform/plugins/private/reporting/public/types.ts @@ -51,10 +51,11 @@ export interface JobSummarySet { failed?: JobSummary[]; } +export type ReportTypeId = 'pngV2' | 'printablePdfV2' | 'csv_searchsource'; + export interface ScheduledReport { - jobParams: string; - fileName: string; - fileType: string; + title: string; + reportTypeId: ReportTypeId; startDate: string; timezone: string; recurring: boolean; @@ -63,7 +64,7 @@ export interface ScheduledReport { emailRecipients: string[]; } -export interface ReportFormat { +export interface ReportTypeData { label: string; id: string; } 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 Date: Wed, 18 Jun 2025 10:24:24 +0200 Subject: [PATCH 03/30] Fix type errors --- src/platform/packages/private/kbn-reporting/public/index.ts | 2 +- .../packages/private/kbn-reporting/public/tsconfig.json | 1 + .../reporting/public/management/mount_management_section.tsx | 5 ++++- x-pack/platform/plugins/private/reporting/public/plugin.ts | 1 + x-pack/platform/plugins/private/reporting/tsconfig.json | 3 +++ 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/platform/packages/private/kbn-reporting/public/index.ts b/src/platform/packages/private/kbn-reporting/public/index.ts index fdac298b694a8..aa7212aa831e6 100644 --- a/src/platform/packages/private/kbn-reporting/public/index.ts +++ b/src/platform/packages/private/kbn-reporting/public/index.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; export type { ClientConfigType } from './types'; export { Job } from './job'; 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/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 85a84e1ab51fe..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'; @@ -36,6 +36,7 @@ export async function mountManagementSection({ apiClient, params, actionsService, + notificationsService, }: { coreStart: CoreStart; license$: LicensingPluginStart['license$']; @@ -45,6 +46,7 @@ export async function mountManagementSection({ apiClient: ReportingAPIClient; params: ManagementAppMountParams; actionsService: ActionsPublicPluginSetup; + notificationsService: NotificationsStart; }) { const services: KibanaContext = { http: coreStart.http, @@ -55,6 +57,7 @@ export async function mountManagementSection({ data: dataService, share: shareService, actions: actionsService, + notifications: notificationsService, }; render( diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 17c7ae2c7c604..8b21b28468dad 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -175,6 +175,7 @@ export class ReportingPublicPlugin apiClient, params, actionsService: actionsSetup, + notificationsService: coreStart.notifications, }); return () => { 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/**/*", From 0ddb6ead1fe7911ee323d76130975d4f68d08b72 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Wed, 18 Jun 2025 11:33:37 +0200 Subject: [PATCH 04/30] Fix translations, reduce page load bundle size --- .../private/kbn-reporting/public/types.ts | 2 +- .../reporting/public/management/translations.ts | 4 ++-- .../plugins/private/reporting/public/plugin.ts | 15 +++++++++------ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/platform/packages/private/kbn-reporting/public/types.ts b/src/platform/packages/private/kbn-reporting/public/types.ts index 5595006a5bd6d..1e90e3c677a42 100644 --- a/src/platform/packages/private/kbn-reporting/public/types.ts +++ b/src/platform/packages/private/kbn-reporting/public/types.ts @@ -11,7 +11,7 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { HomePublicPluginStart } from '@kbn/home-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ManagementStart } from '@kbn/management-plugin/public'; -import { SharePluginStart } from '@kbn/share-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; export interface ReportingPublicPluginStartDependencies { diff --git a/x-pack/platform/plugins/private/reporting/public/management/translations.ts b/x-pack/platform/plugins/private/reporting/public/management/translations.ts index 0c528ed50515e..3d310ed1aa468 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/translations.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/translations.ts @@ -268,14 +268,14 @@ export const CANNOT_LOAD_REPORTING_HEALTH_MESSAGE = i18n.translate( ); export function getInvalidEmailAddress(email: string) { - return i18n.translate('xpack.stackConnectors.components.email.error.invalidEmail', { + 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.stackConnectors.components.email.error.notAllowed', { + 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/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 8b21b28468dad..9dd9acf87a59a 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -36,7 +36,6 @@ import { import { ReportingCsvPanelAction } from '@kbn/reporting-csv-share-panel'; import { InjectedIntl } from '@kbn/i18n-react'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; -import { createScheduledReportShareIntegration } from './management/integrations/scheduled_report_share_integration'; import type { ReportingSetup, ReportingStart } from '.'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; import { StartServices } from './types'; @@ -245,11 +244,15 @@ export class ReportingPublicPlugin ); } - shareSetup.registerShareIntegration( - createScheduledReportShareIntegration({ - apiClient, - services: { ...core, ...setupDeps }, - }) + import('./management/integrations/scheduled_report_share_integration').then( + ({ createScheduledReportShareIntegration }) => { + shareSetup.registerShareIntegration( + createScheduledReportShareIntegration({ + apiClient, + services: { ...core, ...setupDeps }, + }) + ); + } ); this.startServices$ = startServices$; From 5914e610f684e0a9dcc5846c037d3ebff6f5046c Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Wed, 18 Jun 2025 12:54:34 +0200 Subject: [PATCH 05/30] Switch back to form in flyout content, add tests --- .../scheduled_report_flyout_content.test.tsx | 246 ++++++++++++++-- .../scheduled_report_flyout_content.tsx | 275 +++++++++++++++--- .../components/scheduled_report_form.test.tsx | 178 ------------ .../components/scheduled_report_form.tsx | 275 ------------------ .../public/management/translations.ts | 2 +- 5 files changed, 460 insertions(+), 516 deletions(-) delete mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.test.tsx delete mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.tsx 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 index d66def9b6e63d..8375c8ca3702e 100644 --- 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 @@ -6,7 +6,7 @@ */ import React, { PropsWithChildren } from 'react'; -import { render, screen } from '@testing-library/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'; @@ -14,6 +14,9 @@ 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', () => ({ @@ -24,9 +27,12 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({ useUiSetting: () => 'UTC', })); -jest.mock('./scheduled_report_form', () => ({ - ScheduledReportForm: () =>
, -})); +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); @@ -36,6 +42,32 @@ mockGetReportingHealth.mockResolvedValue({ 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', @@ -54,6 +86,24 @@ const sharingData = { }, }, }; +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), @@ -65,31 +115,21 @@ const TestProviders = ({ children }: PropsWithChildren) => ( {children} ); -describe('ScheduledReportFlyoutContent', () => { - 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 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: { - ...coreMock.createStart(), + ...coreServices, + actions: { + validateEmailAddresses: mockValidateEmailAddresses, + }, }, }); jest.clearAllMocks(); @@ -155,4 +195,160 @@ describe('ScheduledReportFlyoutContent', () => { 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 index 2c18317b81d47..3550f3d5c16c6 100644 --- 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 @@ -5,7 +5,8 @@ * 2.0. */ -import React, { useRef } from 'react'; +import React, { useMemo } from 'react'; +import moment from 'moment'; import { EuiButton, EuiButtonEmpty, @@ -15,24 +16,44 @@ import { 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 { SCHEDULED_REPORT_FORM_ID } from '../constants'; +import { REPORTING_MANAGEMENT_HOME } from '@kbn/reporting-common'; import { - CANNOT_LOAD_REPORTING_HEALTH_MESSAGE, - CANNOT_LOAD_REPORTING_HEALTH_TITLE, - SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL, - SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL, - SCHEDULED_REPORT_FLYOUT_TITLE, - UNMET_REPORTING_PREREQUISITES_MESSAGE, - UNMET_REPORTING_PREREQUISITES_TITLE, -} from '../translations'; -import { ReportTypeData, ScheduledReport } from '../../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 { 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 { ScheduledReportForm, ScheduledReportFormImperativeApi } from './scheduled_report_form'; +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' +>; export interface ScheduledReportFlyoutContentProps { apiClient: ReportingAPIClient; @@ -53,24 +74,99 @@ export const ScheduledReportFlyoutContent = ({ onClose, readOnly = false, }: ScheduledReportFlyoutContentProps) => { - const { http } = useKibana().services; + 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 formRef = useRef(null); + const reportingPageLink = useMemo( + () => ( + + {i18n.REPORTING_PAGE_LINK_TEXT} + + ), + [http.basePath] + ); + const { mutateAsync: scheduleReport } = useScheduleReport({ http }); + const { defaultTimezone } = useDefaultTimezone(); + const now = useMemo(() => moment().tz(defaultTimezone), [defaultTimezone]); + const defaultStartDateValue = useMemo(() => now.toISOString(), [now]); + const schema = useMemo>( + () => getScheduledReportFormSchema(validateEmailAddresses, availableReportTypes), + [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 { reportTypeId, recurringSchedule } = 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: formData.title, + reportTypeId: formData.reportTypeId, + }), + schedule: { rrule: rrule as Rrule }, + notification: formData.sendByEmail + ? { email: { to: formData.emailRecipients } } + : undefined, + }); + toasts.addSuccess({ + title: i18n.SCHEDULED_REPORT_FORM_SUCCESS_TOAST_TITLE, + text: mountReactNode( + <> + {i18n.SCHEDULED_REPORT_FORM_SUCCESS_TOAST_MESSAGE} {reportingPageLink}. + + ), + }); + } catch (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 [{ sendByEmail }] = useFormData({ + form, + watch: ['sendByEmail'], + }); + + const isRecurring = recurring || false; + const isEmailActive = sendByEmail || false; const onSubmit = async () => { - const submit = formRef.current?.submit; - if (!submit) { - return; - } try { - await submit(); + await form.submit(); onClose(); } catch (e) { - // A validation error occurred + // Don't close the flyout in case of errors } }; @@ -81,31 +177,135 @@ export const ScheduledReportFlyoutContent = ({ <> -

{SCHEDULED_REPORT_FLYOUT_TITLE}

+

{i18n.SCHEDULED_REPORT_FLYOUT_TITLE}

{isReportingHealthLoading ? ( ) : isReportingHealthError ? ( - -

{CANNOT_LOAD_REPORTING_HEALTH_MESSAGE}

+ +

{i18n.CANNOT_LOAD_REPORTING_HEALTH_MESSAGE}

) : hasUnmetPrerequisites ? ( - -

{UNMET_REPORTING_PREREQUISITES_MESSAGE}

+ +

{i18n.UNMET_REPORTING_PREREQUISITES_MESSAGE}

) : ( - +
+ {i18n.SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE}} + > + + ({ inputDisplay: f.label, value: f.id })) ?? + [], + readOnly, + }, + }} + /> + + {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 && ( @@ -113,7 +313,7 @@ export const ScheduledReportFlyoutContent = ({ - {SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL} + {i18n.SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL} @@ -122,9 +322,10 @@ export const ScheduledReportFlyoutContent = ({ form={SCHEDULED_REPORT_FORM_ID} isDisabled={false} onClick={onSubmit} + isLoading={form.isSubmitting} fill > - {SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL} + {i18n.SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL} diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.test.tsx deleted file mode 100644 index e41c3649a3836..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.test.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { PropsWithChildren } from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { type ReportingAPIClient, useKibana } from '@kbn/reporting-public'; -import { ScheduledReportForm } from './scheduled_report_form'; -import { ReportTypeData, ScheduledReport } from '../../types'; -import { scheduleReport } from '../apis/schedule_report'; -import { coreMock } from '@kbn/core/public/mocks'; -import { testQueryClient } from '../test_utils/test_query_client'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { ScheduledReportApiJSON } from '../../../server/types'; - -// 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/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 mockApiClient = { - getDecoratedJobParams: jest.fn().mockImplementation((params) => params), -} as unknown as ReportingAPIClient; - -const TestProviders = ({ children }: PropsWithChildren) => ( - {children} -); - -describe('ScheduledReportForm', () => { - const scheduledReport = { - title: 'Title', - reportTypeId: 'printablePdfV2', - } as ScheduledReport; - - const availableFormats: ReportTypeData[] = [ - { - id: 'printablePdfV2', - label: 'PDF', - }, - { - id: 'pngV2', - label: 'PNG', - }, - { - id: 'csv_searchsource', - label: 'CSV', - }, - ]; - - beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - ...coreMock.createStart(), - actions: { - validateEmailAddresses: jest.fn().mockResolvedValue([]), - }, - }, - }); - jest.clearAllMocks(); - testQueryClient.clear(); - }); - - it('renders form fields', () => { - render( - - - - ); - - expect(screen.getByText('Report name')).toBeInTheDocument(); - expect(screen.getByText('File type')).toBeInTheDocument(); - expect(screen.getByText('Send by email')).toBeInTheDocument(); - }); - - it('shows email fields when send by email is enabled', async () => { - render( - - - - ); - - const toggle = screen.getByText('Send by email'); - fireEvent.click(toggle); - - expect(await screen.findByText('To')).toBeInTheDocument(); - expect(await screen.findByText('Sensitive information')).toBeInTheDocument(); - }); - - it('shows warning when email connector is missing', () => { - render( - - - - ); - - expect(screen.getByText("Email connector hasn't been created yet")).toBeInTheDocument(); - }); -}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.tsx deleted file mode 100644 index 71dbd1c467f95..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_form.tsx +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { forwardRef, useImperativeHandle, useMemo } from 'react'; -import { EuiCallOut, EuiFlexGroup, EuiLink, EuiSpacer } from '@elastic/eui'; -import moment from 'moment'; -import type { Moment } from 'moment'; -import { type ReportingAPIClient, useKibana } from '@kbn/reporting-public'; -import { - useForm, - getUseField, - Form, - useFormData, - type FormSchema, -} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import { RecurringScheduleFormFields } from '@kbn/response-ops-recurring-schedule-form/components/recurring_schedule_form_fields'; -import { REPORTING_MANAGEMENT_HOME } from '@kbn/reporting-common'; -import { convertToRRule } from '@kbn/response-ops-recurring-schedule-form/utils/convert_to_rrule'; -import type { Rrule } from '@kbn/task-manager-plugin/server/task'; -import type { ReportingSharingData } from '@kbn/reporting-public/share/share_context_menu'; -import { mountReactNode } from '@kbn/core-mount-utils-browser-internal'; -import { getScheduledReportFormSchema } from '../schemas/scheduled_report_form_schema'; -import { useDefaultTimezone } from '../hooks/use_default_timezone'; -import { useScheduleReport } from '../hooks/use_schedule_report'; -import * as i18n from '../translations'; -import { ReportTypeData, ScheduledReport } from '../../types'; -import { getReportParams } from '../report_params'; -import { SCHEDULED_REPORT_FORM_ID } from '../constants'; -import { ResponsiveFormGroup } from './responsive_form_group'; - -export const toMoment = (value: string): Moment => moment(value); -export const toString = (value: Moment): string => value.toISOString(); - -const FormField = getUseField({ - component: Field, -}); - -export interface ScheduledReportFormProps { - apiClient: ReportingAPIClient; - sharingData?: ReportingSharingData; - scheduledReport: Partial; - availableReportTypes?: ReportTypeData[]; - readOnly?: boolean; - hasEmailConnector?: boolean; - objectType?: string; -} - -export type FormData = Pick< - ScheduledReport, - 'title' | 'reportTypeId' | 'recurringSchedule' | 'sendByEmail' | 'emailRecipients' ->; - -export interface ScheduledReportFormImperativeApi { - submit: () => Promise; -} - -export const ScheduledReportForm = forwardRef< - ScheduledReportFormImperativeApi, - ScheduledReportFormProps ->( - ( - { - apiClient, - sharingData, - scheduledReport, - availableReportTypes, - hasEmailConnector, - objectType, - readOnly = false, - }, - ref - ) => { - if (!readOnly && (!objectType || !sharingData)) { - throw new Error('Cannot schedule an export without an objectType or sharingData'); - } - const { - services: { - http, - actions: { validateEmailAddresses }, - notifications: { toasts }, - }, - } = useKibana(); - const reportingPageLink = useMemo( - () => ( - - {i18n.REPORTING_PAGE_LINK_TEXT} - - ), - [http.basePath] - ); - const { mutateAsync: scheduleReport } = useScheduleReport({ http }); - const { defaultTimezone } = useDefaultTimezone(); - const now = useMemo(() => moment().tz(defaultTimezone), [defaultTimezone]); - const defaultStartDateValue = useMemo(() => now.toISOString(), [now]); - const schema = useMemo>( - () => getScheduledReportFormSchema(validateEmailAddresses, availableReportTypes), - [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 { reportTypeId, recurringSchedule } = 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: formData.title, - reportTypeId: formData.reportTypeId, - }), - schedule: { rrule: rrule as Rrule }, - notification: formData.sendByEmail - ? { email: { to: formData.emailRecipients } } - : undefined, - }); - toasts.addSuccess({ - title: i18n.SCHEDULED_REPORT_FORM_SUCCESS_TOAST_TITLE, - text: mountReactNode( - <> - {i18n.SCHEDULED_REPORT_FORM_SUCCESS_TOAST_MESSAGE} {reportingPageLink}. - - ), - }); - } catch (error) { - toasts.addError(error, { - title: i18n.SCHEDULED_REPORT_FORM_FAILURE_TOAST_TITLE, - toastMessage: i18n.SCHEDULED_REPORT_FORM_FAILURE_TOAST_MESSAGE, - }); - } - }, - }); - const [{ sendByEmail }] = useFormData({ - form, - watch: ['sendByEmail'], - }); - - const submit = async () => { - const isValid = await form.validate(); - if (!isValid) { - throw new Error('Form validation failed'); - } - await form.submit(); - }; - - useImperativeHandle(ref, () => ({ - submit, - })); - - const isRecurring = recurring || false; - const isEmailActive = sendByEmail || false; - - return ( -
- {i18n.SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE}}> - - ({ inputDisplay: f.label, value: f.id })) ?? [], - readOnly, - }, - }} - /> - - {i18n.SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE}}> - {isRecurring && ( - - )} - - {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE}} - description={ -

- {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION} {reportingPageLink}. -

- } - > - - {hasEmailConnector ? ( - isEmailActive && ( - <> - - - - -

{i18n.SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_MESSAGE}

-
-
- - ) - ) : ( - <> - - -

{i18n.SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_MESSAGE}

-
- - )} -
-
- ); - } -); diff --git a/x-pack/platform/plugins/private/reporting/public/management/translations.ts b/x-pack/platform/plugins/private/reporting/public/management/translations.ts index 3d310ed1aa468..cb5034a9c3c26 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/translations.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/translations.ts @@ -171,7 +171,7 @@ 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 below.", + "On the scheduled date, we'll also email the report to the addresses you specify here.", } ); From d4b6cde4af02648536181a3505668f6cb914479a Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Wed, 18 Jun 2025 16:04:57 +0200 Subject: [PATCH 06/30] Fix form validity check, flyout sizing, license check --- .../components/scheduled_report_flyout_content.tsx | 6 ++++-- .../integrations/scheduled_report_share_integration.tsx | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) 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 index 3550f3d5c16c6..fe162d466b96f 100644 --- 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 @@ -163,8 +163,10 @@ export const ScheduledReportFlyoutContent = ({ const onSubmit = async () => { try { - await form.submit(); - onClose(); + if (await form.validate()) { + await form.submit(); + onClose(); + } } catch (e) { // Don't close the flyout in case of errors } 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 index b2e561e1b708d..1bb644ba0fa63 100644 --- 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 @@ -54,7 +54,14 @@ export const createScheduledReportShareIntegration = ({ /> ); }, + flyoutSizing: { size: 'm', maxWidth: 500 }, }; }, + prerequisiteCheck: ({ license }) => { + if (!license) { + return false; + } + return license.type !== 'basic'; + }, }; }; From 1af5c5f5a4ae593768bd953c4c52abdfe1acd39c Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Wed, 18 Jun 2025 16:56:03 +0200 Subject: [PATCH 07/30] Use loading state from rq instead of buggy form.isSubmitting --- .../scheduled_report_flyout_content.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) 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 index fe162d466b96f..4400e299f06d8 100644 --- 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 @@ -95,7 +95,9 @@ export const ScheduledReportFlyoutContent = ({ ), [http.basePath] ); - const { mutateAsync: scheduleReport } = useScheduleReport({ http }); + const { mutateAsync: scheduleReport, isLoading: isScheduleExportLoading } = useScheduleReport({ + http, + }); const { defaultTimezone } = useDefaultTimezone(); const now = useMemo(() => moment().tz(defaultTimezone), [defaultTimezone]); const defaultStartDateValue = useMemo(() => now.toISOString(), [now]); @@ -162,13 +164,9 @@ export const ScheduledReportFlyoutContent = ({ const isEmailActive = sendByEmail || false; const onSubmit = async () => { - try { - if (await form.validate()) { - await form.submit(); - onClose(); - } - } catch (e) { - // Don't close the flyout in case of errors + if (await form.validate()) { + await form.submit(); + onClose(); } }; @@ -324,7 +322,7 @@ export const ScheduledReportFlyoutContent = ({ form={SCHEDULED_REPORT_FORM_ID} isDisabled={false} onClick={onSubmit} - isLoading={form.isSubmitting} + isLoading={isScheduleExportLoading} fill > {i18n.SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL} From 3dbdd7182c277aee0888b836d322d4446652fd57 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Wed, 18 Jun 2025 17:16:12 +0200 Subject: [PATCH 08/30] Add time to recurrence form textual summary --- .../components/recurring_schedule_form_fields.tsx | 4 +++- .../recurring-schedule-form/translations.ts | 12 ++++++++++-- .../utils/recurring_summary.ts | 9 +++++++-- .../components/scheduled_report_flyout_content.tsx | 1 + 4 files changed, 21 insertions(+), 5 deletions(-) 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 e1d022de97325..797ea89784cf6 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 @@ -55,6 +55,7 @@ export interface RecurringScheduleFieldsProps { hideTimezone?: boolean; supportsEndOptions?: boolean; allowInfiniteRecurrence?: boolean; + showTimeInSummary?: boolean; readOnly?: boolean; } @@ -69,6 +70,7 @@ export const RecurringScheduleFormFields = memo( hideTimezone = false, supportsEndOptions = true, allowInfiniteRecurrence = true, + showTimeInSummary = false, readOnly = false, }: RecurringScheduleFieldsProps) => { const [formData] = useFormData<{ recurringSchedule: RecurringSchedule }>({ @@ -253,7 +255,7 @@ export const RecurringScheduleFormFields = memo( {i18n.RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY_PREFIX( - recurringSummary(moment(startDate), parsedSchedule, presets) + recurringSummary(moment(startDate), parsedSchedule, presets, showTimeInSummary) )} 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 d1f1fe1197cc9..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 @@ -275,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}` : '', }, }); @@ -307,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/utils/recurring_summary.ts b/src/platform/packages/shared/response-ops/recurring-schedule-form/utils/recurring_summary.ts index 311b85cffe0bb..af0406d3c17aa 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,13 +22,15 @@ 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> + presets: Record>, + showTime = false ) => { if (!recurringSchedule) return ''; @@ -93,10 +95,13 @@ export const recurringSummary = ( ? RECURRING_SCHEDULE_FORM_OCURRENCES_SUMMARY(schedule.count) : null; + const time = showTime ? RECURRING_SCHEDULE_FORM_TIME_SUMMARY(startDate.format('HH:mm')) : null; + const every = RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY( !dailyWithWeekdays ? frequencySummary : null, onSummary, - untilSummary + untilSummary, + time ).trim(); return every; 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 index 4400e299f06d8..3cdb3df244b89 100644 --- 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 @@ -243,6 +243,7 @@ export const ScheduledReportFlyoutContent = ({ hideTimezone readOnly={readOnly} supportsEndOptions={false} + showTimeInSummary /> )} From c24eaf56b72a3586d77f9d17a443d28a02e4904b Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Wed, 18 Jun 2025 18:41:51 +0200 Subject: [PATCH 09/30] Attempt to fix functional Discover tests --- x-pack/test/functional/apps/canvas/reports.ts | 2 +- .../dashboard/group3/reporting/screenshots.ts | 6 +++--- .../apps/discover/group1/reporting.ts | 13 ++++++------- .../apps/lens/group6/lens_reporting.ts | 6 +++--- .../apps/maps/group3/reports/index.ts | 2 +- .../functional/apps/visualize/reporting.ts | 2 +- .../functional/page_objects/reporting_page.ts | 19 +++++++++---------- 7 files changed, 24 insertions(+), 26 deletions(-) diff --git a/x-pack/test/functional/apps/canvas/reports.ts b/x-pack/test/functional/apps/canvas/reports.ts index 681dbe9acd2d6..eccf3e2036705 100644 --- a/x-pack/test/functional/apps/canvas/reports.ts +++ b/x-pack/test/functional/apps/canvas/reports.ts @@ -55,7 +55,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await reporting.openShareMenuItem('PDF Reports'); await reporting.clickGenerateReportButton(); - const url = await reporting.getReportURL(60000); + const url = await reporting.getReportURL(); const res = await reporting.getResponse(url ?? ''); expect(res.status).to.equal(200); diff --git a/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts b/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts index b9e69edf54c0f..6d543215b0b16 100644 --- a/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts +++ b/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts @@ -117,7 +117,7 @@ export default function ({ await reporting.checkUsePrintLayout(); await reporting.clickGenerateReportButton(); - const url = await reporting.getReportURL(60000); + const url = await reporting.getReportURL(); const res = await reporting.getResponse(url ?? ''); expect(res.status).to.equal(200); @@ -222,7 +222,7 @@ export default function ({ await reporting.selectExportItem('PDF'); await reporting.clickGenerateReportButton(); - const url = await reporting.getReportURL(60000); + const url = await reporting.getReportURL(); const res = await reporting.getResponse(url ?? ''); await exports.closeExportFlyout(); @@ -282,7 +282,7 @@ export default function ({ await reporting.clickGenerateReportButton(); await reporting.removeForceSharedItemsContainerSize(); - const url = await reporting.getReportURL(60000); + const url = await reporting.getReportURL(); const reportData = await reporting.getRawReportData(url ?? ''); sessionReportPath = await reporting.writeSessionReport( reportFileName, diff --git a/x-pack/test/functional/apps/discover/group1/reporting.ts b/x-pack/test/functional/apps/discover/group1/reporting.ts index ae66cbee3ebeb..b32923e0ab7d0 100644 --- a/x-pack/test/functional/apps/discover/group1/reporting.ts +++ b/x-pack/test/functional/apps/discover/group1/reporting.ts @@ -98,24 +98,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.info(`Indexed ${res.items.length} test data docs into ${index}.`); }; - const getReport = async ({ timeout } = { timeout: 60 * 1000 }) => { + const getReport = async () => { // close any open notification toasts await toasts.dismissAll(); - await exports.clickExportTopNavButton(); - await reporting.clickGenerateReportButton(); - - const url = await reporting.getReportURL(timeout); - const res = await reporting.getResponse(url ?? ''); + const url = await getReportPostUrl(); + const res = await reporting.getResponse(url ?? '', 'post'); expect(res.status).to.equal(200); expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); + await exports.closeExportFlyout(); return res; }; const getReportPostUrl = async () => { // click 'Copy POST URL' await exports.clickExportTopNavButton(); + await reporting.clickGenerateReportButton(); await reporting.copyReportingPOSTURLValueToClipboard(); const clipboardValue = decodeURIComponent(await browser.getClipboardValue()); @@ -241,7 +240,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.saveSearch('large export'); // match file length, the beginning and the end of the csv file contents - const { text: csvFile } = await getReport({ timeout: 80 * 1000 }); + const { text: csvFile } = await getReport(); expect(csvFile.length).to.be(4845684); expectSnapshot(csvFile.slice(0, 5000)).toMatch(); expectSnapshot(csvFile.slice(-5000)).toMatch(); diff --git a/x-pack/test/functional/apps/lens/group6/lens_reporting.ts b/x-pack/test/functional/apps/lens/group6/lens_reporting.ts index 05844453e9d27..646dcca6d313a 100644 --- a/x-pack/test/functional/apps/lens/group6/lens_reporting.ts +++ b/x-pack/test/functional/apps/lens/group6/lens_reporting.ts @@ -80,7 +80,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await reporting.selectExportItem('PDF'); await reporting.clickGenerateReportButton(); await lens.closeExportFlyout(); - const url = await reporting.getReportURL(60000); + const url = await reporting.getReportURL(); expect(url).to.be.ok(); }); @@ -119,7 +119,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await lens.clickPopoverItem(type); await reporting.clickGenerateReportButton(); - const url = await reporting.getReportURL(60000); + const url = await reporting.getReportURL(); expect(url).to.be.ok(); if (await testSubjects.exists('toastCloseButton')) { @@ -141,7 +141,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it(`should produce a valid URL for reporting`, async () => { await lens.clickPopoverItem(type); await reporting.clickGenerateReportButton(); - await reporting.getReportURL(60000); + await reporting.getReportURL(); if (await testSubjects.exists('toastCloseButton')) { await testSubjects.click('toastCloseButton'); } diff --git a/x-pack/test/functional/apps/maps/group3/reports/index.ts b/x-pack/test/functional/apps/maps/group3/reports/index.ts index e987ad03e8469..05de3f2d6f0f0 100644 --- a/x-pack/test/functional/apps/maps/group3/reports/index.ts +++ b/x-pack/test/functional/apps/maps/group3/reports/index.ts @@ -24,7 +24,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('dashboard reporting: creates a map report', () => { // helper function to check the difference between the new image and the baseline const measurePngDifference = async (fileName: string) => { - const url = await reporting.getReportURL(60000); + const url = await reporting.getReportURL(); const reportData = await reporting.getRawReportData(url ?? ''); const sessionReportPath = await reporting.writeSessionReport( diff --git a/x-pack/test/functional/apps/visualize/reporting.ts b/x-pack/test/functional/apps/visualize/reporting.ts index a97265f3a355d..15bcbfb6a2372 100644 --- a/x-pack/test/functional/apps/visualize/reporting.ts +++ b/x-pack/test/functional/apps/visualize/reporting.ts @@ -135,7 +135,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await reporting.clickGenerateReportButton(); log.debug('get the report download URL'); - const url = await reporting.getReportURL(120000); + const url = await reporting.getReportURL(); log.debug('download the report'); const reportData = await reporting.getRawReportData(url ?? ''); const sessionReportPath = await reporting.writeSessionReport( diff --git a/x-pack/test/functional/page_objects/reporting_page.ts b/x-pack/test/functional/page_objects/reporting_page.ts index b83ce830a9456..78ead4acb2101 100644 --- a/x-pack/test/functional/page_objects/reporting_page.ts +++ b/x-pack/test/functional/page_objects/reporting_page.ts @@ -61,15 +61,11 @@ export class ReportingPageObject extends FtrService { } } - async getReportURL(timeout: number) { + async getReportURL() { this.log.debug('getReportURL'); try { - const url = await this.testSubjects.getAttribute( - 'downloadCompletedReportButton', - 'href', - timeout - ); + const url = await this.testSubjects.getVisibleText('exportAssetValue'); this.log.debug(`getReportURL got url: ${url}`); return url; @@ -91,7 +87,7 @@ export class ReportingPageObject extends FtrService { `); } - async getResponse(fullUrl: string): Promise { + async getResponse(fullUrl: string, method: string = 'get'): Promise { this.log.debug(`getResponse for ${fullUrl}`); const kibanaServerConfig = this.config.get('servers.kibana'); const baseURL = formatUrl({ @@ -99,7 +95,10 @@ export class ReportingPageObject extends FtrService { auth: false, }); const urlWithoutBase = fullUrl.replace(baseURL, ''); - const res = await this.security.testUserSupertest.get(urlWithoutBase); + const res = await this.security.testUserSupertest[method](urlWithoutBase).set( + 'kbn-xsrf', + 'xxx' + ); return res ?? ''; } @@ -147,7 +146,7 @@ export class ReportingPageObject extends FtrService { } async getGenerateReportButton() { - return await this.retry.try(async () => await this.testSubjects.find('generateReportButton')); + return await this.retry.try(async () => await this.testSubjects.find('exportMenuItem-CSV')); } async isGenerateReportButtonDisabled() { @@ -175,7 +174,7 @@ export class ReportingPageObject extends FtrService { } async clickGenerateReportButton() { - await this.testSubjects.click('generateReportButton'); + await this.testSubjects.click('exportMenuItem-CSV'); } async toggleReportMode() { From aa3154f6e2aa28f1ae47a52a3f8ee5b1194ac606 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 09:03:57 +0200 Subject: [PATCH 10/30] Add pdf print option toggle --- .../components/custom_recurring_schedule.tsx | 7 ++- .../recurring_schedule_form_fields.tsx | 9 +++- .../scheduled_report_flyout_content.tsx | 51 +++++++++++++++---- .../public/management/translations.ts | 15 ++++++ .../plugins/private/reporting/public/types.ts | 1 + 5 files changed, 70 insertions(+), 13 deletions(-) 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 9b71d2219899c..af1a9cd363352 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 @@ -44,10 +44,11 @@ const styles = { export interface CustomRecurringScheduleProps { startDate: string; readOnly?: boolean; + compressed?: boolean; } export const CustomRecurringSchedule = memo( - ({ startDate, readOnly = false }: CustomRecurringScheduleProps) => { + ({ startDate, readOnly = false, compressed = false }: CustomRecurringScheduleProps) => { const [{ recurringSchedule }] = useFormData<{ recurringSchedule: RecurringSchedule }>({ watch: [ 'recurringSchedule.frequency', @@ -97,6 +98,7 @@ export const CustomRecurringSchedule = memo( componentProps={{ 'data-test-subj': 'interval-field', id: 'interval', + compressed, euiFieldProps: { 'data-test-subj': 'customRecurringScheduleIntervalInput', min: 1, @@ -115,6 +117,7 @@ export const CustomRecurringSchedule = memo( path="recurringSchedule.customFrequency" componentProps={{ 'data-test-subj': 'custom-frequency-field', + compressed, euiFieldProps: { 'data-test-subj': 'customRecurringScheduleFrequencySelect', options: frequencyOptions, @@ -151,6 +154,7 @@ export const CustomRecurringSchedule = memo( }} componentProps={{ 'data-test-subj': 'byweekday-field', + compressed, euiFieldProps: { 'data-test-subj': 'customRecurringScheduleByWeekdayButtonGroup', legend: 'Repeat on weekday', @@ -166,6 +170,7 @@ export const CustomRecurringSchedule = memo( path="recurringSchedule.bymonth" componentProps={{ 'data-test-subj': 'bymonth-field', + compressed, euiFieldProps: { legend: 'Repeat on weekday or month day', options: bymonthOptions, 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 797ea89784cf6..e4be64380ffba 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 @@ -57,6 +57,7 @@ export interface RecurringScheduleFieldsProps { allowInfiniteRecurrence?: boolean; showTimeInSummary?: boolean; readOnly?: boolean; + compressed?: boolean; } /** @@ -72,6 +73,7 @@ export const RecurringScheduleFormFields = memo( allowInfiniteRecurrence = true, showTimeInSummary = false, readOnly = false, + compressed = false, }: RecurringScheduleFieldsProps) => { const [formData] = useFormData<{ recurringSchedule: RecurringSchedule }>({ watch: [ @@ -138,6 +140,7 @@ export const RecurringScheduleFormFields = memo( componentProps={{ 'data-test-subj': 'frequency-field', euiFieldProps: { + compressed, 'data-test-subj': 'recurringScheduleRepeatSelect', options, disabled: readOnly, @@ -146,7 +149,7 @@ export const RecurringScheduleFormFields = memo( /> {(parsedSchedule?.frequency === Frequency.DAILY || parsedSchedule?.frequency === 'CUSTOM') && ( - + )} {supportsEndOptions && ( @@ -156,6 +159,7 @@ export const RecurringScheduleFormFields = memo( componentProps={{ 'data-test-subj': 'ends-field', euiFieldProps: { + compressed, legend: 'Recurrence ends', options: allowInfiniteRecurrence ? [RECURRENCE_END_NEVER, ...RECURRENCE_END_OPTIONS] @@ -195,6 +199,7 @@ export const RecurringScheduleFormFields = memo( }} componentProps={{ 'data-test-subj': 'until-field', + compressed, euiFieldProps: { showTimeSelect: false, minDate: today, @@ -218,6 +223,7 @@ export const RecurringScheduleFormFields = memo( {i18n.RECURRING_SCHEDULE_FORM_TIMEZONE} } + compressed={compressed} /> ) : null} @@ -231,6 +237,7 @@ export const RecurringScheduleFormFields = memo( 'data-test-subj': 'count-field', id: 'count', euiFieldProps: { + compressed, 'data-test-subj': 'recurringScheduleAfterXOccurenceInput', type: 'number', min: 1, 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 index 3cdb3df244b89..6f93ded17363c 100644 --- 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 @@ -25,8 +25,8 @@ 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, @@ -52,7 +52,12 @@ const FormField = getUseField({ export type FormData = Pick< ScheduledReport, - 'title' | 'reportTypeId' | 'recurringSchedule' | 'sendByEmail' | 'emailRecipients' + | 'title' + | 'reportTypeId' + | 'recurringSchedule' + | 'sendByEmail' + | 'emailRecipients' + | 'optimizedForPrinting' >; export interface ScheduledReportFlyoutContentProps { @@ -101,7 +106,7 @@ export const ScheduledReportFlyoutContent = ({ const { defaultTimezone } = useDefaultTimezone(); const now = useMemo(() => moment().tz(defaultTimezone), [defaultTimezone]); const defaultStartDateValue = useMemo(() => now.toISOString(), [now]); - const schema = useMemo>( + const schema = useMemo( () => getScheduledReportFormSchema(validateEmailAddresses, availableReportTypes), [availableReportTypes, validateEmailAddresses] ); @@ -114,7 +119,14 @@ export const ScheduledReportFlyoutContent = ({ schema, onSubmit: async (formData) => { try { - const { reportTypeId, recurringSchedule } = formData; + const { + title, + reportTypeId, + recurringSchedule, + optimizedForPrinting, + sendByEmail, + emailRecipients, + } = formData; // Remove start date since it's not supported for now const { dtstart, ...rrule } = convertToRRule({ startDate: now, @@ -129,13 +141,12 @@ export const ScheduledReportFlyoutContent = ({ // The assertion at the top of the component ensures these are defined when scheduling sharingData: sharingData!, objectType: objectType!, - title: formData.title, - reportTypeId: formData.reportTypeId, + title, + reportTypeId, + ...(reportTypeId === 'printablePdfV2' ? { optimizedForPrinting } : {}), }), schedule: { rrule: rrule as Rrule }, - notification: formData.sendByEmail - ? { email: { to: formData.emailRecipients } } - : undefined, + notification: sendByEmail ? { email: { to: emailRecipients } } : undefined, }); toasts.addSuccess({ title: i18n.SCHEDULED_REPORT_FORM_SUCCESS_TOAST_TITLE, @@ -155,9 +166,9 @@ export const ScheduledReportFlyoutContent = ({ } }, }); - const [{ sendByEmail }] = useFormData({ + const [{ reportTypeId, sendByEmail }] = useFormData({ form, - watch: ['sendByEmail'], + watch: ['reportTypeId', 'sendByEmail'], }); const isRecurring = recurring || false; @@ -232,6 +243,22 @@ export const ScheduledReportFlyoutContent = ({ }, }} /> + {reportTypeId === 'printablePdfV2' && ( + + )} {i18n.SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE}} @@ -244,6 +271,7 @@ export const ScheduledReportFlyoutContent = ({ readOnly={readOnly} supportsEndOptions={false} showTimeInSummary + compressed /> )} @@ -259,6 +287,7 @@ export const ScheduledReportFlyoutContent = ({ path="sendByEmail" componentProps={{ euiFieldProps: { + compressed: true, disabled: readOnly || !reportingHealth.areNotificationsEnabled, }, }} diff --git a/x-pack/platform/plugins/private/reporting/public/management/translations.ts b/x-pack/platform/plugins/private/reporting/public/management/translations.ts index cb5034a9c3c26..e2771b76c068d 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/translations.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/translations.ts @@ -55,6 +55,21 @@ export const SCHEDULED_REPORT_FORM_FILE_TYPE_LABEL = i18n.translate( 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', { diff --git a/x-pack/platform/plugins/private/reporting/public/types.ts b/x-pack/platform/plugins/private/reporting/public/types.ts index b5ce5bbe17267..0dfca7e33ab4c 100644 --- a/x-pack/platform/plugins/private/reporting/public/types.ts +++ b/x-pack/platform/plugins/private/reporting/public/types.ts @@ -56,6 +56,7 @@ export type ReportTypeId = 'pngV2' | 'printablePdfV2' | 'csv_searchsource'; export interface ScheduledReport { title: string; reportTypeId: ReportTypeId; + optimizedForPrinting?: boolean; startDate: string; timezone: string; recurring: boolean; From f0a92a214df6a390b5f173ea1b249ff2abd63ed0 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 09:54:28 +0200 Subject: [PATCH 11/30] Add health check before registering button, fix type error --- .../scheduled_report_flyout_content.tsx | 7 +++++- .../scheduled_report_share_integration.tsx | 22 +++++++++++-------- .../private/reporting/public/plugin.ts | 19 ++++++++++------ 3 files changed, 31 insertions(+), 17 deletions(-) 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 index 6f93ded17363c..a9033c8a6ab3b 100644 --- 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 @@ -27,6 +27,7 @@ import { REPORTING_MANAGEMENT_HOME } from '@kbn/reporting-common'; import { FIELD_TYPES, Form, + FormSchema, getUseField, useForm, useFormData, @@ -107,7 +108,11 @@ export const ScheduledReportFlyoutContent = ({ const now = useMemo(() => moment().tz(defaultTimezone), [defaultTimezone]); const defaultStartDateValue = useMemo(() => now.toISOString(), [now]); const schema = useMemo( - () => getScheduledReportFormSchema(validateEmailAddresses, availableReportTypes), + () => + getScheduledReportFormSchema( + validateEmailAddresses, + availableReportTypes + ) as FormSchema, [availableReportTypes, validateEmailAddresses] ); const recurring = true; 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 index 1bb644ba0fa63..5659a2bc1b0cd 100644 --- 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 @@ -11,15 +11,27 @@ 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 { 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, @@ -28,15 +40,7 @@ export const createScheduledReportShareIntegration = ({ id: 'scheduledReports', groupId: 'exportDerivatives', shareType: 'integration', - config: ({ - objectType, - objectId, - isDirty, - onClose, - shareableUrl, - shareableUrlForSavedObject, - ...shareOpts - }: ShareContext): ReturnType => { + config: (shareOpts: ShareContext): ReturnType => { const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData }; return { label: ({ openFlyout }) => ( diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 9dd9acf87a59a..d9589d650ffb8 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -245,13 +245,18 @@ export class ReportingPublicPlugin } import('./management/integrations/scheduled_report_share_integration').then( - ({ createScheduledReportShareIntegration }) => { - shareSetup.registerShareIntegration( - createScheduledReportShareIntegration({ - apiClient, - services: { ...core, ...setupDeps }, - }) - ); + async ({ + shouldRegisterScheduledReportShareIntegration, + createScheduledReportShareIntegration, + }) => { + if (await shouldRegisterScheduledReportShareIntegration(core.http)) { + shareSetup.registerShareIntegration( + createScheduledReportShareIntegration({ + apiClient, + services: { ...core, ...setupDeps }, + }) + ); + } } ); From 07725d1a98cbdeb14d2ceeacdd31803a508668b0 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 11:00:23 +0200 Subject: [PATCH 12/30] Disable yearly frequency --- .../components/custom_recurring_schedule.tsx | 23 +++++++---- .../recurring_schedule_form_fields.tsx | 38 +++++++++++++------ .../scheduled_report_flyout_content.tsx | 2 + .../discover/group1/reporting_embeddable.ts | 4 +- 4 files changed, 47 insertions(+), 20 deletions(-) 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 af1a9cd363352..0deccc7225c36 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 @@ -45,10 +45,16 @@ export interface CustomRecurringScheduleProps { startDate: string; readOnly?: boolean; compressed?: boolean; + minFrequency?: Frequency; } export const CustomRecurringSchedule = memo( - ({ startDate, readOnly = false, compressed = false }: CustomRecurringScheduleProps) => { + ({ + startDate, + readOnly = false, + compressed = false, + minFrequency = Frequency.YEARLY, + }: CustomRecurringScheduleProps) => { const [{ recurringSchedule }] = useFormData<{ recurringSchedule: RecurringSchedule }>({ watch: [ 'recurringSchedule.frequency', @@ -61,10 +67,13 @@ export const CustomRecurringSchedule = memo( 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 []; @@ -98,8 +107,8 @@ export const CustomRecurringSchedule = memo( componentProps={{ 'data-test-subj': 'interval-field', id: 'interval', - compressed, euiFieldProps: { + compressed, 'data-test-subj': 'customRecurringScheduleIntervalInput', min: 1, prepend: ( @@ -117,8 +126,8 @@ export const CustomRecurringSchedule = memo( path="recurringSchedule.customFrequency" componentProps={{ 'data-test-subj': 'custom-frequency-field', - compressed, euiFieldProps: { + compressed, 'data-test-subj': 'customRecurringScheduleFrequencySelect', options: frequencyOptions, disabled: readOnly, 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 e4be64380ffba..ef29069996dd9 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'; @@ -55,6 +56,7 @@ export interface RecurringScheduleFieldsProps { hideTimezone?: boolean; supportsEndOptions?: boolean; allowInfiniteRecurrence?: boolean; + minFrequency?: Frequency; showTimeInSummary?: boolean; readOnly?: boolean; compressed?: boolean; @@ -68,6 +70,7 @@ export const RecurringScheduleFormFields = memo( startDate, endDate, timezone, + minFrequency = Frequency.YEARLY, hideTimezone = false, supportsEndOptions = true, allowInfiniteRecurrence = true, @@ -91,13 +94,13 @@ 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: i18n.RECURRING_SCHEDULE_FORM_FREQUENCY_DAILY, value: Frequency.DAILY, @@ -125,10 +128,19 @@ export const RecurringScheduleFormFields = memo( 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]); @@ -149,7 +161,11 @@ export const RecurringScheduleFormFields = memo( /> {(parsedSchedule?.frequency === Frequency.DAILY || parsedSchedule?.frequency === 'CUSTOM') && ( - + )} {supportsEndOptions && ( 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 index a9033c8a6ab3b..a50a93c82aedd 100644 --- 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 @@ -37,6 +37,7 @@ 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'; @@ -275,6 +276,7 @@ export const ScheduledReportFlyoutContent = ({ hideTimezone readOnly={readOnly} supportsEndOptions={false} + minFrequency={Frequency.MONTHLY} showTimeInSummary compressed /> diff --git a/x-pack/test/functional/apps/discover/group1/reporting_embeddable.ts b/x-pack/test/functional/apps/discover/group1/reporting_embeddable.ts index 6d8d70ac7ebf1..abcbfae827af2 100644 --- a/x-pack/test/functional/apps/discover/group1/reporting_embeddable.ts +++ b/x-pack/test/functional/apps/discover/group1/reporting_embeddable.ts @@ -36,14 +36,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const SAVED_DISCOVER_SESSION_WITH_DATA_VIEW = 'savedDiscoverSessionWithDataView'; const SAVED_DISCOVER_SESSION_WITH_ESQL = 'savedDiscoverSessionWithESQL'; - const getDashboardPanelReport = async (title: string, { timeout } = { timeout: 60 * 1000 }) => { + const getDashboardPanelReport = async (title: string) => { await toasts.dismissAll(); await dashboardPanelActions.expectExistsPanelAction(GENERATE_CSV_DATA_TEST_SUBJ, title); await dashboardPanelActions.clickPanelActionByTitle(GENERATE_CSV_DATA_TEST_SUBJ, title); await testSubjects.existOrFail('csvReportStarted'); /* validate toast panel */ - const url = await reporting.getReportURL(timeout); + const url = await reporting.getReportURL(); const res = await reporting.getResponse(url ?? ''); expect(res.status).to.equal(200); From acdd24ae5195bdbfd7204236dfc02afb6baf3f42 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 13:49:10 +0200 Subject: [PATCH 13/30] Fix Discover functional tests --- x-pack/test/functional/apps/canvas/reports.ts | 2 +- .../dashboard/group3/reporting/screenshots.ts | 6 +++--- .../apps/discover/group1/reporting.ts | 19 ++++++++++++------- .../apps/lens/group6/lens_reporting.ts | 6 +++--- .../apps/maps/group3/reports/index.ts | 2 +- .../functional/apps/visualize/reporting.ts | 2 +- .../functional/page_objects/reporting_page.ts | 19 ++++++++++--------- 7 files changed, 31 insertions(+), 25 deletions(-) diff --git a/x-pack/test/functional/apps/canvas/reports.ts b/x-pack/test/functional/apps/canvas/reports.ts index eccf3e2036705..681dbe9acd2d6 100644 --- a/x-pack/test/functional/apps/canvas/reports.ts +++ b/x-pack/test/functional/apps/canvas/reports.ts @@ -55,7 +55,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await reporting.openShareMenuItem('PDF Reports'); await reporting.clickGenerateReportButton(); - const url = await reporting.getReportURL(); + const url = await reporting.getReportURL(60000); const res = await reporting.getResponse(url ?? ''); expect(res.status).to.equal(200); diff --git a/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts b/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts index 6d543215b0b16..b9e69edf54c0f 100644 --- a/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts +++ b/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts @@ -117,7 +117,7 @@ export default function ({ await reporting.checkUsePrintLayout(); await reporting.clickGenerateReportButton(); - const url = await reporting.getReportURL(); + const url = await reporting.getReportURL(60000); const res = await reporting.getResponse(url ?? ''); expect(res.status).to.equal(200); @@ -222,7 +222,7 @@ export default function ({ await reporting.selectExportItem('PDF'); await reporting.clickGenerateReportButton(); - const url = await reporting.getReportURL(); + const url = await reporting.getReportURL(60000); const res = await reporting.getResponse(url ?? ''); await exports.closeExportFlyout(); @@ -282,7 +282,7 @@ export default function ({ await reporting.clickGenerateReportButton(); await reporting.removeForceSharedItemsContainerSize(); - const url = await reporting.getReportURL(); + const url = await reporting.getReportURL(60000); const reportData = await reporting.getRawReportData(url ?? ''); sessionReportPath = await reporting.writeSessionReport( reportFileName, diff --git a/x-pack/test/functional/apps/discover/group1/reporting.ts b/x-pack/test/functional/apps/discover/group1/reporting.ts index b32923e0ab7d0..b9892a88a8585 100644 --- a/x-pack/test/functional/apps/discover/group1/reporting.ts +++ b/x-pack/test/functional/apps/discover/group1/reporting.ts @@ -98,22 +98,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.info(`Indexed ${res.items.length} test data docs into ${index}.`); }; - const getReport = async () => { + const getReport = async ({ timeout } = { timeout: 60 * 1000 }) => { // close any open notification toasts await toasts.dismissAll(); - const url = await getReportPostUrl(); - const res = await reporting.getResponse(url ?? '', 'post'); + await exports.clickExportTopNavButton(); + await reporting.selectExportItem('CSV'); + await reporting.clickGenerateReportButton(); + await exports.closeExportFlyout(); + + const url = await reporting.getReportURL(timeout); + const res = await reporting.getResponse(url ?? ''); expect(res.status).to.equal(200); expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); - await exports.closeExportFlyout(); return res; }; const getReportPostUrl = async () => { // click 'Copy POST URL' await exports.clickExportTopNavButton(); + await reporting.selectExportItem('CSV'); await reporting.clickGenerateReportButton(); await reporting.copyReportingPOSTURLValueToClipboard(); @@ -140,14 +145,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await reporting.openExportPopover(); - expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); + expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); await exports.closeExportFlyout(); }); it('becomes available when saved', async () => { await discover.saveSearch('my search - expectEnabledGenerateReportButton'); await reporting.openExportPopover(); - expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); + expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); await exports.closeExportFlyout(); }); }); @@ -240,7 +245,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.saveSearch('large export'); // match file length, the beginning and the end of the csv file contents - const { text: csvFile } = await getReport(); + const { text: csvFile } = await getReport({ timeout: 80 * 1000 }); expect(csvFile.length).to.be(4845684); expectSnapshot(csvFile.slice(0, 5000)).toMatch(); expectSnapshot(csvFile.slice(-5000)).toMatch(); diff --git a/x-pack/test/functional/apps/lens/group6/lens_reporting.ts b/x-pack/test/functional/apps/lens/group6/lens_reporting.ts index 646dcca6d313a..05844453e9d27 100644 --- a/x-pack/test/functional/apps/lens/group6/lens_reporting.ts +++ b/x-pack/test/functional/apps/lens/group6/lens_reporting.ts @@ -80,7 +80,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await reporting.selectExportItem('PDF'); await reporting.clickGenerateReportButton(); await lens.closeExportFlyout(); - const url = await reporting.getReportURL(); + const url = await reporting.getReportURL(60000); expect(url).to.be.ok(); }); @@ -119,7 +119,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await lens.clickPopoverItem(type); await reporting.clickGenerateReportButton(); - const url = await reporting.getReportURL(); + const url = await reporting.getReportURL(60000); expect(url).to.be.ok(); if (await testSubjects.exists('toastCloseButton')) { @@ -141,7 +141,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it(`should produce a valid URL for reporting`, async () => { await lens.clickPopoverItem(type); await reporting.clickGenerateReportButton(); - await reporting.getReportURL(); + await reporting.getReportURL(60000); if (await testSubjects.exists('toastCloseButton')) { await testSubjects.click('toastCloseButton'); } diff --git a/x-pack/test/functional/apps/maps/group3/reports/index.ts b/x-pack/test/functional/apps/maps/group3/reports/index.ts index 05de3f2d6f0f0..e987ad03e8469 100644 --- a/x-pack/test/functional/apps/maps/group3/reports/index.ts +++ b/x-pack/test/functional/apps/maps/group3/reports/index.ts @@ -24,7 +24,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('dashboard reporting: creates a map report', () => { // helper function to check the difference between the new image and the baseline const measurePngDifference = async (fileName: string) => { - const url = await reporting.getReportURL(); + const url = await reporting.getReportURL(60000); const reportData = await reporting.getRawReportData(url ?? ''); const sessionReportPath = await reporting.writeSessionReport( diff --git a/x-pack/test/functional/apps/visualize/reporting.ts b/x-pack/test/functional/apps/visualize/reporting.ts index 15bcbfb6a2372..a97265f3a355d 100644 --- a/x-pack/test/functional/apps/visualize/reporting.ts +++ b/x-pack/test/functional/apps/visualize/reporting.ts @@ -135,7 +135,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await reporting.clickGenerateReportButton(); log.debug('get the report download URL'); - const url = await reporting.getReportURL(); + const url = await reporting.getReportURL(120000); log.debug('download the report'); const reportData = await reporting.getRawReportData(url ?? ''); const sessionReportPath = await reporting.writeSessionReport( diff --git a/x-pack/test/functional/page_objects/reporting_page.ts b/x-pack/test/functional/page_objects/reporting_page.ts index 78ead4acb2101..b83ce830a9456 100644 --- a/x-pack/test/functional/page_objects/reporting_page.ts +++ b/x-pack/test/functional/page_objects/reporting_page.ts @@ -61,11 +61,15 @@ export class ReportingPageObject extends FtrService { } } - async getReportURL() { + async getReportURL(timeout: number) { this.log.debug('getReportURL'); try { - const url = await this.testSubjects.getVisibleText('exportAssetValue'); + const url = await this.testSubjects.getAttribute( + 'downloadCompletedReportButton', + 'href', + timeout + ); this.log.debug(`getReportURL got url: ${url}`); return url; @@ -87,7 +91,7 @@ export class ReportingPageObject extends FtrService { `); } - async getResponse(fullUrl: string, method: string = 'get'): Promise { + async getResponse(fullUrl: string): Promise { this.log.debug(`getResponse for ${fullUrl}`); const kibanaServerConfig = this.config.get('servers.kibana'); const baseURL = formatUrl({ @@ -95,10 +99,7 @@ export class ReportingPageObject extends FtrService { auth: false, }); const urlWithoutBase = fullUrl.replace(baseURL, ''); - const res = await this.security.testUserSupertest[method](urlWithoutBase).set( - 'kbn-xsrf', - 'xxx' - ); + const res = await this.security.testUserSupertest.get(urlWithoutBase); return res ?? ''; } @@ -146,7 +147,7 @@ export class ReportingPageObject extends FtrService { } async getGenerateReportButton() { - return await this.retry.try(async () => await this.testSubjects.find('exportMenuItem-CSV')); + return await this.retry.try(async () => await this.testSubjects.find('generateReportButton')); } async isGenerateReportButtonDisabled() { @@ -174,7 +175,7 @@ export class ReportingPageObject extends FtrService { } async clickGenerateReportButton() { - await this.testSubjects.click('exportMenuItem-CSV'); + await this.testSubjects.click('generateReportButton'); } async toggleReportMode() { From ea2dc7da3dca018824ca31cad13c9e1c2db700dc Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 15:19:27 +0200 Subject: [PATCH 14/30] Revert test change causing type error --- .../functional/apps/discover/group1/reporting_embeddable.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/discover/group1/reporting_embeddable.ts b/x-pack/test/functional/apps/discover/group1/reporting_embeddable.ts index abcbfae827af2..6d8d70ac7ebf1 100644 --- a/x-pack/test/functional/apps/discover/group1/reporting_embeddable.ts +++ b/x-pack/test/functional/apps/discover/group1/reporting_embeddable.ts @@ -36,14 +36,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const SAVED_DISCOVER_SESSION_WITH_DATA_VIEW = 'savedDiscoverSessionWithDataView'; const SAVED_DISCOVER_SESSION_WITH_ESQL = 'savedDiscoverSessionWithESQL'; - const getDashboardPanelReport = async (title: string) => { + const getDashboardPanelReport = async (title: string, { timeout } = { timeout: 60 * 1000 }) => { await toasts.dismissAll(); await dashboardPanelActions.expectExistsPanelAction(GENERATE_CSV_DATA_TEST_SUBJ, title); await dashboardPanelActions.clickPanelActionByTitle(GENERATE_CSV_DATA_TEST_SUBJ, title); await testSubjects.existOrFail('csvReportStarted'); /* validate toast panel */ - const url = await reporting.getReportURL(); + const url = await reporting.getReportURL(timeout); const res = await reporting.getResponse(url ?? ''); expect(res.status).to.equal(200); From a492c73a15040ebe0cc3ca694d63b9c03fd1c073 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 15:33:51 +0200 Subject: [PATCH 15/30] Implement scheduled report transform util, update recurrence form --- .../private/kbn-reporting/common/types.ts | 23 ++++- .../components/custom_recurring_schedule.tsx | 4 +- .../recurring_schedule_form_fields.tsx | 10 ++- .../recurring-schedule-form/constants.ts | 10 +++ .../recurring-schedule-form/types.ts | 4 + .../utils/recurring_summary.test.ts | 90 +++++++++---------- .../utils/recurring_summary.ts | 52 +++++++---- .../scheduled_report_flyout_content.tsx | 4 +- .../reporting/public/management/utils.ts | 85 ++++++++++++++++++ .../plugins/private/reporting/public/types.ts | 10 ++- 10 files changed, 223 insertions(+), 69 deletions(-) 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/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 0deccc7225c36..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,7 +42,7 @@ const styles = { }; export interface CustomRecurringScheduleProps { - startDate: string; + startDate?: string; readOnly?: boolean; compressed?: boolean; minFrequency?: Frequency; @@ -168,7 +168,7 @@ export const CustomRecurringSchedule = memo( 'data-test-subj': 'customRecurringScheduleByWeekdayButtonGroup', legend: 'Repeat on weekday', options: WEEKDAY_OPTIONS, - disabled: readOnly, + isDisabled: readOnly, }, }} /> 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 ef29069996dd9..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 @@ -50,7 +50,7 @@ export const toMoment = (value?: string): Moment | undefined => (value ? moment( export const toString = (value?: Moment): string => value?.toISOString() ?? ''; export interface RecurringScheduleFieldsProps { - startDate: string; + startDate?: string; endDate?: string; timezone?: string[]; hideTimezone?: boolean; @@ -165,6 +165,7 @@ export const RecurringScheduleFormFields = memo( startDate={startDate} compressed={compressed} minFrequency={minFrequency} + readOnly={readOnly} /> )} @@ -278,7 +279,12 @@ export const RecurringScheduleFormFields = memo( {i18n.RECURRING_SCHEDULE_FORM_RECURRING_SUMMARY_PREFIX( - recurringSummary(moment(startDate), parsedSchedule, presets, showTimeInSummary) + 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/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/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 af0406d3c17aa..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 @@ -26,12 +26,17 @@ import { } from '../translations'; import type { RecurrenceFrequency, RecurringSchedule } from '../types'; -export const recurringSummary = ( - startDate: Moment, - recurringSchedule: RecurringSchedule | undefined, - presets: Record>, - showTime = false -) => { +export const recurringSummary = ({ + startDate, + recurringSchedule, + presets, + showTime = false, +}: { + startDate?: Moment; + recurringSchedule?: RecurringSchedule; + presets: Record>; + showTime?: boolean; +}) => { if (!recurringSchedule) return ''; let schedule = recurringSchedule; @@ -65,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 @@ -95,7 +107,17 @@ export const recurringSummary = ( ? RECURRING_SCHEDULE_FORM_OCURRENCES_SUMMARY(schedule.count) : null; - const time = showTime ? RECURRING_SCHEDULE_FORM_TIME_SUMMARY(startDate.format('HH:mm')) : 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, 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 index a50a93c82aedd..91e6f1a1608e8 100644 --- 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 @@ -271,8 +271,8 @@ export const ScheduledReportFlyoutContent = ({ > {isRecurring && ( { [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/types.ts b/x-pack/platform/plugins/private/reporting/public/types.ts index 0dfca7e33ab4c..d69136efc1e47 100644 --- a/x-pack/platform/plugins/private/reporting/public/types.ts +++ b/x-pack/platform/plugins/private/reporting/public/types.ts @@ -57,12 +57,18 @@ export interface ScheduledReport { title: string; reportTypeId: ReportTypeId; optimizedForPrinting?: boolean; - startDate: string; - timezone: string; 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 { From 65dea18ba3fb69a9be2f26df32c7fb410afbb13f Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 16:31:11 +0200 Subject: [PATCH 16/30] Fix type error --- .../maintenance_windows/components/upcoming_events_popover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/upcoming_events_popover.tsx b/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/upcoming_events_popover.tsx index e695bfe643e26..fe86c09ab90a4 100644 --- a/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/upcoming_events_popover.tsx +++ b/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/upcoming_events_popover.tsx @@ -87,7 +87,7 @@ export const UpcomingEventsPopover: React.FC = React > {i18n.CREATE_FORM_RECURRING_SUMMARY_PREFIX( - recurringSummary(startDate, recurringSchedule, presets) + recurringSummary({ startDate, recurringSchedule, presets }) )} From 04cc893e759f54c211a76e2fbe0816e54e32dc1b Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 17:59:07 +0200 Subject: [PATCH 17/30] Catch schedule report errors --- .../components/scheduled_report_flyout_content.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index 91e6f1a1608e8..22cb9f7c07dd0 100644 --- 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 @@ -181,9 +181,13 @@ export const ScheduledReportFlyoutContent = ({ const isEmailActive = sendByEmail || false; const onSubmit = async () => { - if (await form.validate()) { - await form.submit(); - onClose(); + try { + if (await form.validate()) { + await form.submit(); + onClose(); + } + } catch (e) { + // Keep the flyout open in case of schedule error } }; From 9673dbffe0c566ded4f99c01a63b2c722210ab2a Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 18:08:17 +0200 Subject: [PATCH 18/30] Fix last Dashboard and Discover functional tests --- .../apps/discover/group2/feature_controls/discover_security.ts | 1 + x-pack/test/reporting_functional/services/scenarios.ts | 1 + .../functional/test_suites/common/discover/x_pack/reporting.ts | 1 + 3 files changed, 3 insertions(+) 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..106205e190d09 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.reporting.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..5dfc13fa2d941 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 @@ -90,6 +90,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await PageObjects.reporting.openExportPopover(); + await PageObjects.reporting.clickPopoverItem('CSV'); expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); }); From 4b87914e5ec4b0b0e75917c05be2e38140169403 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 20:28:26 +0200 Subject: [PATCH 19/30] Fix wrong page object method call --- x-pack/test/reporting_functional/services/scenarios.ts | 2 +- .../functional/test_suites/common/discover/x_pack/reporting.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/reporting_functional/services/scenarios.ts b/x-pack/test/reporting_functional/services/scenarios.ts index 106205e190d09..14f1e29e208ff 100644 --- a/x-pack/test/reporting_functional/services/scenarios.ts +++ b/x-pack/test/reporting_functional/services/scenarios.ts @@ -114,7 +114,7 @@ export function createScenarios( const tryDiscoverCsvSuccess = async () => { await PageObjects.reporting.openExportPopover(); - await PageObjects.reporting.clickPopoverItem('CSV'); + 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 5dfc13fa2d941..ab90b58aa0a0d 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 @@ -90,7 +90,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await PageObjects.reporting.openExportPopover(); - await PageObjects.reporting.clickPopoverItem('CSV'); + await PageObjects.exports.clickPopoverItem('CSV'); expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); }); From 7ff7bdba85defbbd66d7bfd62acb89b980537122 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 19 Jun 2025 22:20:21 +0200 Subject: [PATCH 20/30] More functional test fixes --- x-pack/test/functional/apps/discover/group1/reporting.ts | 1 + .../test_suites/common/discover/x_pack/reporting.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/x-pack/test/functional/apps/discover/group1/reporting.ts b/x-pack/test/functional/apps/discover/group1/reporting.ts index b9892a88a8585..0e0fda218656e 100644 --- a/x-pack/test/functional/apps/discover/group1/reporting.ts +++ b/x-pack/test/functional/apps/discover/group1/reporting.ts @@ -106,6 +106,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 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 ?? ''); 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 ab90b58aa0a0d..dabb395d41738 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()` @@ -91,7 +94,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await PageObjects.reporting.openExportPopover(); await PageObjects.exports.clickPopoverItem('CSV'); - expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); + expect(await PageObjects.exports.isPopoverItemEnabled('CSV')).to.be(true); }); it('becomes available when saved', async () => { @@ -100,7 +103,7 @@ 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); }); }); From ff6ff75af604d3029085966afbbc322596fecaec Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Fri, 20 Jun 2025 11:18:48 +0200 Subject: [PATCH 21/30] Even more functional test fixes --- x-pack/test/functional/apps/discover/group1/reporting.ts | 4 ++-- .../test_suites/common/discover/x_pack/reporting.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/test/functional/apps/discover/group1/reporting.ts b/x-pack/test/functional/apps/discover/group1/reporting.ts index 0e0fda218656e..314b9e835e09a 100644 --- a/x-pack/test/functional/apps/discover/group1/reporting.ts +++ b/x-pack/test/functional/apps/discover/group1/reporting.ts @@ -147,14 +147,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await reporting.openExportPopover(); expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); - await exports.closeExportFlyout(); + await reporting.openExportPopover(); }); it('becomes available when saved', async () => { await discover.saveSearch('my search - expectEnabledGenerateReportButton'); await reporting.openExportPopover(); expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); - await exports.closeExportFlyout(); + await reporting.openExportPopover(); }); }); 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 dabb395d41738..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 @@ -93,8 +93,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await PageObjects.reporting.openExportPopover(); - await PageObjects.exports.clickPopoverItem('CSV'); expect(await PageObjects.exports.isPopoverItemEnabled('CSV')).to.be(true); + await PageObjects.reporting.openExportPopover(); }); it('becomes available when saved', async () => { @@ -104,6 +104,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await PageObjects.reporting.openExportPopover(); expect(await PageObjects.exports.isPopoverItemEnabled('CSV')).to.be(true); + await PageObjects.reporting.openExportPopover(); }); }); From 95c706a6f2a565a614612aaa0e9a2e8eaece607b Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Fri, 20 Jun 2025 17:16:29 +0200 Subject: [PATCH 22/30] Filter share items by ones that support scheduling --- .../scheduled_report_flyout_share_wrapper.tsx | 11 +++++++---- .../reporting/public/management/report_params.ts | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) 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 index aa53f28098630..da241f50686bf 100644 --- 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 @@ -11,6 +11,7 @@ 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'; @@ -39,10 +40,12 @@ export const ScheduledReportFlyoutShareWrapper = ({ const { shareMenuItems, objectType } = useShareTypeContext('integration', 'export'); const availableReportTypes = useMemo(() => { - return shareMenuItems.map((item) => ({ - id: item.config.exportType, - label: item.config.label, - })); + return shareMenuItems + .filter((item) => supportedReportTypes.includes(item.config.exportType)) + .map((item) => ({ + id: item.config.exportType, + label: item.config.label, + })); }, [shareMenuItems]); const scheduledReport = useMemo( 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 index 8d27e30f57f8a..3221e4ae5c240 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/report_params.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/report_params.ts @@ -20,6 +20,8 @@ const reportParamsProviders = { csv_searchsource: getCsvReportParams, } as const; +export const supportedReportTypes = Object.keys(reportParamsProviders) as ReportTypeId[]; + export interface GetReportParamsOptions { apiClient: ReportingAPIClient; reportTypeId: ReportTypeId; From e9bf5cdd9c0ff27f543aa4c037203bcb0f7f8cbc Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Fri, 20 Jun 2025 17:30:31 +0200 Subject: [PATCH 23/30] Change csv export icon type --- .../share/share_context_menu/register_csv_modal_reporting.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fa7a100c1b2ca..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 @@ -139,7 +139,7 @@ export const reportingCsvExportProvider = ({ name: panelTitle, exportType: reportType, label: 'CSV', - icon: 'documents', + icon: 'tableDensityNormal', generateAssetExport: generateReportingJobCSV, helpText: ( Date: Fri, 20 Jun 2025 17:31:09 +0200 Subject: [PATCH 24/30] Log schedule errors to console --- .../management/components/scheduled_report_flyout_content.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 index 22cb9f7c07dd0..08fee818068ba 100644 --- 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 @@ -163,6 +163,8 @@ export const ScheduledReportFlyoutContent = ({ ), }); } 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, From aab40a3e867dff6fd30325925e0871b3c232b83d Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Fri, 20 Jun 2025 17:43:29 +0200 Subject: [PATCH 25/30] Check license against allowlist --- .../{server => common}/check_license.test.ts | 2 +- .../kbn-reporting/{server => common}/check_license.ts | 10 +++++----- .../scheduled_report_share_integration.tsx | 5 +++-- .../platform/plugins/private/reporting/server/core.ts | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) rename src/platform/packages/private/kbn-reporting/{server => common}/check_license.test.ts (99%) rename src/platform/packages/private/kbn-reporting/{server => common}/check_license.ts (93%) diff --git a/src/platform/packages/private/kbn-reporting/server/check_license.test.ts b/src/platform/packages/private/kbn-reporting/common/check_license.test.ts similarity index 99% rename from src/platform/packages/private/kbn-reporting/server/check_license.test.ts rename to src/platform/packages/private/kbn-reporting/common/check_license.test.ts index a3b52a770737a..4fd84ddb26558 100644 --- a/src/platform/packages/private/kbn-reporting/server/check_license.test.ts +++ b/src/platform/packages/private/kbn-reporting/common/check_license.test.ts @@ -9,7 +9,7 @@ import { ILicense } from '@kbn/licensing-plugin/server'; import { checkLicense } from './check_license'; -import { ExportTypesRegistry } from './export_types_registry'; +import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; describe('check_license', () => { let exportTypesRegistry: ExportTypesRegistry; diff --git a/src/platform/packages/private/kbn-reporting/server/check_license.ts b/src/platform/packages/private/kbn-reporting/common/check_license.ts similarity index 93% rename from src/platform/packages/private/kbn-reporting/server/check_license.ts rename to src/platform/packages/private/kbn-reporting/common/check_license.ts index 6a079b4ee61dd..cbbea83e53d8e 100644 --- a/src/platform/packages/private/kbn-reporting/server/check_license.ts +++ b/src/platform/packages/private/kbn-reporting/common/check_license.ts @@ -7,16 +7,16 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ILicense, LicenseType } from '@kbn/licensing-plugin/server'; +import type { ILicense, LicenseType } from '@kbn/licensing-plugin/server'; +import type { ExportType } from '@kbn/reporting-server'; +import type { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; import { LICENSE_TYPE_CLOUD_STANDARD, LICENSE_TYPE_ENTERPRISE, LICENSE_TYPE_GOLD, LICENSE_TYPE_PLATINUM, LICENSE_TYPE_TRIAL, -} from '@kbn/reporting-common'; -import type { ExportType } from '.'; -import { ExportTypesRegistry } from './export_types_registry'; +} from '.'; export interface LicenseCheckResult { showLinks: boolean; @@ -25,7 +25,7 @@ export interface LicenseCheckResult { jobTypes?: string[]; } -const scheduledReportValidLicenses: LicenseType[] = [ +export const scheduledReportValidLicenses: LicenseType[] = [ LICENSE_TYPE_TRIAL, LICENSE_TYPE_CLOUD_STANDARD, LICENSE_TYPE_GOLD, 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 index 5659a2bc1b0cd..09a8d0092a3fc 100644 --- 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 @@ -12,6 +12,7 @@ import type { ReportingSharingData } from '@kbn/reporting-public/share/share_con import { EuiButton } from '@elastic/eui'; import type { ReportingAPIClient } from '@kbn/reporting-public'; import { HttpSetup } from '@kbn/core-http-browser'; +import { scheduledReportValidLicenses } from '@kbn/reporting-common/check_license'; 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'; @@ -62,10 +63,10 @@ export const createScheduledReportShareIntegration = ({ }; }, prerequisiteCheck: ({ license }) => { - if (!license) { + if (!license || !license.type) { return false; } - return license.type !== 'basic'; + return scheduledReportValidLicenses.includes(license.type); }, }; }; diff --git a/x-pack/platform/plugins/private/reporting/server/core.ts b/x-pack/platform/plugins/private/reporting/server/core.ts index 82adfffe42f90..4d10d653e30f8 100644 --- a/x-pack/platform/plugins/private/reporting/server/core.ts +++ b/x-pack/platform/plugins/private/reporting/server/core.ts @@ -47,7 +47,7 @@ import type { import type { UsageCounter } from '@kbn/usage-collection-plugin/server'; import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server'; -import { checkLicense } from '@kbn/reporting-server/check_license'; +import { checkLicense } from '@kbn/reporting-common/check_license'; import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import type { ReportingSetup } from '.'; From 03d14c943dafcb436edd2bf2f8cbba950fbd7fde Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:00:04 +0000 Subject: [PATCH 26/30] [CI] Auto-commit changed files from 'node scripts/notice' --- .../packages/private/kbn-reporting/common/tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/platform/packages/private/kbn-reporting/common/tsconfig.json b/src/platform/packages/private/kbn-reporting/common/tsconfig.json index 5d9636f8d165d..2b2e7f8dff321 100644 --- a/src/platform/packages/private/kbn-reporting/common/tsconfig.json +++ b/src/platform/packages/private/kbn-reporting/common/tsconfig.json @@ -21,5 +21,7 @@ "@kbn/i18n", "@kbn/task-manager-plugin", "@kbn/deeplinks-analytics", + "@kbn/licensing-plugin", + "@kbn/reporting-server", ] } From 03d256f825432f4a8dd675b4db527ff46d465156 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Mon, 23 Jun 2025 09:02:30 +0200 Subject: [PATCH 27/30] Fix circular TS proj dependency --- .../private/kbn-reporting/common/constants.ts | 8 +++++++ .../{common => server}/check_license.test.ts | 2 +- .../{common => server}/check_license.ts | 24 ++++--------------- .../scheduled_report_share_integration.tsx | 4 ++-- .../plugins/private/reporting/server/core.ts | 2 +- 5 files changed, 17 insertions(+), 23 deletions(-) rename src/platform/packages/private/kbn-reporting/{common => server}/check_license.test.ts (99%) rename src/platform/packages/private/kbn-reporting/{common => server}/check_license.ts (86%) 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/check_license.test.ts b/src/platform/packages/private/kbn-reporting/server/check_license.test.ts similarity index 99% rename from src/platform/packages/private/kbn-reporting/common/check_license.test.ts rename to src/platform/packages/private/kbn-reporting/server/check_license.test.ts index 4fd84ddb26558..a3b52a770737a 100644 --- a/src/platform/packages/private/kbn-reporting/common/check_license.test.ts +++ b/src/platform/packages/private/kbn-reporting/server/check_license.test.ts @@ -9,7 +9,7 @@ import { ILicense } from '@kbn/licensing-plugin/server'; import { checkLicense } from './check_license'; -import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; +import { ExportTypesRegistry } from './export_types_registry'; describe('check_license', () => { let exportTypesRegistry: ExportTypesRegistry; diff --git a/src/platform/packages/private/kbn-reporting/common/check_license.ts b/src/platform/packages/private/kbn-reporting/server/check_license.ts similarity index 86% rename from src/platform/packages/private/kbn-reporting/common/check_license.ts rename to src/platform/packages/private/kbn-reporting/server/check_license.ts index cbbea83e53d8e..afcf87bbd03db 100644 --- a/src/platform/packages/private/kbn-reporting/common/check_license.ts +++ b/src/platform/packages/private/kbn-reporting/server/check_license.ts @@ -7,16 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ILicense, LicenseType } from '@kbn/licensing-plugin/server'; -import type { ExportType } from '@kbn/reporting-server'; -import type { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; -import { - LICENSE_TYPE_CLOUD_STANDARD, - LICENSE_TYPE_ENTERPRISE, - LICENSE_TYPE_GOLD, - LICENSE_TYPE_PLATINUM, - LICENSE_TYPE_TRIAL, -} from '.'; +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'; export interface LicenseCheckResult { showLinks: boolean; @@ -25,14 +19,6 @@ export interface LicenseCheckResult { jobTypes?: string[]; } -export 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/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 index 09a8d0092a3fc..fa7fb54c8957c 100644 --- 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 @@ -12,7 +12,7 @@ import type { ReportingSharingData } from '@kbn/reporting-public/share/share_con import { EuiButton } from '@elastic/eui'; import type { ReportingAPIClient } from '@kbn/reporting-public'; import { HttpSetup } from '@kbn/core-http-browser'; -import { scheduledReportValidLicenses } from '@kbn/reporting-common/check_license'; +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'; @@ -66,7 +66,7 @@ export const createScheduledReportShareIntegration = ({ if (!license || !license.type) { return false; } - return scheduledReportValidLicenses.includes(license.type); + return SCHEDULED_REPORT_VALID_LICENSES.includes(license.type); }, }; }; diff --git a/x-pack/platform/plugins/private/reporting/server/core.ts b/x-pack/platform/plugins/private/reporting/server/core.ts index 4d10d653e30f8..82adfffe42f90 100644 --- a/x-pack/platform/plugins/private/reporting/server/core.ts +++ b/x-pack/platform/plugins/private/reporting/server/core.ts @@ -47,7 +47,7 @@ import type { import type { UsageCounter } from '@kbn/usage-collection-plugin/server'; import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server'; -import { checkLicense } from '@kbn/reporting-common/check_license'; +import { checkLicense } from '@kbn/reporting-server/check_license'; import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import type { ReportingSetup } from '.'; From 0981e53747f859aa32208a672a168f68d4119611 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Mon, 23 Jun 2025 09:13:04 +0200 Subject: [PATCH 28/30] Combine import and export of useShareTypeContext into a single statement Co-authored-by: Eyo O. Eyo <7893459+eokoneyo@users.noreply.github.com> --- src/platform/plugins/shared/share/public/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/platform/plugins/shared/share/public/index.ts b/src/platform/plugins/shared/share/public/index.ts index ed601f2d25a86..64a17742f905b 100644 --- a/src/platform/plugins/shared/share/public/index.ts +++ b/src/platform/plugins/shared/share/public/index.ts @@ -42,5 +42,4 @@ export function plugin(ctx: PluginInitializerContext) { return new SharePlugin(ctx); } -import { useShareTypeContext } from './components/context'; -export { useShareTypeContext }; +export { useShareTypeContext } from './components/context'; From bc6aa0c7f80dae401dd68b3b0c5576a43db89de3 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 23 Jun 2025 07:23:59 +0000 Subject: [PATCH 29/30] [CI] Auto-commit changed files from 'node scripts/yarn_deduplicate' --- src/platform/packages/private/kbn-reporting/common/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/packages/private/kbn-reporting/common/tsconfig.json b/src/platform/packages/private/kbn-reporting/common/tsconfig.json index 2b2e7f8dff321..5ba99841a16e0 100644 --- a/src/platform/packages/private/kbn-reporting/common/tsconfig.json +++ b/src/platform/packages/private/kbn-reporting/common/tsconfig.json @@ -22,6 +22,5 @@ "@kbn/task-manager-plugin", "@kbn/deeplinks-analytics", "@kbn/licensing-plugin", - "@kbn/reporting-server", ] } From bc68511cf15ef832cc9fe9b5b0110b72e94baa1d Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Mon, 23 Jun 2025 09:58:41 +0200 Subject: [PATCH 30/30] Fix type error --- .../components/scheduled_report_flyout_share_wrapper.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index da241f50686bf..e981eb85b3136 100644 --- 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 @@ -15,6 +15,7 @@ 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; @@ -41,7 +42,7 @@ export const ScheduledReportFlyoutShareWrapper = ({ const availableReportTypes = useMemo(() => { return shareMenuItems - .filter((item) => supportedReportTypes.includes(item.config.exportType)) + .filter((item) => supportedReportTypes.includes(item.config.exportType as ReportTypeId)) .map((item) => ({ id: item.config.exportType, label: item.config.label,