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

{i18n.SCHEDULED_REPORT_FLYOUT_TITLE}

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

{i18n.CANNOT_LOAD_REPORTING_HEALTH_MESSAGE}

+
+ ) : hasUnmetPrerequisites ? ( + +

{i18n.UNMET_REPORTING_PREREQUISITES_MESSAGE}

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

+ {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION} {reportingPageLink}. +

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

{i18n.SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_MESSAGE}

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

{i18n.SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_MESSAGE}

+
+ + )} +
+
+ )} +
+ {!readOnly && ( + + + + + {i18n.SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL} + + + + + {i18n.SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL} + + + + + )} + + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_share_wrapper.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_share_wrapper.tsx new file mode 100644 index 0000000000000..e981eb85b3136 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/scheduled_report_flyout_share_wrapper.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useShareTypeContext } from '@kbn/share-plugin/public'; +import React, { useMemo } from 'react'; +import { ReportingAPIClient, useKibana } from '@kbn/reporting-public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { ReportingSharingData } from '@kbn/reporting-public/share/share_context_menu'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { supportedReportTypes } from '../report_params'; +import { queryClient } from '../../query_client'; +import type { ReportingPublicPluginSetupDependencies } from '../../plugin'; +import { ScheduledReportFlyoutContent } from './scheduled_report_flyout_content'; +import { ReportTypeId } from '../../types'; + +export interface ScheduledReportMenuItem { + apiClient: ReportingAPIClient; + services: ReportingPublicPluginSetupDependencies; + sharingData: ReportingSharingData; + onClose: () => void; +} + +export const ScheduledReportFlyoutShareWrapper = ({ + apiClient, + services: reportingServices, + sharingData, + onClose, +}: ScheduledReportMenuItem) => { + const upstreamServices = useKibana().services; + const services = useMemo( + () => ({ + ...reportingServices, + ...upstreamServices, + }), + [reportingServices, upstreamServices] + ); + const { shareMenuItems, objectType } = useShareTypeContext('integration', 'export'); + + const availableReportTypes = useMemo(() => { + return shareMenuItems + .filter((item) => supportedReportTypes.includes(item.config.exportType as ReportTypeId)) + .map((item) => ({ + id: item.config.exportType, + label: item.config.label, + })); + }, [shareMenuItems]); + + const scheduledReport = useMemo( + () => ({ + title: sharingData.title, + }), + [sharingData] + ); + + if (!services) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/constants.ts b/x-pack/platform/plugins/private/reporting/public/management/constants.ts new file mode 100644 index 0000000000000..cfd481dac44d3 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const SCHEDULED_REPORT_FORM_ID = 'scheduledReportForm'; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.ts b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.ts new file mode 100644 index 0000000000000..71fec7f635bcb --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; +import moment from 'moment'; + +export const useDefaultTimezone = () => { + const kibanaTz: string = useUiSetting('dateFormat:tz'); + if (!kibanaTz || kibanaTz === 'Browser') { + return { defaultTimezone: moment.tz?.guess() ?? 'UTC', isBrowser: true }; + } + return { defaultTimezone: kibanaTz, isBrowser: false }; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.ts b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.ts new file mode 100644 index 0000000000000..13e2326138b8a --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import { HttpSetup } from '@kbn/core/public'; +import { getReportingHealth } from '../apis/get_reporting_health'; +import { queryKeys } from '../query_keys'; + +export const getKey = queryKeys.getHealth; + +export const useGetReportingHealthQuery = ({ http }: { http: HttpSetup }) => { + return useQuery({ + queryKey: getKey(), + queryFn: () => getReportingHealth({ http }), + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.ts b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.ts new file mode 100644 index 0000000000000..5a1408fefc84f --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from '@kbn/core/public'; +import { useMutation } from '@tanstack/react-query'; +import { mutationKeys } from '../mutation_keys'; +import { scheduleReport, ScheduleReportRequestParams } from '../apis/schedule_report'; + +export const getKey = mutationKeys.scheduleReport; + +export const useScheduleReport = ({ http }: { http: HttpSetup }) => { + return useMutation({ + mutationKey: getKey(), + mutationFn: (params: ScheduleReportRequestParams) => scheduleReport({ http, params }), + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/integrations/scheduled_report_share_integration.tsx b/x-pack/platform/plugins/private/reporting/public/management/integrations/scheduled_report_share_integration.tsx new file mode 100644 index 0000000000000..fa7fb54c8957c --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/integrations/scheduled_report_share_integration.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { ShareContext } from '@kbn/share-plugin/public'; +import type { ExportShareDerivatives } from '@kbn/share-plugin/public/types'; +import type { ReportingSharingData } from '@kbn/reporting-public/share/share_context_menu'; +import { EuiButton } from '@elastic/eui'; +import type { ReportingAPIClient } from '@kbn/reporting-public'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { SCHEDULED_REPORT_VALID_LICENSES } from '@kbn/reporting-common'; +import { getKey as getReportingHealthQueryKey } from '../hooks/use_get_reporting_health_query'; +import { queryClient } from '../../query_client'; +import { ScheduledReportFlyoutShareWrapper } from '../components/scheduled_report_flyout_share_wrapper'; +import { SCHEDULE_EXPORT_BUTTON_LABEL } from '../translations'; +import type { ReportingPublicPluginSetupDependencies } from '../../plugin'; +import { getReportingHealth } from '../apis/get_reporting_health'; + +export interface CreateScheduledReportProviderOptions { + apiClient: ReportingAPIClient; + services: ReportingPublicPluginSetupDependencies; +} + +export const shouldRegisterScheduledReportShareIntegration = async (http: HttpSetup) => { + const { isSufficientlySecure, hasPermanentEncryptionKey } = await queryClient.fetchQuery({ + queryKey: getReportingHealthQueryKey(), + queryFn: () => getReportingHealth({ http }), + }); + return isSufficientlySecure && hasPermanentEncryptionKey; +}; + +export const createScheduledReportShareIntegration = ({ + apiClient, + services, +}: CreateScheduledReportProviderOptions): ExportShareDerivatives => { + return { + id: 'scheduledReports', + groupId: 'exportDerivatives', + shareType: 'integration', + config: (shareOpts: ShareContext): ReturnType => { + const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData }; + return { + label: ({ openFlyout }) => ( + + {SCHEDULE_EXPORT_BUTTON_LABEL} + + ), + flyoutContent: ({ closeFlyout }) => { + return ( + + ); + }, + flyoutSizing: { size: 'm', maxWidth: 500 }, + }; + }, + prerequisiteCheck: ({ license }) => { + if (!license || !license.type) { + return false; + } + return SCHEDULED_REPORT_VALID_LICENSES.includes(license.type); + }, + }; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx b/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx index 4352557e57617..986a34d3c55b6 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx @@ -8,7 +8,7 @@ import * as React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import type { CoreStart } from '@kbn/core/public'; +import type { CoreStart, NotificationsStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; @@ -21,25 +21,43 @@ import { ReportingAPIClient, KibanaContext, } from '@kbn/reporting-public'; +import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from '../query_client'; import { ReportListing } from '.'; import { PolicyStatusContextProvider } from '../lib/default_status_context'; -export async function mountManagementSection( - coreStart: CoreStart, - license$: LicensingPluginStart['license$'], - dataService: DataPublicPluginStart, - shareService: SharePluginStart, - config: ClientConfigType, - apiClient: ReportingAPIClient, - params: ManagementAppMountParams -) { +export async function mountManagementSection({ + coreStart, + license$, + dataService, + shareService, + config, + apiClient, + params, + actionsService, + notificationsService, +}: { + coreStart: CoreStart; + license$: LicensingPluginStart['license$']; + dataService: DataPublicPluginStart; + shareService: SharePluginStart; + config: ClientConfigType; + apiClient: ReportingAPIClient; + params: ManagementAppMountParams; + actionsService: ActionsPublicPluginSetup; + notificationsService: NotificationsStart; +}) { const services: KibanaContext = { http: coreStart.http, application: coreStart.application, + settings: coreStart.settings, uiSettings: coreStart.uiSettings, docLinks: coreStart.docLinks, data: dataService, share: shareService, + actions: actionsService, + notifications: notificationsService, }; render( @@ -47,15 +65,17 @@ export async function mountManagementSection( - + + + diff --git a/x-pack/platform/plugins/private/reporting/public/management/mutation_keys.ts b/x-pack/platform/plugins/private/reporting/public/management/mutation_keys.ts new file mode 100644 index 0000000000000..b1d2acc130f74 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/mutation_keys.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mutationKeys = { + root: 'reporting', + scheduleReport: () => [mutationKeys.root, 'scheduleReport'] as const, +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts b/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts new file mode 100644 index 0000000000000..efa86cccb03bb --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const queryKeys = { + root: 'reporting', + getHealth: () => [queryKeys.root, 'health'] as const, +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/report_params.ts b/x-pack/platform/plugins/private/reporting/public/management/report_params.ts new file mode 100644 index 0000000000000..3221e4ae5c240 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/report_params.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import rison from '@kbn/rison'; +import { + getPdfReportParams, + getPngReportParams, +} from '@kbn/reporting-public/share/share_context_menu/register_pdf_png_modal_reporting'; +import { getCsvReportParams } from '@kbn/reporting-public/share/share_context_menu/register_csv_modal_reporting'; +import type { ReportingAPIClient } from '@kbn/reporting-public'; +import type { ReportTypeId } from '../types'; + +const reportParamsProviders = { + pngV2: getPngReportParams, + printablePdfV2: getPdfReportParams, + csv_searchsource: getCsvReportParams, +} as const; + +export const supportedReportTypes = Object.keys(reportParamsProviders) as ReportTypeId[]; + +export interface GetReportParamsOptions { + apiClient: ReportingAPIClient; + reportTypeId: ReportTypeId; + objectType: string; + sharingData: any; + title: string; +} + +export const getReportParams = ({ + apiClient, + reportTypeId, + objectType, + sharingData, + title, +}: GetReportParamsOptions) => { + const getParams = reportParamsProviders[reportTypeId]; + if (!getParams) { + throw new Error(`No params provider found for report type ${reportTypeId}`); + } + return rison.encode( + apiClient.getDecoratedJobParams({ + ...getParams({ + objectType, + sharingData, + }), + objectType, + title, + }) + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/schemas/scheduled_report_form_schema.ts b/x-pack/platform/plugins/private/reporting/public/management/schemas/scheduled_report_form_schema.ts new file mode 100644 index 0000000000000..2f1cee3e75f67 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/schemas/scheduled_report_form_schema.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { getRecurringScheduleFormSchema } from '@kbn/response-ops-recurring-schedule-form/schemas/recurring_schedule_form_schema'; +import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { ReportTypeData, ReportTypeId } from '../../types'; +import { getEmailsValidator } from '../validators/emails_validator'; +import * as i18n from '../translations'; + +const { emptyField } = fieldValidators; + +export const getScheduledReportFormSchema = ( + validateEmailAddresses: ActionsPublicPluginSetup['validateEmailAddresses'], + availableReportTypes?: ReportTypeData[] +) => ({ + title: { + type: FIELD_TYPES.TEXT, + label: i18n.SCHEDULED_REPORT_FORM_FILE_NAME_LABEL, + validations: [ + { + validator: emptyField(i18n.SCHEDULED_REPORT_FORM_FILE_NAME_REQUIRED_MESSAGE), + }, + ], + }, + reportTypeId: { + type: FIELD_TYPES.SUPER_SELECT, + label: i18n.SCHEDULED_REPORT_FORM_FILE_TYPE_LABEL, + defaultValue: (availableReportTypes?.[0]?.id as ReportTypeId) ?? '', + validations: [ + { + validator: emptyField(i18n.SCHEDULED_REPORT_FORM_FILE_TYPE_REQUIRED_MESSAGE), + }, + ], + }, + recurringSchedule: getRecurringScheduleFormSchema({ allowInfiniteRecurrence: false }), + sendByEmail: { + type: FIELD_TYPES.TOGGLE, + label: i18n.SCHEDULED_REPORT_FORM_SEND_BY_EMAIL_LABEL, + defaultValue: false, + }, + emailRecipients: { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_LABEL, + defaultValue: [], + validations: [ + { + validator: emptyField(i18n.SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_REQUIRED_MESSAGE), + }, + { + isBlocking: false, + validator: getEmailsValidator(validateEmailAddresses), + }, + ], + }, +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx b/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx index 3e7a3c8cb10fd..910a32f7a5aed 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx @@ -32,6 +32,7 @@ export const ReportListingStateful: FC = (props) => { const ilmPolicyContextValue = useIlmPolicyStatus(); const hasIlmPolicy = ilmPolicyContextValue?.status !== 'policy-not-found'; const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); + return ( <> {}, + }, +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/translations.ts b/x-pack/platform/plugins/private/reporting/public/management/translations.ts new file mode 100644 index 0000000000000..e2771b76c068d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/translations.ts @@ -0,0 +1,297 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SCHEDULE_EXPORT_BUTTON_LABEL = i18n.translate( + 'xpack.reporting.scheduleExportButtonLabel', + { + defaultMessage: 'Schedule export', + } +); + +export const SCHEDULED_REPORT_FLYOUT_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingFlyout.title', + { + defaultMessage: 'Schedule exports', + } +); + +export const SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingFlyout.submitButtonLabel', + { + defaultMessage: 'Schedule exports', + } +); + +export const SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingFlyout.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_NAME_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileNameLabel', + { + defaultMessage: 'Report name', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_NAME_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileNameRequiredMessage', + { + defaultMessage: 'Report file name is required', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_TYPE_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileTypeLabel', + { + defaultMessage: 'File type', + } +); + +export const SCHEDULED_REPORT_FORM_OPTIMIZED_FOR_PRINTING_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.optimizedForPrintingLabel', + { + defaultMessage: 'Print format', + } +); + +export const SCHEDULED_REPORT_FORM_OPTIMIZED_FOR_PRINTING_DESCRIPTION = i18n.translate( + 'xpack.reporting.scheduledReportingForm.optimizedForDescription', + { + defaultMessage: 'Uses multiple pages, showing at most 2 visualizations per page', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_TYPE_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileTypeRequiredMessage', + { + defaultMessage: 'File type is required', + } +); + +export const SCHEDULED_REPORT_FORM_START_DATE_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.startDateLabel', + { + defaultMessage: 'Date', + } +); + +export const SCHEDULED_REPORT_FORM_START_DATE_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.startDateRequiredMessage', + { + defaultMessage: 'Date is required', + } +); + +export const SCHEDULED_REPORT_FORM_START_DATE_TOO_EARLY_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.startDateTooEarlyMessage', + { + defaultMessage: 'Start date must be in the future', + } +); + +export const SCHEDULED_REPORT_FORM_TIMEZONE_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.timezoneLabel', + { + defaultMessage: 'Timezone', + } +); +export const SCHEDULED_REPORT_FORM_TIMEZONE_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.timezoneRequiredMessage', + { + defaultMessage: 'Timezone is required', + } +); + +export const SCHEDULED_REPORT_FORM_FILE_NAME_SUFFIX = i18n.translate( + 'xpack.reporting.scheduledReportingForm.fileNameSuffix', + { + defaultMessage: '+ @timestamp', + } +); + +export const SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.detailsSectionTitle', + { + defaultMessage: 'Details', + } +); + +export const SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.scheduleSectionTitle', + { + defaultMessage: 'Schedule', + } +); + +export const SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.exportsSectionTitle', + { + defaultMessage: 'Exports', + } +); + +export const SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION = i18n.translate( + 'xpack.reporting.scheduledReportingForm.exportsSectionDescription', + { + defaultMessage: + "On the scheduled date, we'll create a snapshot of this data point and will post the downloadable report on the ", + } +); + +export const REPORTING_PAGE_LINK_TEXT = i18n.translate( + 'xpack.reporting.scheduledReportingForm.reportingPageLinkText', + { + defaultMessage: 'Reporting page', + } +); + +export const SCHEDULED_REPORT_FORM_RECURRING_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.recurringLabel', + { + defaultMessage: 'Make recurring', + } +); + +export const SCHEDULED_REPORT_FORM_SEND_BY_EMAIL_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.sendByEmailLabel', + { + defaultMessage: 'Send by email', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailRecipientsLabel', + { + defaultMessage: 'To', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_REQUIRED_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailRecipientsRequiredMessage', + { + defaultMessage: 'Provide at least one recipient', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_HINT = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailRecipientsHint', + { + defaultMessage: + "On the scheduled date, we'll also email the report to the addresses you specify here.", + } +); + +export const SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.missingEmailConnectorTitle', + { + defaultMessage: "Email connector hasn't been created yet", + } +); + +export const SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.missingEmailConnectorMessage', + { + defaultMessage: 'A default email connector must be configured in order to send notifications.', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailSensitiveInfoTitle', + { + defaultMessage: 'Sensitive information', + } +); + +export const SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailSensitiveInfoMessage', + { + defaultMessage: 'Report may contain sensitive information', + } +); + +export const SCHEDULED_REPORT_FORM_SUCCESS_TOAST_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.successToastTitle', + { + defaultMessage: 'Export scheduled', + } +); + +export const SCHEDULED_REPORT_FORM_CREATE_EMAIL_CONNECTOR_LABEL = i18n.translate( + 'xpack.reporting.scheduledReportingForm.createEmailConnectorLabel', + { + defaultMessage: 'Create Email connector', + } +); + +export const SCHEDULED_REPORT_FORM_SUCCESS_TOAST_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.successToastMessage', + { + defaultMessage: 'Find your schedule information and your exports in the ', + } +); + +export const SCHEDULED_REPORT_FORM_FAILURE_TOAST_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.failureToastTitle', + { + defaultMessage: 'Schedule error', + } +); + +export const SCHEDULED_REPORT_FORM_FAILURE_TOAST_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.failureToastMessage', + { + defaultMessage: 'Sorry, we couldn’t schedule your export. Please try again.', + } +); + +export const CANNOT_LOAD_REPORTING_HEALTH_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.cannotLoadReportingHealthTitle', + { + defaultMessage: 'Cannot load reporting health', + } +); + +export const UNMET_REPORTING_PREREQUISITES_TITLE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.unmetReportingPrerequisitesTitle', + { + defaultMessage: 'Cannot schedule reports', + } +); + +export const UNMET_REPORTING_PREREQUISITES_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.unmetReportingPrerequisitesMessage', + { + defaultMessage: + 'One or more prerequisites for scheduling reports was not met. Contact your administrator to know more.', + } +); + +export const CANNOT_LOAD_REPORTING_HEALTH_MESSAGE = i18n.translate( + 'xpack.reporting.scheduledReportingForm.cannotLoadReportingHealthMessage', + { + defaultMessage: 'Reporting health is a prerequisite to create scheduled exports', + } +); + +export function getInvalidEmailAddress(email: string) { + return i18n.translate('xpack.reporting.components.email.error.invalidEmail', { + defaultMessage: 'Email address {email} is not valid', + values: { email }, + }); +} + +export function getNotAllowedEmailAddress(email: string) { + return i18n.translate('xpack.reporting.components.email.error.notAllowed', { + defaultMessage: 'Email address {email} is not allowed', + values: { email }, + }); +} diff --git a/x-pack/platform/plugins/private/reporting/public/management/utils.ts b/x-pack/platform/plugins/private/reporting/public/management/utils.ts index cf1698bbd9fea..3e9098e0c9a3a 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/utils.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/utils.ts @@ -8,6 +8,18 @@ import type { IconType } from '@elastic/eui'; import { JOB_STATUS } from '@kbn/reporting-common'; import { Job } from '@kbn/reporting-public'; +import type { Rrule } from '@kbn/task-manager-plugin/server/task'; +import { Frequency } from '@kbn/rrule'; +import type { + RecurrenceFrequency, + RecurringSchedule, +} from '@kbn/response-ops-recurring-schedule-form/types'; +import { + RRULE_TO_ISO_WEEKDAYS, + RecurrenceEnd, +} from '@kbn/response-ops-recurring-schedule-form/constants'; +import { ScheduledReportApiJSON } from '@kbn/reporting-common/types'; +import type { ScheduledReport } from '../types'; /** * This is not the most forward-compatible way of mapping to an {@link IconType} for an application. @@ -47,3 +59,76 @@ export const jobHasIssues = (job: Job): boolean => { [JOB_STATUS.WARNINGS, JOB_STATUS.FAILED].some((status) => job.status === status) ); }; + +const isCustomRrule = (rRule: Rrule) => { + const freq = rRule.freq; + // interval is greater than 1 + if (rRule.interval && rRule.interval > 1) { + return true; + } + // frequency is daily and no weekdays are selected + if (freq && freq === Frequency.DAILY && !rRule.byweekday) { + return true; + } + // frequency is weekly and there are multiple weekdays selected + if (freq && freq === Frequency.WEEKLY && rRule.byweekday && rRule.byweekday.length > 1) { + return true; + } + // frequency is monthly and by month day is selected + if (freq && freq === Frequency.MONTHLY && rRule.bymonthday) { + return true; + } + return false; +}; + +export const transformScheduledReport = (report: ScheduledReportApiJSON): ScheduledReport => { + const { title, schedule, notification } = report; + const rRule = schedule.rrule; + + const isCustomFrequency = isCustomRrule(rRule); + const frequency = rRule.freq as RecurrenceFrequency; + + const recurringSchedule: RecurringSchedule = { + frequency: isCustomFrequency ? 'CUSTOM' : frequency, + interval: rRule.interval, + ends: RecurrenceEnd.NEVER, + }; + + if (isCustomFrequency) { + recurringSchedule.customFrequency = frequency; + } + + if (frequency !== Frequency.MONTHLY && rRule.byweekday) { + recurringSchedule.byweekday = rRule.byweekday.reduce>((acc, day) => { + const isoWeekDay = RRULE_TO_ISO_WEEKDAYS[day]; + if (isoWeekDay != null) { + acc[isoWeekDay] = true; + } + return acc; + }, {}); + } + if (frequency === Frequency.MONTHLY) { + if (rRule.byweekday?.length) { + recurringSchedule.bymonth = 'weekday'; + recurringSchedule.bymonthweekday = rRule.byweekday[0]; + } else if (rRule.bymonthday?.length) { + recurringSchedule.bymonth = 'day'; + recurringSchedule.bymonthday = rRule.bymonthday[0]; + } + } + + if (rRule.byhour?.length && rRule.byminute?.length) { + recurringSchedule.byhour = rRule.byhour[0]; + recurringSchedule.byminute = rRule.byminute[0]; + } + + return { + title, + recurringSchedule, + reportTypeId: report.jobtype as ScheduledReport['reportTypeId'], + timezone: schedule.rrule.tzid, + recurring: true, + sendByEmail: Boolean(notification?.email), + emailRecipients: [...(notification?.email?.to || [])], + }; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.ts b/x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.ts new file mode 100644 index 0000000000000..04fac1ed67a07 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { InvalidEmailReason } from '@kbn/actions-plugin/common'; +import type { ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import { getInvalidEmailAddress, getNotAllowedEmailAddress } from '../translations'; +import type { ScheduledReport } from '../../types'; + +export const getEmailsValidator = + ( + validateEmailAddresses: ActionsPublicPluginSetup['validateEmailAddresses'] + ): ValidationFunc => + ({ value, path }) => { + const validatedEmails = validateEmailAddresses(Array.isArray(value) ? value : [value]); + for (const validatedEmail of validatedEmails) { + if (!validatedEmail.valid) { + return { + path, + message: + validatedEmail.reason === InvalidEmailReason.notAllowed + ? getNotAllowedEmailAddress(value) + : getInvalidEmailAddress(value), + }; + } + } + }; diff --git a/x-pack/platform/plugins/private/reporting/public/management/validators/start_date_validator.ts b/x-pack/platform/plugins/private/reporting/public/management/validators/start_date_validator.ts new file mode 100644 index 0000000000000..4555b4f1edf9e --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/validators/start_date_validator.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Moment } from 'moment'; +import { ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { SCHEDULED_REPORT_FORM_START_DATE_TOO_EARLY_MESSAGE } from '../translations'; +import { ScheduledReport } from '../../types'; + +export const getStartDateValidator = + (today: Moment): ValidationFunc => + ({ value }) => { + if (value.isBefore(today)) { + return { + message: SCHEDULED_REPORT_FORM_START_DATE_TOO_EARLY_MESSAGE, + }; + } + }; diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 4ba306e084302..d9589d650ffb8 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -15,7 +15,12 @@ import { i18n } from '@kbn/i18n'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public'; -import type { SharePluginSetup, SharePluginStart, ExportShare } from '@kbn/share-plugin/public'; +import type { + SharePluginSetup, + SharePluginStart, + ExportShare, + ExportShareDerivatives, +} from '@kbn/share-plugin/public'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { durationToNumber } from '@kbn/reporting-common'; @@ -30,6 +35,7 @@ import { } from '@kbn/reporting-public/share'; import { ReportingCsvPanelAction } from '@kbn/reporting-csv-share-panel'; import { InjectedIntl } from '@kbn/i18n-react'; +import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import type { ReportingSetup, ReportingStart } from '.'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; import { StartServices } from './types'; @@ -41,6 +47,7 @@ export interface ReportingPublicPluginSetupDependencies { screenshotMode: ScreenshotModePluginSetup; share: SharePluginSetup; intl: InjectedIntl; + actions: ActionsPublicPluginSetup; } export interface ReportingPublicPluginStartDependencies { @@ -108,6 +115,7 @@ export class ReportingPublicPlugin screenshotMode: screenshotModeSetup, share: shareSetup, uiActions: uiActionsSetup, + actions: actionsSetup, } = setupDeps; const startServices$: Observable = from(getStartServices()).pipe( @@ -157,15 +165,17 @@ export class ReportingPublicPlugin const { docTitle } = coreStart.chrome; docTitle.change(this.title); - const umountAppCallback = await mountManagementSection( + const umountAppCallback = await mountManagementSection({ coreStart, - licensing.license$, - data, - share, - this.config, + license$: licensing.license$, + dataService: data, + shareService: share, + config: this.config, apiClient, - params - ); + params, + actionsService: actionsSetup, + notificationsService: coreStart.notifications, + }); return () => { docTitle.reset(); @@ -234,6 +244,22 @@ export class ReportingPublicPlugin ); } + import('./management/integrations/scheduled_report_share_integration').then( + async ({ + shouldRegisterScheduledReportShareIntegration, + createScheduledReportShareIntegration, + }) => { + if (await shouldRegisterScheduledReportShareIntegration(core.http)) { + shareSetup.registerShareIntegration( + createScheduledReportShareIntegration({ + apiClient, + services: { ...core, ...setupDeps }, + }) + ); + } + } + ); + this.startServices$ = startServices$; return this.getContract(apiClient, startServices$); } diff --git a/x-pack/platform/plugins/private/reporting/public/query_client.ts b/x-pack/platform/plugins/private/reporting/public/query_client.ts new file mode 100644 index 0000000000000..83745302b278f --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/query_client.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/x-pack/platform/plugins/private/reporting/public/types.ts b/x-pack/platform/plugins/private/reporting/public/types.ts index 986cbcdab06fa..d69136efc1e47 100644 --- a/x-pack/platform/plugins/private/reporting/public/types.ts +++ b/x-pack/platform/plugins/private/reporting/public/types.ts @@ -8,6 +8,7 @@ import type { CoreStart } from '@kbn/core/public'; import { JOB_STATUS } from '@kbn/reporting-common'; import type { JobId, ReportOutput, ReportSource, TaskRunResult } from '@kbn/reporting-common/types'; +import { RecurringSchedule } from '@kbn/response-ops-recurring-schedule-form/types'; import { ReportingPublicPluginStartDependencies } from './plugin'; /* @@ -49,3 +50,28 @@ export interface JobSummarySet { completed?: JobSummary[]; failed?: JobSummary[]; } + +export type ReportTypeId = 'pngV2' | 'printablePdfV2' | 'csv_searchsource'; + +export interface ScheduledReport { + title: string; + reportTypeId: ReportTypeId; + optimizedForPrinting?: boolean; + recurring: boolean; + recurringSchedule: RecurringSchedule; + sendByEmail: boolean; + emailRecipients: string[]; + /** + * @internal Still unsupported by the schedule API + */ + startDate?: string; + /** + * @internal Still unsupported by the schedule API + */ + timezone?: string; +} + +export interface ReportTypeData { + label: string; + id: string; +} diff --git a/x-pack/platform/plugins/private/reporting/tsconfig.json b/x-pack/platform/plugins/private/reporting/tsconfig.json index fcd2883be3b6d..97f9602f4a192 100644 --- a/x-pack/platform/plugins/private/reporting/tsconfig.json +++ b/x-pack/platform/plugins/private/reporting/tsconfig.json @@ -58,6 +58,9 @@ "@kbn/notifications-plugin", "@kbn/spaces-utils", "@kbn/logging-mocks", + "@kbn/core-http-browser", + "@kbn/response-ops-recurring-schedule-form", + "@kbn/core-mount-utils-browser-internal", ], "exclude": [ "target/**/*", diff --git a/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx b/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx index dc9561083a6dd..2670993aa2e2b 100644 --- a/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx +++ b/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx @@ -167,11 +167,11 @@ export const CreateMaintenanceWindowForm = React.memo = React > {i18n.CREATE_FORM_RECURRING_SUMMARY_PREFIX( - recurringSummary(startDate, recurringSchedule, presets) + recurringSummary({ startDate, recurringSchedule, presets }) )} diff --git a/x-pack/test/functional/apps/discover/group1/reporting.ts b/x-pack/test/functional/apps/discover/group1/reporting.ts index ae66cbee3ebeb..314b9e835e09a 100644 --- a/x-pack/test/functional/apps/discover/group1/reporting.ts +++ b/x-pack/test/functional/apps/discover/group1/reporting.ts @@ -103,7 +103,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await toasts.dismissAll(); await exports.clickExportTopNavButton(); + await reporting.selectExportItem('CSV'); await reporting.clickGenerateReportButton(); + await exports.closeExportFlyout(); + await exports.clickExportTopNavButton(); const url = await reporting.getReportURL(timeout); const res = await reporting.getResponse(url ?? ''); @@ -116,6 +119,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const getReportPostUrl = async () => { // click 'Copy POST URL' await exports.clickExportTopNavButton(); + await reporting.selectExportItem('CSV'); + await reporting.clickGenerateReportButton(); await reporting.copyReportingPOSTURLValueToClipboard(); const clipboardValue = decodeURIComponent(await browser.getClipboardValue()); @@ -141,15 +146,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await reporting.openExportPopover(); - expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); - await exports.closeExportFlyout(); + expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); + await reporting.openExportPopover(); }); it('becomes available when saved', async () => { await discover.saveSearch('my search - expectEnabledGenerateReportButton'); await reporting.openExportPopover(); - expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); - await exports.closeExportFlyout(); + expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); + await reporting.openExportPopover(); }); }); diff --git a/x-pack/test/functional/apps/discover/group2/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/group2/feature_controls/discover_security.ts index 566c382a1b76d..0cfb8e58d2ff1 100644 --- a/x-pack/test/functional/apps/discover/group2/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/group2/feature_controls/discover_security.ts @@ -142,6 +142,7 @@ export default function (ctx: FtrProviderContext) { it('shows CSV reports', async () => { await exports.clickExportTopNavButton(); + await exports.clickPopoverItem('CSV'); await testSubjects.existOrFail('generateReportButton'); await exports.closeExportFlyout(); }); diff --git a/x-pack/test/reporting_functional/services/scenarios.ts b/x-pack/test/reporting_functional/services/scenarios.ts index b526a1b26dfb1..14f1e29e208ff 100644 --- a/x-pack/test/reporting_functional/services/scenarios.ts +++ b/x-pack/test/reporting_functional/services/scenarios.ts @@ -114,6 +114,7 @@ export function createScenarios( const tryDiscoverCsvSuccess = async () => { await PageObjects.reporting.openExportPopover(); + await PageObjects.exports.clickPopoverItem('CSV'); expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); }; const tryGeneratePdfFail = async () => { diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts b/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts index 72954ec033c93..03a1ac4f49e8e 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts @@ -34,8 +34,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // close any open notification toasts await toasts.dismissAll(); - await PageObjects.reporting.openExportPopover(); + await PageObjects.exports.clickExportTopNavButton(); + await PageObjects.reporting.selectExportItem('CSV'); await PageObjects.reporting.clickGenerateReportButton(); + await PageObjects.exports.closeExportFlyout(); + await PageObjects.exports.clickExportTopNavButton(); const url = await PageObjects.reporting.getReportURL(timeout); // TODO: Fetch CSV client side in Serverless since `PageObjects.reporting.getResponse()` @@ -90,7 +93,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('is available if new', async () => { await PageObjects.reporting.openExportPopover(); - expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); + expect(await PageObjects.exports.isPopoverItemEnabled('CSV')).to.be(true); + await PageObjects.reporting.openExportPopover(); }); it('becomes available when saved', async () => { @@ -99,7 +103,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { true ); await PageObjects.reporting.openExportPopover(); - expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); + expect(await PageObjects.exports.isPopoverItemEnabled('CSV')).to.be(true); + await PageObjects.reporting.openExportPopover(); }); }); From 0eefc32e7548e4126146045f6f451aa27cc631bf Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:01:09 +0100 Subject: [PATCH 02/13] [ResponseOps] [Reporting] Allow users to see scheduled reports list (#224354) ## Summary Resolves https://github.com/elastic/kibana/issues/216322 > [!IMPORTANT] > This PR is targeting the `scheduled-reports-ui` feature branch, where the backend changes from the `scheduled-reports` branch are temporarily integrated while waiting for https://github.com/elastic/kibana/pull/221028 to be merged (see the squashed `[TMP] ...` commit message). > This PR has few commits from https://github.com/elastic/kibana/pull/222135 to support view schedule config table action. Please ignore code changes in folders if you are already reviewing other PR. `src/platform/packages/private/kbn-reporting/public/share/share_context_menu`, `src/platform/packages/shared/response-ops/recurring-schedule-form`, `x-pack/platform/plugins/private/reporting/public/management/schemas`, `x-pack/platform/plugins/private/reporting/public/management/validators` and `xpack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx`. This PR adds new tabs `Exports` and `Schedules` in Reporting section. `Exports` tab shows the list of all reports. Allows to open dashboard, download report and view report information. `Schedules` tab shows list of scheduled reports. Allows to disable schedule. ### Checklist Check the PR satisfies following conditions. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### What to test - Verify tabs - Verify exports table shows list of all reports - Verify schedules table shows list of scheduled report - Verify you can disable scheduled report - Verify pagination in both tables --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../private/kbn-reporting/common/constants.ts | 2 + .../private/kbn-reporting/public/job.tsx | 2 + .../public/reporting_api_client.test.ts | 21 ++ .../public/reporting_api_client.ts | 22 +- .../private/reporting/common/test/fixtures.ts | 74 +++- .../private/reporting/public/constants.ts | 15 + .../public/lib/ilm_policy_status_context.tsx | 10 +- .../__test__/report_listing.test.helpers.tsx | 28 +- .../apis/bulk_disable_scheduled_reports.ts | 25 ++ .../apis/get_scheduled_reports_list.ts | 48 +++ .../disable_report_confirmation_modal.tsx | 52 +++ .../management/components/ilm_policy_link.tsx | 12 +- .../management/components/license_prompt.tsx | 76 ++++ .../migrate_ilm_policy_callout/index.tsx | 2 +- .../components/report_diagnostic.tsx | 12 +- .../components/report_exports_table.test.tsx | 106 ++++++ .../report_exports_table.tsx} | 120 ++++--- .../components/report_schedule_indicator.tsx | 51 +++ .../report_schedules_table.test.tsx | 244 +++++++++++++ .../components/report_schedules_table.tsx | 330 ++++++++++++++++++ .../components/reporting_tabs.test.tsx | 276 +++++++++++++++ .../management/components/reporting_tabs.tsx | 225 ++++++++++++ .../components/scheduled_report_flyout.tsx | 9 +- .../scheduled_report_flyout_content.tsx | 4 +- .../suspended_component_with_props.tsx | 21 ++ .../management/components/truncated_title.tsx | 35 ++ .../default/report_listing_default.tsx | 50 --- .../hooks/use_bulk_disable.test.tsx | 76 ++++ .../management/hooks/use_bulk_disable.tsx | 48 +++ .../hooks/use_get_scheduled_list.test.tsx | 48 +++ .../hooks/use_get_scheduled_list.tsx | 28 ++ .../reporting/public/management/index.ts | 5 +- .../management/mount_management_section.tsx | 53 ++- .../reporting/public/management/query_keys.ts | 9 +- .../public/management/report_listing.test.ts | 289 --------------- .../public/management/report_listing.tsx | 26 -- .../stateful/report_listing_stateful.tsx | 88 ----- .../private/reporting/public/plugin.ts | 13 +- .../public/redirect/redirect_app.tsx | 12 +- .../private/reporting/public/translations.ts | 30 ++ .../plugins/private/reporting/tsconfig.json | 12 +- .../translations/translations/fr-FR.json | 21 -- .../translations/translations/ja-JP.json | 21 -- .../translations/translations/zh-CN.json | 21 -- .../reporting_and_security/management.ts | 30 +- 45 files changed, 2082 insertions(+), 620 deletions(-) create mode 100644 x-pack/platform/plugins/private/reporting/public/constants.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/apis/bulk_disable_scheduled_reports.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/apis/get_scheduled_reports_list.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/disable_report_confirmation_modal.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx rename x-pack/platform/plugins/private/reporting/public/management/{report_listing_table.tsx => components/report_exports_table.tsx} (77%) create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/report_schedule_indicator.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/suspended_component_with_props.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/components/truncated_title.tsx delete mode 100644 x-pack/platform/plugins/private/reporting/public/management/default/report_listing_default.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.tsx delete mode 100644 x-pack/platform/plugins/private/reporting/public/management/report_listing.test.ts delete mode 100644 x-pack/platform/plugins/private/reporting/public/management/report_listing.tsx delete mode 100644 x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/translations.ts diff --git a/src/platform/packages/private/kbn-reporting/common/constants.ts b/src/platform/packages/private/kbn-reporting/common/constants.ts index 9803499f777ed..093868282a724 100644 --- a/src/platform/packages/private/kbn-reporting/common/constants.ts +++ b/src/platform/packages/private/kbn-reporting/common/constants.ts @@ -74,6 +74,8 @@ export const REPORTING_REDIRECT_LOCATOR_STORE_KEY = '__REPORTING_REDIRECT_LOCATO // Management UI route export const REPORTING_MANAGEMENT_HOME = '/app/management/insightsAndAlerting/reporting'; +export const REPORTING_MANAGEMENT_SCHEDULES = + '/app/management/insightsAndAlerting/reporting/schedules'; /* * ILM diff --git a/src/platform/packages/private/kbn-reporting/public/job.tsx b/src/platform/packages/private/kbn-reporting/public/job.tsx index 3b4822363733e..bcb001e06f300 100644 --- a/src/platform/packages/private/kbn-reporting/public/job.tsx +++ b/src/platform/packages/private/kbn-reporting/public/job.tsx @@ -79,6 +79,7 @@ export class Job { public readonly queue_time_ms?: Required['queue_time_ms'][number]; public readonly execution_time_ms?: Required['execution_time_ms'][number]; + public readonly scheduled_report_id?: ReportSource['scheduled_report_id']; constructor(report: ReportApiJSON) { this.id = report.id; @@ -117,6 +118,7 @@ export class Job { this.metrics = report.metrics; this.queue_time_ms = report.queue_time_ms; this.execution_time_ms = report.execution_time_ms; + this.scheduled_report_id = report.scheduled_report_id; } public isSearch() { diff --git a/src/platform/packages/private/kbn-reporting/public/reporting_api_client.test.ts b/src/platform/packages/private/kbn-reporting/public/reporting_api_client.test.ts index 3504ed87956d4..c655715a9b9d6 100644 --- a/src/platform/packages/private/kbn-reporting/public/reporting_api_client.test.ts +++ b/src/platform/packages/private/kbn-reporting/public/reporting_api_client.test.ts @@ -118,6 +118,27 @@ describe('ReportingAPIClient', () => { }); }); + describe('getScheduledReportInfo', () => { + beforeEach(() => { + httpClient.get.mockResolvedValueOnce({ data: [{ id: '123', title: 'Scheduled Report 1' }] }); + }); + + it('should send a get request', async () => { + await apiClient.getScheduledReportInfo('123'); + + expect(httpClient.get).toHaveBeenCalledWith( + expect.stringContaining('/internal/reporting/scheduled/list') + ); + }); + + it('should return a report', async () => { + await expect(apiClient.getScheduledReportInfo('123')).resolves.toEqual({ + id: '123', + title: 'Scheduled Report 1', + }); + }); + }); + describe('getError', () => { it('should get an error message', async () => { httpClient.get.mockResolvedValueOnce({ diff --git a/src/platform/packages/private/kbn-reporting/public/reporting_api_client.ts b/src/platform/packages/private/kbn-reporting/public/reporting_api_client.ts index 66f5daa0c520a..41c7947d6726b 100644 --- a/src/platform/packages/private/kbn-reporting/public/reporting_api_client.ts +++ b/src/platform/packages/private/kbn-reporting/public/reporting_api_client.ts @@ -18,7 +18,13 @@ import { buildKibanaPath, REPORTING_REDIRECT_APP, } from '@kbn/reporting-common'; -import { BaseParams, JobId, ManagementLinkFn, ReportApiJSON } from '@kbn/reporting-common/types'; +import { + BaseParams, + JobId, + ManagementLinkFn, + ReportApiJSON, + ScheduledReportApiJSON, +} from '@kbn/reporting-common/types'; import rison from '@kbn/rison'; import moment from 'moment'; import { stringify } from 'query-string'; @@ -83,7 +89,10 @@ export class ReportingAPIClient implements IReportingAPI { } public getKibanaAppHref(job: Job): string { - const searchParams = stringify({ jobId: job.id }); + const searchParams = stringify({ + jobId: job.id, + ...(job.scheduled_report_id ? { scheduledReportId: job.scheduled_report_id } : {}), + }); const path = buildKibanaPath({ basePath: this.http.basePath.serverBasePath, @@ -158,6 +167,15 @@ export class ReportingAPIClient implements IReportingAPI { return new Job(report); } + public async getScheduledReportInfo(id: string) { + const { data: reportList = [] }: { data: ScheduledReportApiJSON[] } = await this.http.get( + `${INTERNAL_ROUTES.SCHEDULED.LIST}` + ); + + const report = reportList.find((item) => item.id === id); + return report; + } + public async findForJobIds(jobIds: JobId[]) { const reports: ReportApiJSON[] = await this.http.fetch(INTERNAL_ROUTES.JOBS.LIST, { query: { page: 0, ids: jobIds.join(',') }, diff --git a/x-pack/platform/plugins/private/reporting/common/test/fixtures.ts b/x-pack/platform/plugins/private/reporting/common/test/fixtures.ts index 823785c4eb273..1932582a0b3d9 100644 --- a/x-pack/platform/plugins/private/reporting/common/test/fixtures.ts +++ b/x-pack/platform/plugins/private/reporting/common/test/fixtures.ts @@ -5,8 +5,9 @@ * 2.0. */ +import { Frequency } from '@kbn/rrule'; import { JOB_STATUS } from '@kbn/reporting-common'; -import { ReportApiJSON } from '@kbn/reporting-common/types'; +import { BaseParamsV2, ReportApiJSON, ScheduledReportApiJSON } from '@kbn/reporting-common/types'; import type { ReportMock } from './types'; const buildMockReport = (baseObj: ReportMock): ReportApiJSON => ({ @@ -173,3 +174,74 @@ export const mockJobs: ReportApiJSON[] = [ status: JOB_STATUS.COMPLETED, }), ]; + +export const mockScheduledReports: ScheduledReportApiJSON[] = [ + { + created_at: '2025-06-10T12:41:45.136Z', + created_by: 'Foo Bar', + enabled: true, + id: 'scheduled-report-1', + jobtype: 'printable_pdf_v2', + last_run: '2025-05-10T12:41:46.959Z', + next_run: '2025-06-16T13:56:07.123Z', + schedule: { + rrule: { freq: Frequency.WEEKLY, tzid: 'UTC', interval: 1 }, + }, + title: 'Scheduled report 1', + space_id: 'default', + payload: { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { + id: 'preserve_layout', + }, + objectType: 'dashboard', + version: '7.14.0', + locatorParams: [ + { + id: 'canvas', + version: '7.14.0', + params: { + dashboardId: '7adfa750-4c81-11e8-b3d7-01146121b73d', + preserveSavedFilters: 'true', + timeRange: { + from: 'now-7d', + to: 'now', + }, + useHash: 'false', + viewMode: 'view', + }, + }, + ], + isDeprecated: false, + } as BaseParamsV2, + }, + { + created_at: '2025-06-16T12:41:45.136Z', + created_by: 'Test abc', + enabled: true, + id: 'scheduled-report-2', + jobtype: 'printable_pdf_v2', + last_run: '2025-06-16T12:41:46.959Z', + next_run: '2025-06-16T13:56:07.123Z', + space_id: 'default', + schedule: { + rrule: { freq: Frequency.DAILY, tzid: 'UTC', interval: 1 }, + }, + title: 'Scheduled report 2', + }, + { + created_at: '2025-06-12T12:41:45.136Z', + created_by: 'New', + enabled: false, + id: 'scheduled-report-3', + jobtype: 'printable_pdf_v2', + last_run: '2025-06-16T12:41:46.959Z', + next_run: '2025-06-16T13:56:07.123Z', + space_id: 'space-a', + schedule: { + rrule: { freq: Frequency.MONTHLY, tzid: 'UTC', interval: 2 }, + }, + title: 'Scheduled report 3', + }, +]; diff --git a/x-pack/platform/plugins/private/reporting/public/constants.ts b/x-pack/platform/plugins/private/reporting/public/constants.ts new file mode 100644 index 0000000000000..193e9cf234af3 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/constants.ts @@ -0,0 +1,15 @@ +/* + * 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 APP_PATH = '/app/management/insightsAndAlerting/reporting' as const; +export const HOME_PATH = `/`; +export const REPORTING_EXPORTS_PATH = '/exports' as const; +export const REPORTING_SCHEDULES_PATH = '/schedules' as const; +export const EXPORTS_TAB_ID = 'exports' as const; +export const SCHEDULES_TAB_ID = 'schedules' as const; + +export type Section = 'exports' | 'schedules'; diff --git a/x-pack/platform/plugins/private/reporting/public/lib/ilm_policy_status_context.tsx b/x-pack/platform/plugins/private/reporting/public/lib/ilm_policy_status_context.tsx index a6ac75b330152..0733fd276192b 100644 --- a/x-pack/platform/plugins/private/reporting/public/lib/ilm_policy_status_context.tsx +++ b/x-pack/platform/plugins/private/reporting/public/lib/ilm_policy_status_context.tsx @@ -32,9 +32,17 @@ export const IlmPolicyStatusContextProvider: FC> = ({ export type UseIlmPolicyStatusReturn = ReturnType; -export const useIlmPolicyStatus = (): ContextValue => { +export const useIlmPolicyStatus = (isEnabled: boolean): ContextValue => { const ctx = useContext(IlmPolicyStatusContext); if (!ctx) { + if (!isEnabled) { + return { + status: undefined, + isLoading: false, + recheckStatus: () => {}, + }; + } + throw new Error('"useIlmPolicyStatus" can only be used inside of "IlmPolicyStatusContext"'); } return ctx; diff --git a/x-pack/platform/plugins/private/reporting/public/management/__test__/report_listing.test.helpers.tsx b/x-pack/platform/plugins/private/reporting/public/management/__test__/report_listing.test.helpers.tsx index 793b49979cb6f..883a1f19b637c 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/__test__/report_listing.test.helpers.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/__test__/report_listing.test.helpers.tsx @@ -31,10 +31,13 @@ import { act } from 'react-dom/test-utils'; import { Observable } from 'rxjs'; import { EuiThemeProvider } from '@elastic/eui'; -import { ListingProps as Props, ReportListing } from '..'; +import { RouteComponentProps } from 'react-router-dom'; +import { createLocation, createMemoryHistory } from 'history'; +import { ListingProps as Props, ReportingTabs } from '..'; import { mockJobs } from '../../../common/test'; import { IlmPolicyStatusContextProvider } from '../../lib/ilm_policy_status_context'; import { ReportDiagnostic } from '../components'; +import { MatchParams } from '../components/reporting_tabs'; export interface TestDependencies { http: ReturnType; @@ -90,6 +93,21 @@ const license$ = { }, } as Observable; +const routeProps: RouteComponentProps = { + history: createMemoryHistory({ + initialEntries: ['/exports'], + }), + location: createLocation('/exports'), + match: { + isExact: true, + path: `/exports`, + url: '', + params: { + section: 'exports', + }, + }, +}; + export const createTestBed = registerTestBed( ({ http, @@ -107,14 +125,16 @@ export const createTestBed = registerTestBed( - diff --git a/x-pack/platform/plugins/private/reporting/public/management/apis/bulk_disable_scheduled_reports.ts b/x-pack/platform/plugins/private/reporting/public/management/apis/bulk_disable_scheduled_reports.ts new file mode 100644 index 0000000000000..c86cdde56b0d7 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/apis/bulk_disable_scheduled_reports.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from '@kbn/core/public'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; + +export const bulkDisableScheduledReports = async ({ + http, + ids = [], +}: { + http: HttpSetup; + ids: string[]; +}): Promise<{ + scheduled_report_ids: string[]; + errors: Array<{ message: string; status?: number; id: string }>; + total: number; +}> => { + return await http.patch(INTERNAL_ROUTES.SCHEDULED.BULK_DISABLE, { + body: JSON.stringify({ ids }), + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/apis/get_scheduled_reports_list.ts b/x-pack/platform/plugins/private/reporting/public/management/apis/get_scheduled_reports_list.ts new file mode 100644 index 0000000000000..f177f789cc318 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/apis/get_scheduled_reports_list.ts @@ -0,0 +1,48 @@ +/* + * 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 { HttpFetchQuery, HttpSetup } from '@kbn/core/public'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import { type ScheduledReportApiJSON } from '@kbn/reporting-common/types'; + +export interface Pagination { + index: number; + size: number; +} + +export const getScheduledReportsList = async ({ + http, + index, + size, +}: { + http: HttpSetup; + index?: number; + size?: number; +}): Promise<{ + page: number; + size: number; + total: number; + data: ScheduledReportApiJSON[]; +}> => { + const query: HttpFetchQuery = { page: index, size }; + + const res = await http.get<{ + page: number; + per_page: number; + total: number; + data: ScheduledReportApiJSON[]; + }>(INTERNAL_ROUTES.SCHEDULED.LIST, { + query, + }); + + return { + page: res.page, + size: res.per_page, + total: res.total, + data: res.data, + }; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/disable_report_confirmation_modal.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/disable_report_confirmation_modal.tsx new file mode 100644 index 0000000000000..e7ba0dc278d0a --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/disable_report_confirmation_modal.tsx @@ -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 React from 'react'; +import { EuiConfirmModal, useGeneratedHtmlId } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface ConfirmDisableReportModalProps { + title: string; + message: string; + onCancel: () => void; + onConfirm: () => void; +} + +const DisableReportConfirmationModalComponent: React.FC = ({ + title, + message, + onCancel, + onConfirm, +}) => { + const titleId = useGeneratedHtmlId(); + + return ( + + {message} + + ); +}; +DisableReportConfirmationModalComponent.displayName = 'DisableReportConfirmationModal'; + +export const DisableReportConfirmationModal = React.memo(DisableReportConfirmationModalComponent); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/ilm_policy_link.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/ilm_policy_link.tsx index 8bb72cddd6c76..e48e8198ec877 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/ilm_policy_link.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/ilm_policy_link.tsx @@ -10,33 +10,33 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty } from '@elastic/eui'; -import type { ApplicationStart } from '@kbn/core/public'; import { ILM_POLICY_NAME } from '@kbn/reporting-common'; import { LocatorPublic, SerializableRecord } from '../../shared_imports'; interface Props { - navigateToUrl: ApplicationStart['navigateToUrl']; locator: LocatorPublic; } const i18nTexts = { buttonLabel: i18n.translate('xpack.reporting.listing.reports.ilmPolicyLinkText', { - defaultMessage: 'Edit reporting ILM policy', + defaultMessage: 'Edit ILM policy', }), }; -export const IlmPolicyLink: FunctionComponent = ({ locator, navigateToUrl }) => { +export const IlmPolicyLink: FunctionComponent = ({ locator }) => { return ( { const url = locator.getRedirectUrl({ page: 'policy_edit', policyName: ILM_POLICY_NAME, }); - navigateToUrl(url); + window.open(url, '_blank'); + window.focus(); }} > {i18nTexts.buttonLabel} diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx new file mode 100644 index 0000000000000..a44bbc2c8b936 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiPageTemplate, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/reporting-public'; + +const title = ( +

+ {i18n.translate('xpack.reporting.schedules.licenseCheck.title', { + defaultMessage: `Upgrade your license to use Machine Learning`, + })} +

+); + +export const LicensePrompt = React.memo(() => { + const { application } = useKibana().services; + + return ( + + + + + + {i18n.translate('xpack.reporting.schedules.licenseCheck.upgrade', { + defaultMessage: `Upgrade`, + })} + + + + + + + + {i18n.translate('xpack.reporting.schedules.licenseCheck.startTrial', { + defaultMessage: `Start a trial`, + })} + + + + +
+ } + /> + ); +}); +LicensePrompt.displayName = 'LicensePrompt'; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/migrate_ilm_policy_callout/index.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/migrate_ilm_policy_callout/index.tsx index 43c617b2ba972..d8b936b55d0df 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/migrate_ilm_policy_callout/index.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/migrate_ilm_policy_callout/index.tsx @@ -20,7 +20,7 @@ interface Props { } export const MigrateIlmPolicyCallOut: FunctionComponent = ({ toasts }) => { - const { isLoading, recheckStatus, status } = useIlmPolicyStatus(); + const { isLoading, recheckStatus, status } = useIlmPolicyStatus(true); if (isLoading || !status || status === 'ok') { return null; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_diagnostic.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_diagnostic.tsx index 90139a56ead28..c6ea3e874e322 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/report_diagnostic.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_diagnostic.tsx @@ -10,7 +10,6 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, - EuiButtonEmpty, EuiCallOut, EuiFlyout, EuiFlyoutBody, @@ -182,17 +181,12 @@ export const ReportDiagnostic = ({ apiClient, clientConfig }: Props) => { {configAllowsImageReports && (
{flyout} - + - +
)}
diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx new file mode 100644 index 0000000000000..7d381f3921957 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx @@ -0,0 +1,106 @@ +/* + * 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 { + applicationServiceMock, + coreMock, + httpServiceMock, + notificationServiceMock, +} from '@kbn/core/public/mocks'; +import { ReportExportsTable } from './report_exports_table'; +import { render, screen } from '@testing-library/react'; +import { Job, ReportingAPIClient } from '@kbn/reporting-public'; +import { Observable } from 'rxjs'; +import { ILicense } from '@kbn/licensing-plugin/public'; +import { SharePluginSetup } from '@kbn/share-plugin/public'; +import { mockConfig } from '../__test__/report_listing.test.helpers'; +import React from 'react'; +import { REPORT_TABLE_ID, REPORT_TABLE_ROW_ID } from '@kbn/reporting-common'; +import { mockJobs } from '../../../common/test'; +import { RecursivePartial, UseEuiTheme } from '@elastic/eui'; +import { ThemeProvider } from '@emotion/react'; + +const coreStart = coreMock.createStart(); +const http = httpServiceMock.createSetupContract(); +const uiSettingsClient = coreMock.createSetup().uiSettings; +const httpService = httpServiceMock.createSetupContract(); +const application = applicationServiceMock.createStartContract(); +const reportingAPIClient = new ReportingAPIClient(httpService, uiSettingsClient, 'x.x.x'); +const validCheck = { + check: () => ({ + state: 'VALID', + message: '', + }), +}; +const license$ = { + subscribe: (handler: unknown) => { + return (handler as Function)(validCheck); + }, +} as Observable; + +export const getMockTheme = (partialTheme: RecursivePartial): UseEuiTheme => + partialTheme as UseEuiTheme; + +const defaultProps = { + coreStart, + http, + application, + apiClient: reportingAPIClient, + config: mockConfig, + license$, + urlService: {} as unknown as SharePluginSetup['url'], + toasts: notificationServiceMock.createSetupContract().toasts, + capabilities: application.capabilities, + redirect: application.navigateToApp, + navigateToUrl: application.navigateToUrl, +}; + +describe('ReportExportsTable', () => { + const mockTheme = getMockTheme({ euiTheme: { size: { s: '' } } }); + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(reportingAPIClient, 'list') + .mockImplementation(() => Promise.resolve(mockJobs.map((j) => new Job(j)))); + jest.spyOn(reportingAPIClient, 'total').mockImplementation(() => Promise.resolve(18)); + }); + + it('renders table correctly', async () => { + render( + + + + ); + + expect(await screen.findByTestId(REPORT_TABLE_ID)).toBeInTheDocument(); + }); + + it('renders empty state correctly', async () => { + jest.spyOn(reportingAPIClient, 'list').mockImplementation(() => Promise.resolve([])); + jest.spyOn(reportingAPIClient, 'total').mockImplementation(() => Promise.resolve(0)); + render( + + + + ); + + expect(await screen.findByText('No reports have been created')).toBeInTheDocument(); + }); + + it('renders data correctly', async () => { + render( + + + + ); + + expect(await screen.findAllByTestId(REPORT_TABLE_ROW_ID)).toHaveLength(mockJobs.length); + + expect(await screen.findByTestId(`viewReportingLink-${mockJobs[0].id}`)).toBeInTheDocument(); + expect(await screen.findByTestId(`reportDownloadLink-${mockJobs[0].id}`)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/report_listing_table.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx similarity index 77% rename from x-pack/platform/plugins/private/reporting/public/management/report_listing_table.tsx rename to x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx index 848c25a4ba278..66d6d8fc0246c 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/report_listing_table.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx @@ -9,6 +9,7 @@ import { Component, Fragment, default as React } from 'react'; import { Subscription } from 'rxjs'; import { + EuiBadge, EuiBasicTable, EuiBasicTableColumn, EuiFlexGroup, @@ -24,11 +25,13 @@ import { ILicense } from '@kbn/licensing-plugin/public'; import { durationToNumber, REPORT_TABLE_ID, REPORT_TABLE_ROW_ID } from '@kbn/reporting-common'; import { checkLicense, Job } from '@kbn/reporting-public'; -import { ListingPropsInternal } from '.'; -import { prettyPrintJobType } from '../../common/job_utils'; -import { Poller } from '../../common/poller'; -import { ReportDeleteButton, ReportInfoFlyout, ReportStatusIndicator } from './components'; -import { guessAppIconTypeFromObjectType, getDisplayNameFromObjectType } from './utils'; +import { ListingPropsInternal } from '..'; +import { prettyPrintJobType } from '../../../common/job_utils'; +import { Poller } from '../../../common/poller'; +import { ReportDeleteButton, ReportInfoFlyout, ReportStatusIndicator } from '.'; +import { guessAppIconTypeFromObjectType, getDisplayNameFromObjectType } from '../utils'; +import { NO_CREATED_REPORTS_DESCRIPTION } from '../../translations'; +import { TruncatedTitle } from './truncated_title'; type TableColumn = EuiBasicTableColumn; @@ -44,7 +47,7 @@ interface State { selectedJob: undefined | Job; } -export class ReportListingTable extends Component { +export class ReportExportsTable extends Component { private isInitialJobsFetch: boolean; private licenseSubscription?: Subscription; private mounted?: boolean; @@ -128,7 +131,7 @@ export class ReportListingTable extends Component { await this.props.apiClient.deleteReport(job.id); this.removeJob(job); this.props.toasts.addSuccess( - i18n.translate('xpack.reporting.listing.table.deleteConfim', { + i18n.translate('xpack.reporting.exports.table.deleteConfirm', { defaultMessage: `The {reportTitle} report was deleted`, values: { reportTitle: job.title, @@ -137,7 +140,7 @@ export class ReportListingTable extends Component { ); } catch (error) { this.props.toasts.addDanger( - i18n.translate('xpack.reporting.listing.table.deleteFailedErrorMessage', { + i18n.translate('xpack.reporting.exports.table.deleteFailedErrorMessage', { defaultMessage: `The report was not deleted: {error}`, values: { error }, }) @@ -172,6 +175,7 @@ export class ReportListingTable extends Component { try { jobs = await this.props.apiClient.list(this.state.page); total = await this.props.apiClient.total(); + this.isInitialJobsFetch = false; } catch (fetchError) { if (!this.licenseAllowsToShowThisPage()) { @@ -183,7 +187,7 @@ export class ReportListingTable extends Component { if (fetchError.message === 'Failed to fetch') { this.props.toasts.addDanger( fetchError.message || - i18n.translate('xpack.reporting.listing.table.requestFailedErrorMessage', { + i18n.translate('xpack.reporting.exports.table.requestFailedErrorMessage', { defaultMessage: 'Request failed', }) ); @@ -216,9 +220,10 @@ export class ReportListingTable extends Component { type: '5%', title: '30%', status: '20%', - createdAt: '25%', - content: '10%', - actions: '10%', + createdAt: '21%', + content: '7%', + exportType: '12%', + actions: '5%', }; public render() { @@ -227,7 +232,7 @@ export class ReportListingTable extends Component { { field: 'type', width: tableColumnWidths.type, - name: i18n.translate('xpack.reporting.listing.tableColumns.typeTitle', { + name: i18n.translate('xpack.reporting.exports.tableColumns.typeTitle', { defaultMessage: 'Type', }), render: (_type: string, job) => { @@ -251,8 +256,8 @@ export class ReportListingTable extends Component { }, { field: 'title', - name: i18n.translate('xpack.reporting.listing.tableColumns.reportTitle', { - defaultMessage: 'Title', + name: i18n.translate('xpack.reporting.exports.tableColumns.reportTitle', { + defaultMessage: 'Name', }), width: tableColumnWidths.title, render: (objectTitle: string, job) => { @@ -262,10 +267,14 @@ export class ReportListingTable extends Component { data-test-subj={`viewReportingLink-${job.id}`} onClick={() => this.setState({ selectedJob: job })} > - {objectTitle || - i18n.translate('xpack.reporting.listing.table.noTitleLabel', { - defaultMessage: 'Untitled', - })} + ); @@ -278,7 +287,7 @@ export class ReportListingTable extends Component { { field: 'status', width: tableColumnWidths.status, - name: i18n.translate('xpack.reporting.listing.tableColumns.statusTitle', { + name: i18n.translate('xpack.reporting.exports.tableColumns.statusTitle', { defaultMessage: 'Status', }), render: (_status: string, job) => { @@ -300,7 +309,7 @@ export class ReportListingTable extends Component { { field: 'created_at', width: tableColumnWidths.createdAt, - name: i18n.translate('xpack.reporting.listing.tableColumns.createdAtTitle', { + name: i18n.translate('xpack.reporting.exports.tableColumns.createdAtTitle', { defaultMessage: 'Created at', }), render: (_createdAt: string, job) => ( @@ -313,16 +322,48 @@ export class ReportListingTable extends Component { { field: 'content', width: tableColumnWidths.content, - name: i18n.translate('xpack.reporting.listing.tableColumns.content', { + name: i18n.translate('xpack.reporting.exports.tableColumns.content', { defaultMessage: 'Content', }), - render: (_status: string, job) => prettyPrintJobType(job.jobtype), + render: (_status: string, job) => ( +
{prettyPrintJobType(job.jobtype)}
+ ), mobileOptions: { show: false, }, }, { - name: i18n.translate('xpack.reporting.listing.tableColumns.actionsTitle', { + field: 'scheduled_report_id', + width: tableColumnWidths.exportType, + name: i18n.translate('xpack.reporting.exports.tableColumns.exportType', { + defaultMessage: 'Export type', + }), + render: (_scheduledReportId: string) => { + const exportType = _scheduledReportId + ? i18n.translate('xpack.reporting.exports.exportType.scheduled', { + defaultMessage: 'Scheduled', + }) + : i18n.translate('xpack.reporting.exports.exportType.single', { + defaultMessage: 'Single', + }); + + return ( + + + + + + {exportType} + + + ); + }, + mobileOptions: { + show: false, + }, + }, + { + name: i18n.translate('xpack.reporting.exports.tableColumns.actionsTitle', { defaultMessage: 'Actions', }), width: tableColumnWidths.actions, @@ -332,10 +373,10 @@ export class ReportListingTable extends Component { 'data-test-subj': (job) => `reportDownloadLink-${job.id}`, type: 'icon', icon: 'download', - name: i18n.translate('xpack.reporting.listing.table.downloadReportButtonLabel', { + name: i18n.translate('xpack.reporting.exports.table.downloadReportButtonLabel', { defaultMessage: 'Download report', }), - description: i18n.translate('xpack.reporting.listing.table.downloadReportDescription', { + description: i18n.translate('xpack.reporting.exports.table.downloadReportDescription', { defaultMessage: 'Download this report in a new tab.', }), onClick: (job) => this.props.apiClient.downloadReport(job.id), @@ -343,28 +384,29 @@ export class ReportListingTable extends Component { }, { name: i18n.translate( - 'xpack.reporting.listing.table.viewReportingInfoActionButtonLabel', + 'xpack.reporting.exports.table.viewReportingInfoActionButtonLabel', { defaultMessage: 'View report info', } ), description: i18n.translate( - 'xpack.reporting.listing.table.viewReportingInfoActionButtonDescription', + 'xpack.reporting.exports.table.viewReportingInfoActionButtonDescription', { defaultMessage: 'View additional information about this report.', } ), + 'data-test-subj': 'reportViewInfoLink', type: 'icon', icon: 'iInCircle', onClick: (job) => this.setState({ selectedJob: job }), }, { - name: i18n.translate('xpack.reporting.listing.table.openInKibanaAppLabel', { + name: i18n.translate('xpack.reporting.exports.table.openInKibanaAppLabel', { defaultMessage: 'Open in Kibana', }), 'data-test-subj': 'reportOpenInKibanaApp', description: i18n.translate( - 'xpack.reporting.listing.table.openInKibanaAppDescription', + 'xpack.reporting.exports.table.openInKibanaAppDescription', { defaultMessage: 'Open the Kibana app where this report was generated.', } @@ -386,7 +428,7 @@ export class ReportListingTable extends Component { pageIndex: this.state.page, pageSize: 10, totalItemCount: this.state.total, - showPerPageOptions: false, + showPerPageOptions: true, }; const selection = { @@ -396,6 +438,7 @@ export class ReportListingTable extends Component { return ( + {this.state.selectedJobs.length > 0 && (
@@ -405,22 +448,14 @@ export class ReportListingTable extends Component {
)} { ); } } + +// eslint-disable-next-line import/no-default-export +export { ReportExportsTable as default }; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedule_indicator.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedule_indicator.tsx new file mode 100644 index 0000000000000..ab61c4d5cff58 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedule_indicator.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { Frequency } from '@kbn/rrule'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; +import { ScheduledReportApiJSON } from '@kbn/reporting-common/types'; + +interface ReportScheduleIndicatorProps { + schedule: ScheduledReportApiJSON['schedule']; +} + +const translations = { + [Frequency.DAILY]: i18n.translate('xpack.reporting.schedules.scheduleIndicator.daily', { + defaultMessage: 'Daily', + }), + [Frequency.WEEKLY]: i18n.translate('xpack.reporting.schedules.scheduleIndicator.weekly', { + defaultMessage: 'Weekly', + }), + [Frequency.MONTHLY]: i18n.translate('xpack.reporting.schedules.scheduleIndicator.monthly', { + defaultMessage: 'Monthly', + }), +}; + +export const ReportScheduleIndicator: FC = ({ schedule }) => { + if (!schedule || !schedule.rrule) { + return null; + } + + const statusText = translations[schedule.rrule.freq]; + + return ( + + + + + + {statusText} + + + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx new file mode 100644 index 0000000000000..015bd3dc18d96 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx @@ -0,0 +1,244 @@ +/* + * 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 { + applicationServiceMock, + coreMock, + httpServiceMock, + notificationServiceMock, +} from '@kbn/core/public/mocks'; +import { render, screen, waitFor } from '@testing-library/react'; +import { ReportingAPIClient } from '@kbn/reporting-public'; +import { Observable } from 'rxjs'; +import { ILicense } from '@kbn/licensing-plugin/public'; +import { SharePluginSetup } from '@kbn/share-plugin/public'; +import { mockConfig } from '../__test__/report_listing.test.helpers'; +import React from 'react'; +import { RecursivePartial, UseEuiTheme } from '@elastic/eui'; +import ReportSchedulesTable from './report_schedules_table'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { useGetScheduledList } from '../hooks/use_get_scheduled_list'; +import { mockScheduledReports } from '../../../common/test/fixtures'; +import { userEvent } from '@testing-library/user-event'; +import { useBulkDisable } from '../hooks/use_bulk_disable'; + +jest.mock('../hooks/use_get_scheduled_list', () => ({ + useGetScheduledList: jest.fn(), +})); +jest.mock('../hooks/use_bulk_disable'); + +const useBulkDisableMock = useBulkDisable as jest.Mock; + +const coreStart = coreMock.createStart(); +const http = httpServiceMock.createSetupContract(); +const uiSettingsClient = coreMock.createSetup().uiSettings; +const httpService = httpServiceMock.createSetupContract(); +const application = applicationServiceMock.createStartContract(); +const reportingAPIClient = new ReportingAPIClient(httpService, uiSettingsClient, 'x.x.x'); +const validCheck = { + check: () => ({ + state: 'VALID', + message: '', + }), +}; +const license$ = { + subscribe: (handler: unknown) => { + return (handler as Function)(validCheck); + }, +} as Observable; + +export const getMockTheme = (partialTheme: RecursivePartial): UseEuiTheme => + partialTheme as UseEuiTheme; + +const defaultProps = { + coreStart, + http, + application, + apiClient: reportingAPIClient, + config: mockConfig, + license$, + urlService: {} as unknown as SharePluginSetup['url'], + toasts: notificationServiceMock.createSetupContract().toasts, + capabilities: application.capabilities, + redirect: application.navigateToApp, + navigateToUrl: application.navigateToUrl, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +describe('ReportSchedulesTable', () => { + const bulkDisableScheduledReportsMock = jest.fn(); + useBulkDisableMock.mockReturnValue({ + isLoading: false, + mutateAsync: bulkDisableScheduledReportsMock, + }); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders table correctly', async () => { + (useGetScheduledList as jest.Mock).mockReturnValueOnce({ + data: { + page: 0, + size: 10, + total: 0, + data: [], + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findByTestId('reportSchedulesTable')).toBeInTheDocument(); + }); + + it('renders empty state correctly', async () => { + (useGetScheduledList as jest.Mock).mockReturnValueOnce({ + data: { + page: 0, + size: 10, + total: 0, + data: [], + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findByText('No reports have been created')).toBeInTheDocument(); + }); + + it('renders data correctly', async () => { + (useGetScheduledList as jest.Mock).mockReturnValueOnce({ + data: { + page: 3, + size: 10, + total: 3, + data: mockScheduledReports, + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findAllByTestId('scheduledReportRow')).toHaveLength(3); + expect(await screen.findByText(mockScheduledReports[0].title)).toBeInTheDocument(); + expect(await screen.findAllByText('Active')).toHaveLength(2); + expect(await screen.findAllByText('Disabled')).toHaveLength(1); + }); + + it('shows disable confirmation modal correctly', async () => { + (useGetScheduledList as jest.Mock).mockReturnValue({ + data: { + page: 3, + size: 10, + total: 3, + data: mockScheduledReports, + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findAllByTestId('scheduledReportRow')).toHaveLength(3); + + userEvent.click((await screen.findAllByTestId('euiCollapsedItemActionsButton'))[0]); + + const firstReportDisable = await screen.findByTestId( + `reportDisableSchedule-${mockScheduledReports[0].id}` + ); + + expect(firstReportDisable).toBeInTheDocument(); + + userEvent.click(firstReportDisable, { pointerEventsCheck: 0 }); + + expect(await screen.findByTestId('confirm-disable-modal')).toBeInTheDocument(); + }); + + it('disable schedule report correctly', async () => { + (useGetScheduledList as jest.Mock).mockReturnValue({ + data: { + page: 3, + size: 10, + total: 3, + data: mockScheduledReports, + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findAllByTestId('scheduledReportRow')).toHaveLength(3); + + userEvent.click((await screen.findAllByTestId('euiCollapsedItemActionsButton'))[0]); + + const firstReportDisable = await screen.findByTestId( + `reportDisableSchedule-${mockScheduledReports[0].id}` + ); + + expect(firstReportDisable).toBeInTheDocument(); + + userEvent.click(firstReportDisable, { pointerEventsCheck: 0 }); + + expect(await screen.findByTestId('confirm-disable-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Disable')); + + await waitFor(() => { + expect(bulkDisableScheduledReportsMock).toHaveBeenCalledWith({ + ids: [mockScheduledReports[0].id], + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx new file mode 100644 index 0000000000000..e795621563655 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx @@ -0,0 +1,330 @@ +/* + * 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 { Fragment, default as React, useCallback, useState } from 'react'; +import { + EuiAvatar, + EuiBasicTable, + EuiBasicTableColumn, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiIconTip, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { stringify } from 'query-string'; +import { REPORTING_REDIRECT_APP, buildKibanaPath } from '@kbn/reporting-common'; +import type { ScheduledReportApiJSON, BaseParamsV2 } from '@kbn/reporting-common/types'; +import { ListingPropsInternal } from '..'; +import { + guessAppIconTypeFromObjectType, + getDisplayNameFromObjectType, + transformScheduledReport, +} from '../utils'; +import { useGetScheduledList } from '../hooks/use_get_scheduled_list'; +import { prettyPrintJobType } from '../../../common/job_utils'; +import { ReportScheduleIndicator } from './report_schedule_indicator'; +import { useBulkDisable } from '../hooks/use_bulk_disable'; +import { NO_CREATED_REPORTS_DESCRIPTION } from '../../translations'; +import { ScheduledReportFlyout } from './scheduled_report_flyout'; +import { TruncatedTitle } from './truncated_title'; +import { DisableReportConfirmationModal } from './disable_report_confirmation_modal'; + +interface QueryParams { + index: number; + size: number; +} + +export const ReportSchedulesTable = (props: ListingPropsInternal) => { + const { http, toasts } = props; + const [selectedReport, setSelectedReport] = useState(null); + const [configFlyOut, setConfigFlyOut] = useState(false); + const [disableFlyOut, setDisableFlyOut] = useState(false); + const [queryParams, setQueryParams] = useState({ + index: 1, + size: 10, + }); + const { data: scheduledList, isLoading } = useGetScheduledList({ + http, + ...queryParams, + }); + + const { mutateAsync: bulkDisableScheduledReports } = useBulkDisable({ + http, + toasts, + }); + + const tableColumns: Array> = [ + { + field: 'payload.objectType', + name: i18n.translate('xpack.reporting.schedules.tableColumns.typeTitle', { + defaultMessage: 'Type', + }), + width: '5%', + render: (_objectType: string) => ( + + ), + }, + { + field: 'title', + name: i18n.translate('xpack.reporting.schedules.tableColumns.reportTitle', { + defaultMessage: 'Title', + }), + width: '22%', + render: (_title: string, item: ScheduledReportApiJSON) => ( + { + setSelectedReport(item); + setConfigFlyOut(true); + }} + > + + + ), + mobileOptions: { + header: false, + width: '100%', + }, + }, + { + field: 'status', + name: i18n.translate('xpack.reporting.schedules.tableColumns.statusTitle', { + defaultMessage: 'Status', + }), + width: '10%', + render: (_status: string, item: ScheduledReportApiJSON) => { + return ( + + {item.enabled + ? i18n.translate('xpack.reporting.schedules.status.active', { + defaultMessage: 'Active', + }) + : i18n.translate('xpack.reporting.schedules.status.disabled', { + defaultMessage: 'Disabled', + })} + + ); + }, + }, + { + field: 'schedule', + name: i18n.translate('xpack.reporting.schedules.tableColumns.scheduleTitle', { + defaultMessage: 'Schedule', + }), + width: '10%', + render: (_schedule: ScheduledReportApiJSON['schedule']) => ( + + ), + }, + { + field: 'next_run', + name: i18n.translate('xpack.reporting.schedules.tableColumns.nextScheduleTitle', { + defaultMessage: 'Next schedule', + }), + width: '20%', + render: (_nextRun: string) => { + return moment(_nextRun).format('YYYY-MM-DD @ hh:mm A'); + }, + }, + { + field: 'jobtype', + width: '10%', + name: i18n.translate('xpack.reporting.schedules.tableColumns.fileType', { + defaultMessage: 'File Type', + }), + render: (_jobtype: string) => prettyPrintJobType(_jobtype), + mobileOptions: { + show: false, + }, + }, + { + field: 'created_by', + name: i18n.translate('xpack.reporting.schedules.tableColumns.createdByTitle', { + defaultMessage: 'Created by', + }), + width: '15%', + render: (_createdBy: string) => { + return ( + + + + + + + {_createdBy} + + + + ); + }, + }, + { + field: 'actions', + name: i18n.translate('xpack.reporting.schedules.tableColumns.actionsTitle', { + defaultMessage: 'Actions', + }), + width: '8%', + actions: [ + { + name: i18n.translate('xpack.reporting.schedules.table.viewConfig.title', { + defaultMessage: 'View schedule config', + }), + description: i18n.translate('xpack.reporting.schedules.table.viewConfig.description', { + defaultMessage: 'View schedule configuration details', + }), + 'data-test-subj': (item) => `reportViewConfig-${item.id}`, + type: 'icon', + icon: 'calendar', + onClick: (item) => { + setConfigFlyOut(true); + setSelectedReport(item); + }, + }, + { + name: i18n.translate('xpack.reporting.schedules.table.openDashboard.title', { + defaultMessage: 'Open Dashboard', + }), + description: i18n.translate('xpack.reporting.schedules.table.openDashboard.description', { + defaultMessage: 'Open associated dashboard', + }), + 'data-test-subj': (item) => `reportOpenDashboard-${item.id}`, + type: 'icon', + icon: 'dashboardApp', + available: (item) => Boolean((item.payload as BaseParamsV2)?.locatorParams), + onClick: async (item) => { + const searchParams = stringify({ scheduledReportId: item.id }); + + const path = buildKibanaPath({ + basePath: http.basePath.serverBasePath, + spaceId: item.payload?.spaceId, + appPath: REPORTING_REDIRECT_APP, + }); + + const href = `${path}?${searchParams}`; + + window.open(href, '_blank'); + window.focus(); + }, + }, + { + name: i18n.translate('xpack.reporting.schedules.table.disableSchedule.title', { + defaultMessage: 'Disable schedule', + }), + description: i18n.translate( + 'xpack.reporting.schedules.table.disableSchedule.description', + { + defaultMessage: 'Disable report schedule', + } + ), + 'data-test-subj': (item) => `reportDisableSchedule-${item.id}`, + enabled: (item) => item.enabled, + type: 'icon', + icon: 'cross', + onClick: (item) => { + setSelectedReport(item); + setDisableFlyOut(true); + }, + }, + ], + }, + ]; + + const onConfirm = useCallback(() => { + if (selectedReport) { + bulkDisableScheduledReports({ ids: [selectedReport.id] }); + } + + setSelectedReport(null); + setDisableFlyOut(false); + }, [bulkDisableScheduledReports, setSelectedReport, selectedReport]); + + const onCancel = useCallback(() => { + setSelectedReport(null); + setDisableFlyOut(false); + }, [setSelectedReport]); + + const tableOnChangeCallback = useCallback( + ({ page }: { page: QueryParams }) => { + setQueryParams((prev) => ({ + ...prev, + index: page.index + 1, + size: page.size, + })); + }, + [setQueryParams] + ); + + return ( + + + ({ 'data-test-subj': 'scheduledReportRow' })} + /> + {selectedReport && configFlyOut && ( + { + setSelectedReport(null); + setConfigFlyOut(false); + }} + scheduledReport={transformScheduledReport(selectedReport)} + availableReportTypes={[ + { + id: selectedReport.jobtype, + label: prettyPrintJobType(selectedReport.jobtype), + }, + ]} + /> + )} + {selectedReport && disableFlyOut ? ( + + ) : null} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ReportSchedulesTable as default }; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.test.tsx new file mode 100644 index 0000000000000..067397755677a --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.test.tsx @@ -0,0 +1,276 @@ +/* + * 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 * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { RouteComponentProps } from 'react-router-dom'; +import { Router } from '@kbn/shared-ux-router'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { createMemoryHistory, createLocation } from 'history'; + +import ReportingTabs, { MatchParams, ReportingTabsProps } from './reporting_tabs'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + applicationServiceMock, + coreMock, + httpServiceMock, + notificationServiceMock, +} from '@kbn/core/public/mocks'; +import { InternalApiClientProvider, ReportingAPIClient } from '@kbn/reporting-public'; +import { Observable } from 'rxjs'; +import { ILicense } from '@kbn/licensing-plugin/public'; +import { LocatorPublic, SharePluginSetup } from '@kbn/share-plugin/public'; +import { SerializableRecord } from '@kbn/utility-types'; +import { ReportDiagnostic } from './report_diagnostic'; +import { mockConfig } from '../__test__/report_listing.test.helpers'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; +import { EuiThemeProvider } from '@elastic/eui'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { IlmPolicyStatusContextProvider } from '../../lib/ilm_policy_status_context'; +import { dataService } from '@kbn/controls-plugin/public/services/kibana_services'; +import { shareService } from '@kbn/dashboard-plugin/public/services/kibana_services'; +import { IlmPolicyMigrationStatus } from '@kbn/reporting-common/types'; +import { HttpSetupMock } from '@kbn/core-http-browser-mocks'; +import { act } from 'react-dom/test-utils'; + +jest.mock('./report_exports_table', () => { + return () =>
{'Render Report Exports Table'}
; +}); + +jest.mock('./report_schedules_table', () => { + return () =>
{'Render Report Schedules Table'}
; +}); + +const queryClient = new QueryClient(); + +describe('Reporting tabs', () => { + const ilmLocator: LocatorPublic = { + getUrl: jest.fn(), + } as unknown as LocatorPublic; + const http = httpServiceMock.createSetupContract(); + const uiSettingsClient = coreMock.createSetup().uiSettings; + const httpService = httpServiceMock.createSetupContract(); + const application = applicationServiceMock.createStartContract(); + const reportingAPIClient = new ReportingAPIClient(httpService, uiSettingsClient, 'x.x.x'); + const validCheck = { + check: () => ({ + state: 'VALID', + message: '', + }), + }; + const mockUnsubscribe = jest.fn(); + // @ts-expect-error we don't need to provide all props for the test + const license$ = { + subscribe: (handler: unknown) => { + (handler as Function)(validCheck); + return { unsubscribe: mockUnsubscribe }; + }, + } as Observable; + + const reportDiagnostic = () => ( + + ); + + const routeProps: RouteComponentProps = { + history: createMemoryHistory({ + initialEntries: ['/exports'], + }), + location: createLocation('/exports'), + match: { + isExact: true, + path: `/exports`, + url: '', + params: { + section: 'exports', + }, + }, + }; + + const props = { + ...routeProps, + coreStart: coreMock.createStart(), + http, + application, + apiClient: reportingAPIClient, + config: mockConfig, + license$, + urlService: { + locators: { + get: () => ilmLocator, + }, + } as unknown as SharePluginSetup['url'], + toasts: notificationServiceMock.createSetupContract().toasts, + ilmLocator, + uiSettings: uiSettingsClient, + reportDiagnostic, + dataService: dataPluginMock.createStartContract(), + shareService: sharePluginMock.createStartContract(), + }; + + const renderComponent = ( + renderProps: Partial & ReportingTabsProps, + newHttpService?: HttpSetupMock + ) => { + const updatedReportingAPIClient = newHttpService + ? new ReportingAPIClient(newHttpService, uiSettingsClient, 'x.x.x') + : reportingAPIClient; + return ( + + + + + + + + + + + + + + + + ); + }; + + afterEach(() => { + jest.clearAllMocks(); + mockUnsubscribe.mockClear(); + }); + + it('renders exports components', async () => { + await act(async () => render(renderComponent(props))); + + expect(await screen.findByTestId('reportingTabs-exports')).toBeInTheDocument(); + expect(await screen.findByTestId('reportingTabs-schedules')).toBeInTheDocument(); + }); + + it('shows the correct number of tabs', async () => { + const updatedProps: RouteComponentProps = { + history: createMemoryHistory(), + location: createLocation('/'), + match: { + isExact: true, + path: `/schedules`, + url: '', + params: { + section: 'schedules', + }, + }, + }; + + await act(async () => { + render(renderComponent({ ...props, ...updatedProps })); + }); + + expect(await screen.findAllByRole('tab')).toHaveLength(2); + }); + + describe('ILM policy', () => { + it('shows ILM policy link correctly when config is stateful', async () => { + const status: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValue({ status }); + + application.capabilities = { + catalogue: {}, + navLinks: {}, + management: { data: { index_lifecycle_management: true } }, + }; + + const updatedShareService = { + ...sharePluginMock.createStartContract(), + url: { + ...sharePluginMock.createStartContract().url, + locators: { + ...sharePluginMock.createStartContract().url.locators, + id: 'ILM_LOCATOR_ID', + get: () => ilmLocator, + }, + }, + }; + + await act(async () => { + // @ts-expect-error we don't need to provide all props for the test + render(renderComponent({ ...props, shareService: updatedShareService })); + }); + + expect(await screen.findByTestId('ilmPolicyLink')).toBeInTheDocument(); + }); + + it('hides ILM policy link correctly for non stateful config', async () => { + const status: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValue({ status }); + + application.capabilities = { + catalogue: {}, + navLinks: {}, + management: { data: { index_lifecycle_management: true } }, + }; + + const updatedShareService = { + ...sharePluginMock.createStartContract(), + url: { + ...sharePluginMock.createStartContract().url, + locators: { + ...sharePluginMock.createStartContract().url.locators, + id: 'ILM_LOCATOR_ID', + get: () => ilmLocator, + }, + }, + }; + const newConfig = { ...mockConfig, statefulSettings: { enabled: false } }; + + await act(async () => { + // @ts-expect-error we don't need to provide all props for the test + render(renderComponent({ ...props, shareService: updatedShareService, config: newConfig })); + }); + + expect(screen.queryByTestId('ilmPolicyLink')).not.toBeInTheDocument(); + }); + }); + + describe('Screenshotting Diagnostic', () => { + it('shows screenshotting diagnostic link if config is stateful', async () => { + await act(async () => { + render(renderComponent(props)); + }); + + expect(await screen.findByTestId('screenshotDiagnosticLink')).toBeInTheDocument(); + }); + + it('does not show when image reporting not set in config', async () => { + const mockNoImageConfig = { + ...mockConfig, + export_types: { + csv: { enabled: true }, + pdf: { enabled: false }, + png: { enabled: false }, + }, + }; + + await act(async () => { + render( + renderComponent({ + ...props, + config: mockNoImageConfig, + }) + ); + }); + + expect(screen.queryByTestId('screenshotDiagnosticLink')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx new file mode 100644 index 0000000000000..e4b057851c86c --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx @@ -0,0 +1,225 @@ +/* + * 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, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPageTemplate } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Route, Routes } from '@kbn/shared-ux-router'; +import { RouteComponentProps } from 'react-router-dom'; +import { CoreStart, ScopedHistory } from '@kbn/core/public'; +import { ILicense, LicenseType, LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { + ClientConfigType, + ReportingAPIClient, + checkLicense, + useInternalApiClient, + useKibana, +} from '@kbn/reporting-public'; +import { SharePluginStart } from '@kbn/share-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; +import { suspendedComponentWithProps } from './suspended_component_with_props'; +import { REPORTING_EXPORTS_PATH, REPORTING_SCHEDULES_PATH, Section } from '../../constants'; +import ReportExportsTable from './report_exports_table'; +import { IlmPolicyLink } from './ilm_policy_link'; +import { ReportDiagnostic } from './report_diagnostic'; +import { useIlmPolicyStatus } from '../../lib/ilm_policy_status_context'; +import { MigrateIlmPolicyCallOut } from './migrate_ilm_policy_callout'; +import ReportSchedulesTable from './report_schedules_table'; +import { LicensePrompt } from './license_prompt'; + +export interface MatchParams { + section: Section; +} + +export interface ReportingTabsProps { + coreStart: CoreStart; + license$: LicensingPluginStart['license$']; + dataService: DataPublicPluginStart; + shareService: SharePluginStart; + config: ClientConfigType; + apiClient: ReportingAPIClient; +} + +export const ReportingTabs: React.FunctionComponent< + Partial & ReportingTabsProps +> = (props) => { + const { coreStart, license$, shareService, config, ...rest } = props; + const { notifications } = coreStart; + const { section } = rest.match?.params as MatchParams; + const history = rest.history as ScopedHistory; + const { apiClient } = useInternalApiClient(); + const { + services: { + application: { capabilities, navigateToApp, navigateToUrl }, + http, + }, + } = useKibana(); + + const ilmLocator = shareService.url.locators.get('ILM_LOCATOR_ID'); + const ilmPolicyContextValue = useIlmPolicyStatus(config.statefulSettings.enabled); + const hasIlmPolicy = ilmPolicyContextValue?.status !== 'policy-not-found'; + const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); + const license = useObservable(license$ ?? new Observable(), null); + + const isAtLeast = useCallback( + (level: LicenseType) => { + if (!license) { + return { enableLinks: false, showLinks: false }; + } + return checkLicense(license.check('reporting', level)); + }, + [license] + ); + + const tabs = [ + { + id: 'exports', + name: i18n.translate('xpack.reporting.tabs.exports', { + defaultMessage: 'Exports', + }), + }, + { + id: 'schedules', + name: i18n.translate('xpack.reporting.tabs.schedules', { + defaultMessage: 'Schedules', + }), + }, + ]; + + const { enableLinks, showLinks } = isAtLeast('trial'); + + const renderExportsList = useCallback(() => { + return suspendedComponentWithProps( + ReportExportsTable, + 'xl' + )({ + apiClient, + toasts: notifications.toasts, + license$, + config, + capabilities, + redirect: navigateToApp, + navigateToUrl, + urlService: shareService.url, + http, + }); + }, [ + apiClient, + notifications.toasts, + license$, + config, + capabilities, + navigateToApp, + navigateToUrl, + shareService.url, + http, + ]); + + const renderSchedulesList = useCallback(() => { + return ( + <> + {enableLinks && showLinks ? ( + + {suspendedComponentWithProps( + ReportSchedulesTable, + 'xl' + )({ + apiClient, + toasts: notifications.toasts, + license$, + config, + capabilities, + redirect: navigateToApp, + navigateToUrl, + urlService: shareService.url, + http, + })} + + ) : ( + + )} + + ); + }, [ + apiClient, + notifications.toasts, + license$, + config, + capabilities, + navigateToApp, + navigateToUrl, + shareService.url, + http, + enableLinks, + showLinks, + ]); + + const onSectionChange = (newSection: Section) => { + history.push(`/${newSection}`); + }; + + return ( + <> + , + + + , + + {capabilities?.management?.data?.index_lifecycle_management && ( + + {ilmPolicyContextValue?.isLoading ? ( + + ) : ( + showIlmPolicyLink && + )} + + )} + , + ] + : [] + } + data-test-subj="reportingPageHeader" + pageTitle={ + + } + description={ + + } + tabs={tabs.map(({ id, name }) => ({ + label: name, + onClick: () => onSectionChange(id as Section), + isSelected: id === section, + key: id, + 'data-test-subj': `reportingTabs-${id}`, + }))} + /> + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ReportingTabs as default }; 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 5cf81fcc80887..a211d67bc1c78 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 @@ -25,7 +25,14 @@ export const ScheduledReportFlyout = ({ onClose, }: ScheduledReportFlyoutProps) => { return ( - + ( - + {i18n.REPORTING_PAGE_LINK_TEXT} ), diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/suspended_component_with_props.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/suspended_component_with_props.tsx new file mode 100644 index 0000000000000..994aa399b177d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/suspended_component_with_props.tsx @@ -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 React, { Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +export function suspendedComponentWithProps( + ComponentToSuspend: React.ComponentType, + size?: 's' | 'm' | 'l' | 'xl' | 'xxl' +) { + return (props: T) => ( + }> + {/* @ts-expect-error upgrade typescript v4.9.5*/} + + + ); +} diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/truncated_title.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/truncated_title.tsx new file mode 100644 index 0000000000000..3db9d556fbd0d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/truncated_title.tsx @@ -0,0 +1,35 @@ +/* + * 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 { css } from '@emotion/react'; + +const LINE_CLAMP = 1; + +const getTextCss = css` + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +`; + +interface Props { + text: string; +} + +const TruncatedTitleComponent: React.FC = ({ text }) => { + return ( + + {text} + + ); +}; +TruncatedTitleComponent.displayName = 'TruncatedTitle'; + +export const TruncatedTitle = React.memo(TruncatedTitleComponent); diff --git a/x-pack/platform/plugins/private/reporting/public/management/default/report_listing_default.tsx b/x-pack/platform/plugins/private/reporting/public/management/default/report_listing_default.tsx deleted file mode 100644 index a2b09ccd3c177..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/default/report_listing_default.tsx +++ /dev/null @@ -1,50 +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, { FC } from 'react'; - -import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { ListingPropsInternal } from '..'; -import { ReportListingTable } from '../report_listing_table'; - -/** - * Used in non-stateful (Serverless) - * Does not render controls for features only applicable in Stateful - */ -export const ReportListingDefault: FC = (props) => { - const { apiClient, capabilities, config, navigateToUrl, toasts, urlService, ...listingProps } = - props; - return ( - <> - - } - description={ - - } - /> - - - - ); -}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx new file mode 100644 index 0000000000000..7cfd82a51fa17 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { httpServiceMock, notificationServiceMock } from '@kbn/core/public/mocks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useBulkDisable } from './use_bulk_disable'; +import { bulkDisableScheduledReports } from '../apis/bulk_disable_scheduled_reports'; + +jest.mock('../apis/bulk_disable_scheduled_reports', () => ({ + bulkDisableScheduledReports: jest.fn(), +})); + +describe('useBulkDisable', () => { + const http = httpServiceMock.createStartContract(); + const toasts = notificationServiceMock.createStartContract().toasts; + const queryClient = new QueryClient(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls bulkDisableScheduledReports with correct arguments', async () => { + (bulkDisableScheduledReports as jest.Mock).mockResolvedValueOnce({ + scheduled_report_ids: ['random_schedule_report_1'], + errors: [], + total: 1, + }); + + const { result } = renderHook(() => useBulkDisable({ http, toasts }), { + wrapper, + }); + + result.current.mutate({ ids: ['random_schedule_report_1'] }); + + await waitFor(() => { + expect(bulkDisableScheduledReports).toBeCalledWith({ + http, + ids: ['random_schedule_report_1'], + }); + expect(result.current.data).toEqual({ + scheduled_report_ids: ['random_schedule_report_1'], + errors: [], + total: 1, + }); + expect(toasts.addSuccess).toHaveBeenCalled(); + }); + }); + + it('throws error', async () => { + (bulkDisableScheduledReports as jest.Mock).mockRejectedValueOnce({}); + + const { result } = renderHook(() => useBulkDisable({ http, toasts }), { + wrapper, + }); + + result.current.mutate({ ids: [] }); + + await waitFor(() => { + expect(bulkDisableScheduledReports).toBeCalledWith({ + http, + ids: [], + }); + expect(toasts.addError).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.tsx new file mode 100644 index 0000000000000..b0ab70d05093d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.tsx @@ -0,0 +1,48 @@ +/* + * 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 { useMutation, useQueryClient } from '@tanstack/react-query'; +import { HttpSetup, IHttpFetchError, ResponseErrorBody, ToastsStart } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { bulkDisableScheduledReports } from '../apis/bulk_disable_scheduled_reports'; +import { mutationKeys, queryKeys } from '../query_keys'; + +export type ServerError = IHttpFetchError; + +const getKey = mutationKeys.bulkDisableScheduledReports; + +export const useBulkDisable = (props: { http: HttpSetup; toasts: ToastsStart }) => { + const { http, toasts } = props; + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: getKey(), + mutationFn: ({ ids }: { ids: string[] }) => + bulkDisableScheduledReports({ + http, + ids, + }), + onError: (error: ServerError) => { + toasts.addError(error, { + title: i18n.translate('xpack.reporting.schedules.reports.disableError', { + defaultMessage: 'Error disabling scheduled report', + }), + }); + }, + onSuccess: () => { + toasts.addSuccess( + i18n.translate('xpack.reporting.schedules.reports.disabled', { + defaultMessage: 'Scheduled report disabled', + }) + ); + queryClient.invalidateQueries({ + queryKey: queryKeys.getScheduledList({}), + refetchType: 'active', + }); + }, + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx new file mode 100644 index 0000000000000..8a7fff87387e7 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx @@ -0,0 +1,48 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/public/mocks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { getScheduledReportsList } from '../apis/get_scheduled_reports_list'; +import { useGetScheduledList } from './use_get_scheduled_list'; + +jest.mock('../apis/get_scheduled_reports_list', () => ({ + getScheduledReportsList: jest.fn(), +})); + +describe('useGetScheduledList', () => { + const http = httpServiceMock.createStartContract(); + const queryClient = new QueryClient(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls getScheduledList with correct arguments', async () => { + (getScheduledReportsList as jest.Mock).mockResolvedValueOnce({ data: [] }); + + const { result } = renderHook(() => useGetScheduledList({ http, index: 1, size: 10 }), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.data).toEqual({ data: [] }); + }); + + expect(getScheduledReportsList).toBeCalledWith({ + http, + index: 1, + size: 10, + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.tsx new file mode 100644 index 0000000000000..34cde02bcb0c8 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import { HttpSetup } from '@kbn/core/public'; +import { getScheduledReportsList } from '../apis/get_scheduled_reports_list'; +import { queryKeys } from '../query_keys'; + +export const getKey = queryKeys.getScheduledList; + +interface GetScheduledListQueryProps { + http: HttpSetup; + index?: number; + size?: number; +} + +export const useGetScheduledList = (props: GetScheduledListQueryProps) => { + const { index = 1, size = 10 } = props; + return useQuery({ + queryKey: getKey({ index, size }), + queryFn: () => getScheduledReportsList(props), + keepPreviousData: true, + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/index.ts b/x-pack/platform/plugins/private/reporting/public/management/index.ts index 09c4517e304e0..9bc608a4a8fb6 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/index.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { ApplicationStart, ToastsStart } from '@kbn/core/public'; +import type { ApplicationStart, HttpSetup, ToastsStart } from '@kbn/core/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ClientConfigType, ReportingAPIClient } from '@kbn/reporting-public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; @@ -21,6 +21,7 @@ export interface ListingProps { export type ListingPropsInternal = ListingProps & { capabilities: ApplicationStart['capabilities']; + http: HttpSetup; }; -export { ReportListing } from './report_listing'; +export { ReportingTabs } from './components/reporting_tabs'; 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 986a34d3c55b6..5a681c889ed7c 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 @@ -5,8 +5,8 @@ * 2.0. */ -import * as React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; +import React, { Suspense, lazy } from 'react'; +import ReactDOM from 'react-dom'; import type { CoreStart, NotificationsStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; @@ -24,9 +24,14 @@ import { import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '../query_client'; -import { ReportListing } from '.'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { Route, Router, Routes } from '@kbn/shared-ux-router'; +import { Redirect } from 'react-router-dom'; +import { Section } from '../constants'; import { PolicyStatusContextProvider } from '../lib/default_status_context'; +const ReportingTabs = lazy(() => import('./components/reporting_tabs')); + export async function mountManagementSection({ coreStart, license$, @@ -59,31 +64,49 @@ export async function mountManagementSection({ actions: actionsService, notifications: notificationsService, }; + const sections: Section[] = ['exports', 'schedules']; + const { element, history } = params; + + const sectionsRegex = sections.join('|'); - render( + ReactDOM.render( - + + + { + return ( + }> + + + ); + }} + /> + + + , - params.element + element ); return () => { - unmountComponentAtNode(params.element); + ReactDOM.unmountComponentAtNode(element); }; } 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 index efa86cccb03bb..8a99d4fe0e638 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts @@ -5,7 +5,12 @@ * 2.0. */ +const root = 'reporting'; export const queryKeys = { - root: 'reporting', - getHealth: () => [queryKeys.root, 'health'] as const, + getScheduledList: (params: unknown) => [root, 'scheduledList', params] as const, + getHealth: () => [root, 'health'] as const, +}; + +export const mutationKeys = { + bulkDisableScheduledReports: () => [root, 'bulkDisableScheduledReports'] as const, }; diff --git a/x-pack/platform/plugins/private/reporting/public/management/report_listing.test.ts b/x-pack/platform/plugins/private/reporting/public/management/report_listing.test.ts deleted file mode 100644 index 8f0dd9bf17a7f..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/report_listing.test.ts +++ /dev/null @@ -1,289 +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 { act } from 'react-dom/test-utils'; -import type { Observable } from 'rxjs'; - -import type { ILicense } from '@kbn/licensing-plugin/public'; -import { IlmPolicyMigrationStatus } from '@kbn/reporting-common/types'; - -import { ListingProps as Props } from '.'; -import { mockJobs } from '../../common/test'; -import { TestBed, TestDependencies, setup } from './__test__'; -import { mockConfig } from './__test__/report_listing.test.helpers'; -import { Job } from '@kbn/reporting-public'; - -describe('ReportListing', () => { - let testBed: TestBed; - let applicationService: TestDependencies['application']; - - const runSetup = async (props?: Partial) => { - await act(async () => { - testBed = await setup(props); - }); - testBed.component.update(); - }; - - beforeEach(async () => { - await runSetup(); - // Collect all of the injected services so we can mutate for the tests - applicationService = testBed.testDependencies.application; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('renders a listing with some items', () => { - const { find } = testBed; - expect(find('reportJobRow').length).toBe(mockJobs.length); - }); - - it('subscribes to license changes, and unsubscribes on dismount', async () => { - const unsubscribeMock = jest.fn(); - const subMock = { - subscribe: jest.fn().mockReturnValue({ - unsubscribe: unsubscribeMock, - }), - } as unknown as Observable; - - await runSetup({ license$: subMock }); - - expect(subMock.subscribe).toHaveBeenCalled(); - expect(unsubscribeMock).not.toHaveBeenCalled(); - testBed.component.unmount(); - expect(unsubscribeMock).toHaveBeenCalled(); - }); - - it('navigates to a Kibana App in a new tab and is spaces aware', () => { - const { find } = testBed; - - jest.spyOn(window, 'open').mockImplementation(jest.fn()); - jest.spyOn(window, 'focus').mockImplementation(jest.fn()); - - find('euiCollapsedItemActionsButton').first().simulate('click'); - find('reportOpenInKibanaApp').first().simulate('click'); - - expect(window.open).toHaveBeenCalledWith( - '/s/my-space/app/reportingRedirect?jobId=k90e51pk1ieucbae0c3t8wo2', - '_blank' - ); - }); - - describe('flyout', () => { - let reportingAPIClient: TestDependencies['reportingAPIClient']; - let jobUnderTest: Job; - - beforeEach(async () => { - await runSetup(); - reportingAPIClient = testBed.testDependencies.reportingAPIClient; - jest.spyOn(reportingAPIClient, 'getInfo').mockResolvedValue(jobUnderTest); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('shows the enabled "open in Kibana" button in the actions menu for v2 jobs', async () => { - const [jobJson] = mockJobs; - jobUnderTest = new Job(jobJson); - const { actions } = testBed; - - await actions.flyout.open(jobUnderTest.id); - actions.flyout.openActionsMenu(); - expect(actions.flyout.findOpenInAppButton().props().disabled).toBe(false); - }); - - it('shows the disabled "open in Kibana" button in the actions menu for pre-v2 jobs', async () => { - const [, jobJson] = mockJobs; - jobUnderTest = new Job(jobJson); - const { actions } = testBed; - - await actions.flyout.open(jobUnderTest.id); - actions.flyout.openActionsMenu(); - expect(actions.flyout.findOpenInAppButton().props().disabled).toBe(true); - }); - - it('shows the disabled "Download" button in the actions menu for a job that is not done', async () => { - const [jobJson] = mockJobs; - jobUnderTest = new Job(jobJson); - const { actions } = testBed; - - await actions.flyout.open(jobUnderTest.id); - actions.flyout.openActionsMenu(); - expect(actions.flyout.findDownloadButton().props().disabled).toBe(true); - }); - - it('shows the enabled "Download" button in the actions menu for a job is done', async () => { - const [, , jobJson] = mockJobs; - jobUnderTest = new Job(jobJson); - const { actions } = testBed; - - await actions.flyout.open(jobUnderTest.id); - actions.flyout.openActionsMenu(); - expect(actions.flyout.findDownloadButton().props().disabled).toBe(false); - }); - }); - - describe('ILM policy', () => { - let httpService: TestDependencies['http']; - let urlService: TestDependencies['urlService']; - let toasts: TestDependencies['toasts']; - let reportingAPIClient: TestDependencies['reportingAPIClient']; - - /** - * Simulate a fresh page load, useful for network requests and other effects - * that happen only at first load. - */ - const remountComponent = async () => { - const { component } = testBed; - act(() => { - component.unmount(); - }); - await act(async () => { - component.mount(); - }); - // Flush promises - await new Promise((r) => setImmediate(r)); - component.update(); - }; - - beforeEach(async () => { - await runSetup(); - // Collect all of the injected services so we can mutate for the tests - applicationService = testBed.testDependencies.application; - applicationService.capabilities = { - catalogue: {}, - navLinks: {}, - management: { data: { index_lifecycle_management: true } }, - }; - httpService = testBed.testDependencies.http; - urlService = testBed.testDependencies.urlService; - toasts = testBed.testDependencies.toasts; - reportingAPIClient = testBed.testDependencies.reportingAPIClient; - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('shows the migrate banner when migration status is not "OK"', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmMigrationBanner()).toBe(true); - }); - - it('does not show the migrate banner when migration status is "OK"', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'ok'; - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmMigrationBanner()).toBe(false); - }); - - it('hides the ILM policy link if there is no ILM policy', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'policy-not-found'; - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmPolicyLink()).toBe(false); - }); - - it('hides the ILM policy link if there is no ILM policy locator', async () => { - const { actions } = testBed; - jest.spyOn(urlService.locators, 'get').mockReturnValue(undefined); - const status: IlmPolicyMigrationStatus = 'ok'; // should never happen, but need to test that when the locator is missing we don't render the link - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmPolicyLink()).toBe(false); - }); - - it('always shows the ILM policy link if there is an ILM policy', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'ok'; - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmPolicyLink()).toBe(true); - - const status2: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; - httpService.get.mockResolvedValue({ status: status2 }); - await remountComponent(); - expect(actions.hasIlmPolicyLink()).toBe(true); - }); - - it('hides the banner after migrating indices', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; - const status2: IlmPolicyMigrationStatus = 'ok'; - httpService.get.mockResolvedValueOnce({ status }); - httpService.get.mockResolvedValueOnce({ status: status2 }); - await remountComponent(); - - expect(actions.hasIlmMigrationBanner()).toBe(true); - await actions.migrateIndices(); - expect(actions.hasIlmMigrationBanner()).toBe(false); - expect(actions.hasIlmPolicyLink()).toBe(true); - expect(toasts.addSuccess).toHaveBeenCalledTimes(1); - }); - - it('informs users when migrations failed', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; - httpService.get.mockResolvedValueOnce({ status }); - (reportingAPIClient.migrateReportingIndicesIlmPolicy as jest.Mock).mockRejectedValueOnce( - new Error('oops!') - ); - await remountComponent(); - - expect(actions.hasIlmMigrationBanner()).toBe(true); - await actions.migrateIndices(); - expect(toasts.addError).toHaveBeenCalledTimes(1); - expect(actions.hasIlmMigrationBanner()).toBe(true); - expect(actions.hasIlmPolicyLink()).toBe(true); - }); - - it('only shows the link to the ILM policy if UI capabilities allow it', async () => { - applicationService.capabilities = { - catalogue: {}, - navLinks: {}, - management: { data: { index_lifecycle_management: false } }, - }; - await remountComponent(); - - expect(testBed.actions.hasIlmPolicyLink()).toBe(false); - - applicationService.capabilities = { - catalogue: {}, - navLinks: {}, - management: { data: { index_lifecycle_management: true } }, - }; - - await remountComponent(); - - expect(testBed.actions.hasIlmPolicyLink()).toBe(true); - }); - }); - describe('Screenshotting Diagnostic', () => { - it('shows screenshotting diagnostic link if config enables image reports', () => { - expect(testBed.actions.hasScreenshotDiagnosticLink()).toBe(true); - }); - it('does not show when image reporting not set in config', async () => { - const mockNoImageConfig = { - ...mockConfig, - export_types: { - csv: { enabled: true }, - pdf: { enabled: false }, - png: { enabled: false }, - }, - }; - await runSetup({ config: mockNoImageConfig }); - expect(testBed.actions.hasScreenshotDiagnosticLink()).toBe(false); - }); - }); -}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/report_listing.tsx b/x-pack/platform/plugins/private/reporting/public/management/report_listing.tsx deleted file mode 100644 index a658d2e190051..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/report_listing.tsx +++ /dev/null @@ -1,26 +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 from 'react'; -import { useInternalApiClient, useKibana } from '@kbn/reporting-public'; -import { ReportListingStateful } from './stateful/report_listing_stateful'; -import { ReportListingDefault } from './default/report_listing_default'; -import { ListingProps } from '.'; - -export const ReportListing = (props: ListingProps) => { - const { apiClient } = useInternalApiClient(); - const { - services: { - application: { capabilities }, - }, - } = useKibana(); - return props.config.statefulSettings.enabled ? ( - - ) : ( - - ); -}; 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 deleted file mode 100644 index 910a32f7a5aed..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx +++ /dev/null @@ -1,88 +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, { FC } from 'react'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPageHeader, - EuiSpacer, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { ListingPropsInternal } from '..'; -import { useIlmPolicyStatus } from '../../lib/ilm_policy_status_context'; -import { IlmPolicyLink, MigrateIlmPolicyCallOut, ReportDiagnostic } from '../components'; -import { ReportListingTable } from '../report_listing_table'; - -/** - * Used in Stateful deployments only - * Renders controls for ILM and Screenshotting Diagnostics which are only applicable in Stateful - */ -export const ReportListingStateful: FC = (props) => { - const { apiClient, capabilities, config, navigateToUrl, toasts, urlService, ...listingProps } = - props; - const ilmLocator = urlService.locators.get('ILM_LOCATOR_ID'); - const ilmPolicyContextValue = useIlmPolicyStatus(); - const hasIlmPolicy = ilmPolicyContextValue?.status !== 'policy-not-found'; - const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); - - return ( - <> - - } - description={ - - } - /> - - - - - - - - - - {capabilities?.management?.data?.index_lifecycle_management && ( - - {ilmPolicyContextValue?.isLoading ? ( - - ) : ( - showIlmPolicyLink && ( - - ) - )} - - )} - - - - - - ); -}; diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index d9589d650ffb8..4eb247b5f17d8 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -39,6 +39,8 @@ import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import type { ReportingSetup, ReportingStart } from '.'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; import { StartServices } from './types'; +import { APP_DESC, APP_TITLE } from './translations'; +import { APP_PATH } from './constants'; export interface ReportingPublicPluginSetupDependencies { home: HomePublicPluginSetup; @@ -126,6 +128,7 @@ export class ReportingPublicPlugin notifications: start.notifications, rendering: start.rendering, uiSettings: start.uiSettings, + chrome: start.chrome, }, ...rest, ]; @@ -137,14 +140,10 @@ export class ReportingPublicPlugin homeSetup.featureCatalogue.register({ id: 'reporting', - title: i18n.translate('xpack.reporting.registerFeature.reportingTitle', { - defaultMessage: 'Reporting', - }), - description: i18n.translate('xpack.reporting.registerFeature.reportingDescription', { - defaultMessage: 'Manage your reports generated from Discover, Visualize, and Dashboard.', - }), + title: APP_TITLE, + description: APP_DESC, icon: 'reportingApp', - path: '/app/management/insightsAndAlerting/reporting', + path: APP_PATH, showOnHomePage: false, category: 'admin', }); diff --git a/x-pack/platform/plugins/private/reporting/public/redirect/redirect_app.tsx b/x-pack/platform/plugins/private/reporting/public/redirect/redirect_app.tsx index 34d4dabd63d24..78e1e088ada74 100644 --- a/x-pack/platform/plugins/private/reporting/public/redirect/redirect_app.tsx +++ b/x-pack/platform/plugins/private/reporting/public/redirect/redirect_app.tsx @@ -18,7 +18,7 @@ import { REPORTING_REDIRECT_LOCATOR_STORE_KEY, REPORTING_REDIRECT_ALLOWED_LOCATOR_TYPES, } from '@kbn/reporting-common'; -import { LocatorParams } from '@kbn/reporting-common/types'; +import { LocatorParams, BaseParamsV2 } from '@kbn/reporting-common/types'; import { ReportingAPIClient } from '@kbn/reporting-public'; import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public'; @@ -51,9 +51,15 @@ export const RedirectApp: FunctionComponent = ({ apiClient, screenshotMod try { let locatorParams: undefined | LocatorParams; - const { jobId } = parse(window.location.search); + const { jobId, scheduledReportId } = parse(window.location.search); - if (jobId) { + if (scheduledReportId) { + const scheduledReport = await apiClient.getScheduledReportInfo( + scheduledReportId as string + ); + + locatorParams = (scheduledReport?.payload as BaseParamsV2)?.locatorParams?.[0]; + } else if (jobId) { const result = await apiClient.getInfo(jobId as string); locatorParams = result?.locatorParams?.[0]; } else { diff --git a/x-pack/platform/plugins/private/reporting/public/translations.ts b/x-pack/platform/plugins/private/reporting/public/translations.ts new file mode 100644 index 0000000000000..b5fd9977f8a5c --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/translations.ts @@ -0,0 +1,30 @@ +/* + * 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 APP_TITLE = i18n.translate('xpack.reporting.registerFeature.reportingTitle', { + defaultMessage: 'Reporting', +}); + +export const APP_DESC = i18n.translate('xpack.reporting.registerFeature.reportingDescription', { + defaultMessage: 'Manage your reports generated from Discover, Visualize, and Dashboard.', +}); + +export const LOADING_REPORTS_DESCRIPTION = i18n.translate( + 'xpack.reporting.table.loadingReportsDescription', + { + defaultMessage: 'Loading reports', + } +); + +export const NO_CREATED_REPORTS_DESCRIPTION = i18n.translate( + 'xpack.reporting.table.noCreatedReportsDescription', + { + defaultMessage: 'No reports have been created', + } +); diff --git a/x-pack/platform/plugins/private/reporting/tsconfig.json b/x-pack/platform/plugins/private/reporting/tsconfig.json index 97f9602f4a192..8ac22d028c0ca 100644 --- a/x-pack/platform/plugins/private/reporting/tsconfig.json +++ b/x-pack/platform/plugins/private/reporting/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../../../../tsconfig.base.json", "compilerOptions": { - "outDir": "target/types", + "outDir": "target/types" }, "include": ["common/**/*", "public/**/*", "server/**/*", "../../../../../typings/**/*"], "kbn_references": [ @@ -58,11 +58,13 @@ "@kbn/notifications-plugin", "@kbn/spaces-utils", "@kbn/logging-mocks", + "@kbn/shared-ux-router", + "@kbn/controls-plugin", + "@kbn/dashboard-plugin", + "@kbn/core-http-browser-mocks", "@kbn/core-http-browser", "@kbn/response-ops-recurring-schedule-form", - "@kbn/core-mount-utils-browser-internal", + "@kbn/core-mount-utils-browser-internal" ], - "exclude": [ - "target/**/*", - ] + "exclude": ["target/**/*"] } diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index c8f101da9f285..884ea4bb0de41 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -33379,41 +33379,20 @@ "xpack.reporting.listing.infoPanel.tzInfo": "Fuseau horaire", "xpack.reporting.listing.infoPanel.unknownLabel": "inconnue", "xpack.reporting.listing.reports.ilmPolicyLinkText": "Modifier la politique ILM de reporting", - "xpack.reporting.listing.reports.subtitle": "Obtenir les rapports générés dans les applications Kibana.", "xpack.reporting.listing.reports.subtitleStateful": "Obtenir les rapports générés dans les applications Kibana.", "xpack.reporting.listing.reports.titleStateful": "Rapports", - "xpack.reporting.listing.reportstitle": "Rapports", - "xpack.reporting.listing.table.captionDescription": "Rapports générés dans les applications Kibana", "xpack.reporting.listing.table.deleteCancelButton": "Annuler", - "xpack.reporting.listing.table.deleteConfim": "Le rapport {reportTitle} a été supprimé", "xpack.reporting.listing.table.deleteConfirmButton": "Supprimer", "xpack.reporting.listing.table.deleteConfirmMessage": "Vous ne pouvez pas récupérer les rapports supprimés.", "xpack.reporting.listing.table.deleteConfirmTitle": "Supprimer le rapport \"{name}\" ?", - "xpack.reporting.listing.table.deleteFailedErrorMessage": "Le rapport n'a pas été supprimé : {error}", "xpack.reporting.listing.table.deleteNumConfirmTitle": "Supprimer les {num} rapports sélectionnés ?", "xpack.reporting.listing.table.deleteReportButton": "Supprimer {num, plural, one {le rapport} other {les rapports} }", - "xpack.reporting.listing.table.downloadReportButtonLabel": "Télécharger le rapport", - "xpack.reporting.listing.table.downloadReportDescription": "Téléchargez ce rapport dans un nouvel onglet.", - "xpack.reporting.listing.table.loadingReportsDescription": "Chargement des rapports", - "xpack.reporting.listing.table.noCreatedReportsDescription": "Aucun rapport n'a été créé", - "xpack.reporting.listing.table.noTitleLabel": "Sans titre", - "xpack.reporting.listing.table.openInKibanaAppDescription": "Ouvrez l’application Kibana directement à l’emplacement de génération de ce rapport.", - "xpack.reporting.listing.table.openInKibanaAppLabel": "Ouvrir dans Kibana", "xpack.reporting.listing.table.reportInfoAndErrorButtonTooltip": "Consultez les informations de rapport et le message d'erreur.", "xpack.reporting.listing.table.reportInfoAndWarningsButtonTooltip": "Consultez les informations de rapport et les avertissements.", "xpack.reporting.listing.table.reportInfoButtonTooltip": "Consultez les informations de rapport.", "xpack.reporting.listing.table.reportInfoUnableToFetch": "Impossible de récupérer les informations de rapport.", - "xpack.reporting.listing.table.requestFailedErrorMessage": "Demande refusée", "xpack.reporting.listing.table.showReportInfoAriaLabel": "Afficher les informations de rapport", "xpack.reporting.listing.table.untitledReport": "Rapport sans titre", - "xpack.reporting.listing.table.viewReportingInfoActionButtonDescription": "Accédez à des informations supplémentaires sur ce rapport.", - "xpack.reporting.listing.table.viewReportingInfoActionButtonLabel": "Afficher les informations du rapport", - "xpack.reporting.listing.tableColumns.actionsTitle": "Actions", - "xpack.reporting.listing.tableColumns.content": "Contenu", - "xpack.reporting.listing.tableColumns.createdAtTitle": "Créé à", - "xpack.reporting.listing.tableColumns.reportTitle": "Titre", - "xpack.reporting.listing.tableColumns.statusTitle": "Statut", - "xpack.reporting.listing.tableColumns.typeTitle": "Type", "xpack.reporting.management.reportingTitle": "Reporting", "xpack.reporting.pdfFooterImageDescription": "Image personnalisée à utiliser dans le pied de page du PDF", "xpack.reporting.pdfFooterImageLabel": "Image de pied de page du PDF", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 4a6b959681a3c..01b29ecc158bd 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -33356,41 +33356,20 @@ "xpack.reporting.listing.infoPanel.tzInfo": "タイムゾーン", "xpack.reporting.listing.infoPanel.unknownLabel": "不明", "xpack.reporting.listing.reports.ilmPolicyLinkText": "レポートILMポリシーを編集", - "xpack.reporting.listing.reports.subtitle": "Kibanaアプリケーションで生成されたレポートを取得します。", "xpack.reporting.listing.reports.subtitleStateful": "Kibanaアプリケーションで生成されたレポートを取得します。", "xpack.reporting.listing.reports.titleStateful": "レポート", - "xpack.reporting.listing.reportstitle": "レポート", - "xpack.reporting.listing.table.captionDescription": "Kibanaアプリケーションでレポートが生成されました", "xpack.reporting.listing.table.deleteCancelButton": "キャンセル", - "xpack.reporting.listing.table.deleteConfim": "{reportTitle} レポートを削除しました", "xpack.reporting.listing.table.deleteConfirmButton": "削除", "xpack.reporting.listing.table.deleteConfirmMessage": "削除されたレポートは復元できません。", "xpack.reporting.listing.table.deleteConfirmTitle": "「{name}」レポートを削除しますか?", - "xpack.reporting.listing.table.deleteFailedErrorMessage": "レポートは削除されませんでした:{error}", "xpack.reporting.listing.table.deleteNumConfirmTitle": "{num}件の選択したレポートを削除しますか?", "xpack.reporting.listing.table.deleteReportButton": "{num, plural, other {件のレポート} }を削除", - "xpack.reporting.listing.table.downloadReportButtonLabel": "レポートをダウンロード", - "xpack.reporting.listing.table.downloadReportDescription": "このレポートを新しいタブでダウンロードします。", - "xpack.reporting.listing.table.loadingReportsDescription": "レポートを読み込み中です", - "xpack.reporting.listing.table.noCreatedReportsDescription": "レポートが作成されていません", - "xpack.reporting.listing.table.noTitleLabel": "無題", - "xpack.reporting.listing.table.openInKibanaAppDescription": "このレポートが生成されたKibanaアプリを開きます。", - "xpack.reporting.listing.table.openInKibanaAppLabel": "Kibanaで開く", "xpack.reporting.listing.table.reportInfoAndErrorButtonTooltip": "レポート情報とエラーメッセージを参照してください。", "xpack.reporting.listing.table.reportInfoAndWarningsButtonTooltip": "レポート情報と警告を参照してください。", "xpack.reporting.listing.table.reportInfoButtonTooltip": "レポート情報を参照してください。", "xpack.reporting.listing.table.reportInfoUnableToFetch": "レポート情報を取得できません。", - "xpack.reporting.listing.table.requestFailedErrorMessage": "リクエストに失敗しました", "xpack.reporting.listing.table.showReportInfoAriaLabel": "レポート情報を表示", "xpack.reporting.listing.table.untitledReport": "無題のレポート", - "xpack.reporting.listing.table.viewReportingInfoActionButtonDescription": "このレポートの詳細を表示してください。", - "xpack.reporting.listing.table.viewReportingInfoActionButtonLabel": "レポート情報を表示", - "xpack.reporting.listing.tableColumns.actionsTitle": "アクション", - "xpack.reporting.listing.tableColumns.content": "コンテンツ", - "xpack.reporting.listing.tableColumns.createdAtTitle": "作成日時:", - "xpack.reporting.listing.tableColumns.reportTitle": "タイトル", - "xpack.reporting.listing.tableColumns.statusTitle": "ステータス", - "xpack.reporting.listing.tableColumns.typeTitle": "型", "xpack.reporting.management.reportingTitle": "レポート", "xpack.reporting.pdfFooterImageDescription": "PDFのフッターに使用するカスタム画像です", "xpack.reporting.pdfFooterImageLabel": "PDFフッター画像", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index fef186f203536..ce4449fbf6856 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -33413,41 +33413,20 @@ "xpack.reporting.listing.infoPanel.tzInfo": "时区", "xpack.reporting.listing.infoPanel.unknownLabel": "未知", "xpack.reporting.listing.reports.ilmPolicyLinkText": "编辑报告 ILM 策略", - "xpack.reporting.listing.reports.subtitle": "获取在 Kibana 应用程序中生成的报告。", "xpack.reporting.listing.reports.subtitleStateful": "获取在 Kibana 应用程序中生成的报告。", "xpack.reporting.listing.reports.titleStateful": "报告", - "xpack.reporting.listing.reportstitle": "报告", - "xpack.reporting.listing.table.captionDescription": "在 Kibana 应用程序中生成的报告", "xpack.reporting.listing.table.deleteCancelButton": "取消", - "xpack.reporting.listing.table.deleteConfim": "报告 {reportTitle} 已删除", "xpack.reporting.listing.table.deleteConfirmButton": "删除", "xpack.reporting.listing.table.deleteConfirmMessage": "您无法恢复删除的报告。", "xpack.reporting.listing.table.deleteConfirmTitle": "删除“{name}”报告?", - "xpack.reporting.listing.table.deleteFailedErrorMessage": "报告未删除:{error}", "xpack.reporting.listing.table.deleteNumConfirmTitle": "删除 {num} 个选定报告?", "xpack.reporting.listing.table.deleteReportButton": "删除{num, plural, other {报告} }", - "xpack.reporting.listing.table.downloadReportButtonLabel": "下载报告", - "xpack.reporting.listing.table.downloadReportDescription": "在新选项卡中下载此报告。", - "xpack.reporting.listing.table.loadingReportsDescription": "正在载入报告", - "xpack.reporting.listing.table.noCreatedReportsDescription": "未创建任何报告", - "xpack.reporting.listing.table.noTitleLabel": "未命名", - "xpack.reporting.listing.table.openInKibanaAppDescription": "打开生成此报告的 Kibana 应用。", - "xpack.reporting.listing.table.openInKibanaAppLabel": "在 Kibana 中打开", "xpack.reporting.listing.table.reportInfoAndErrorButtonTooltip": "查看报告信息和错误消息。", "xpack.reporting.listing.table.reportInfoAndWarningsButtonTooltip": "查看报告信息和警告。", "xpack.reporting.listing.table.reportInfoButtonTooltip": "查看报告信息。", "xpack.reporting.listing.table.reportInfoUnableToFetch": "无法提取报告信息。", - "xpack.reporting.listing.table.requestFailedErrorMessage": "请求失败", "xpack.reporting.listing.table.showReportInfoAriaLabel": "显示报告信息", "xpack.reporting.listing.table.untitledReport": "未命名报告", - "xpack.reporting.listing.table.viewReportingInfoActionButtonDescription": "查看有关此报告的其他信息。", - "xpack.reporting.listing.table.viewReportingInfoActionButtonLabel": "查看报告信息", - "xpack.reporting.listing.tableColumns.actionsTitle": "操作", - "xpack.reporting.listing.tableColumns.content": "内容", - "xpack.reporting.listing.tableColumns.createdAtTitle": "创建于", - "xpack.reporting.listing.tableColumns.reportTitle": "标题", - "xpack.reporting.listing.tableColumns.statusTitle": "状态", - "xpack.reporting.listing.tableColumns.typeTitle": "类型", "xpack.reporting.management.reportingTitle": "Reporting", "xpack.reporting.pdfFooterImageDescription": "要在 PDF 的页脚中使用的定制图像", "xpack.reporting.pdfFooterImageLabel": "PDF 页脚图像", diff --git a/x-pack/test/reporting_functional/reporting_and_security/management.ts b/x-pack/test/reporting_functional/reporting_and_security/management.ts index dccda59fed44a..263944393070a 100644 --- a/x-pack/test/reporting_functional/reporting_and_security/management.ts +++ b/x-pack/test/reporting_functional/reporting_and_security/management.ts @@ -24,13 +24,13 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { it('does not allow user that does not have reporting privileges', async () => { await reportingFunctional.loginDataAnalyst(); - await PageObjects.common.navigateToApp('reporting'); + await PageObjects.common.navigateToApp('reporting', { path: '/exports' }); await testSubjects.missingOrFail('reportJobListing'); }); it('does allow user with reporting privileges', async () => { await reportingFunctional.loginReportingUser(); - await PageObjects.common.navigateToApp('reporting'); + await PageObjects.common.navigateToApp('reporting', { path: '/exports' }); await testSubjects.existOrFail('reportJobListing'); }); @@ -54,5 +54,31 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await PageObjects.dashboard.expectOnDashboard(dashboardTitle); }); + + it('Allows user to view report details', async () => { + await PageObjects.common.navigateToApp('reporting'); + await (await testSubjects.findAll('euiCollapsedItemActionsButton'))[0].click(); + + await (await testSubjects.find('reportViewInfoLink')).click(); + + await testSubjects.existOrFail('reportInfoFlyout'); + }); + + describe('Schedules', () => { + it('does allow user with reporting privileges o navigate to the Schedules tab', async () => { + await reportingFunctional.loginReportingUser(); + + await PageObjects.common.navigateToApp('reporting'); + await (await testSubjects.find('reportingTabs-schedules')).click(); + await testSubjects.existOrFail('reportSchedulesTable'); + }); + + it('does not allow user to access schedules that does not have reporting privileges', async () => { + await reportingFunctional.loginDataAnalyst(); + + await PageObjects.common.navigateToApp('reporting'); + await testSubjects.missingOrFail('reportingTabs-schedules'); + }); + }); }); }; From 6a7880b1d824ba80906a80de130a5bbb48b014d6 Mon Sep 17 00:00:00 2001 From: Janki Salvi Date: Mon, 23 Jun 2025 12:16:35 +0100 Subject: [PATCH 03/13] add tests for table actions, fix license bug --- .../components/report_exports_table.tsx | 4 +- .../report_schedules_table.test.tsx | 124 +++++++++++++++++- .../management/components/reporting_tabs.tsx | 57 +++++--- .../management/mount_management_section.tsx | 2 +- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 7 files changed, 166 insertions(+), 27 deletions(-) diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx index 66d6d8fc0246c..13207f632f0cc 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx @@ -218,12 +218,12 @@ export class ReportExportsTable extends Component { */ private readonly tableColumnWidths = { type: '5%', - title: '30%', + title: '25%', status: '20%', createdAt: '21%', content: '7%', exportType: '12%', - actions: '5%', + actions: '10%', }; public render() { diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx index 015bd3dc18d96..cfdfbee0dcb52 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx @@ -12,7 +12,7 @@ import { notificationServiceMock, } from '@kbn/core/public/mocks'; import { render, screen, waitFor } from '@testing-library/react'; -import { ReportingAPIClient } from '@kbn/reporting-public'; +import { ReportingAPIClient, useKibana } from '@kbn/reporting-public'; import { Observable } from 'rxjs'; import { ILicense } from '@kbn/licensing-plugin/public'; import { SharePluginSetup } from '@kbn/share-plugin/public'; @@ -27,6 +27,18 @@ import { mockScheduledReports } from '../../../common/test/fixtures'; import { userEvent } from '@testing-library/user-event'; import { useBulkDisable } from '../hooks/use_bulk_disable'; +jest.mock('@kbn/reporting-public', () => ({ + useKibana: jest.fn(), + ReportingAPIClient: jest.fn().mockImplementation(() => ({ + getScheduledList: jest.fn(), + disableScheduledReports: jest.fn(), + })), +})); + +jest.mock('./scheduled_report_flyout', () => ({ + ScheduledReportFlyout: () =>
, +})); + jest.mock('../hooks/use_get_scheduled_list', () => ({ useGetScheduledList: jest.fn(), })); @@ -77,6 +89,7 @@ const queryClient = new QueryClient({ }, }, }); +const mockValidateEmailAddresses = jest.fn().mockResolvedValue([]); describe('ReportSchedulesTable', () => { const bulkDisableScheduledReportsMock = jest.fn(); @@ -95,6 +108,16 @@ describe('ReportSchedulesTable', () => { beforeEach(() => { jest.clearAllMocks(); + window.open = jest.fn(); + window.focus = jest.fn(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + ...coreStart, + actions: { + validateEmailAddresses: mockValidateEmailAddresses, + }, + }, + }); }); it('renders table correctly', async () => { @@ -241,4 +264,103 @@ describe('ReportSchedulesTable', () => { }); }); }); + + it('should show config flyout from table action', async () => { + (useGetScheduledList as jest.Mock).mockReturnValue({ + data: { + page: 3, + size: 10, + total: 3, + data: mockScheduledReports, + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findAllByTestId('scheduledReportRow')).toHaveLength(3); + + userEvent.click((await screen.findAllByTestId('euiCollapsedItemActionsButton'))[0]); + + const firstReportViewConfig = await screen.findByTestId( + `reportViewConfig-${mockScheduledReports[0].id}` + ); + + expect(firstReportViewConfig).toBeInTheDocument(); + + userEvent.click(firstReportViewConfig, { pointerEventsCheck: 0 }); + + expect(await screen.findByTestId('scheduledReportFlyout')).toBeInTheDocument(); + }); + + it('should show config flyout from title click', async () => { + (useGetScheduledList as jest.Mock).mockReturnValue({ + data: { + page: 3, + size: 10, + total: 3, + data: mockScheduledReports, + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findAllByTestId('scheduledReportRow')).toHaveLength(3); + + userEvent.click((await screen.findAllByTestId('reportTitle'))[0]); + + expect(await screen.findByTestId('scheduledReportFlyout')).toBeInTheDocument(); + }); + + it('should open dashboard', async () => { + (useGetScheduledList as jest.Mock).mockReturnValue({ + data: { + page: 3, + size: 10, + total: 3, + data: mockScheduledReports, + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findAllByTestId('scheduledReportRow')).toHaveLength(3); + + userEvent.click((await screen.findAllByTestId('euiCollapsedItemActionsButton'))[0]); + + const firstOpenDashboard = await screen.findByTestId( + `reportOpenDashboard-${mockScheduledReports[0].id}` + ); + + expect(firstOpenDashboard).toBeInTheDocument(); + + userEvent.click(firstOpenDashboard, { pointerEventsCheck: 0 }); + + await waitFor(() => { + expect(window.open).toHaveBeenCalledWith( + '/app/reportingRedirect?scheduledReportId=scheduled-report-1', + '_blank' + ); + }); + }); }); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx index e4b057851c86c..1e340accc2333 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx @@ -11,12 +11,11 @@ import { i18n } from '@kbn/i18n'; import { Route, Routes } from '@kbn/shared-ux-router'; import { RouteComponentProps } from 'react-router-dom'; import { CoreStart, ScopedHistory } from '@kbn/core/public'; -import { ILicense, LicenseType, LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import { ILicense, LicensingPluginStart } from '@kbn/licensing-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { ClientConfigType, ReportingAPIClient, - checkLicense, useInternalApiClient, useKibana, } from '@kbn/reporting-public'; @@ -24,6 +23,7 @@ import { SharePluginStart } from '@kbn/share-plugin/public'; import { FormattedMessage } from '@kbn/i18n-react'; import useObservable from 'react-use/lib/useObservable'; import { Observable } from 'rxjs'; +import { SCHEDULED_REPORT_VALID_LICENSES } from '@kbn/reporting-common'; import { suspendedComponentWithProps } from './suspended_component_with_props'; import { REPORTING_EXPORTS_PATH, REPORTING_SCHEDULES_PATH, Section } from '../../constants'; import ReportExportsTable from './report_exports_table'; @@ -68,15 +68,41 @@ export const ReportingTabs: React.FunctionComponent< const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); const license = useObservable(license$ ?? new Observable(), null); - const isAtLeast = useCallback( - (level: LicenseType) => { - if (!license) { - return { enableLinks: false, showLinks: false }; - } - return checkLicense(license.check('reporting', level)); - }, - [license] - ); + const hasValidLicense = useCallback(() => { + if (!license) { + return { enableLinks: false, showLinks: false }; + } + if (!license || !license.type) { + return { + showLinks: true, + enableLinks: false, + message: + 'You cannot use Reporting because license information is not available at this time.', + }; + } + + if (!license.isActive) { + return { + showLinks: true, + enableLinks: false, + message: 'You cannot use Reporting because your ${license.type} license has expired.', + }; + } + + if (!SCHEDULED_REPORT_VALID_LICENSES.includes(license.type)) { + return { + showLinks: false, + enableLinks: false, + message: + 'Your {licenseType} license does not support Scheduled reports. Please upgrade your license.', + }; + } + + return { + showLinks: true, + enableLinks: true, + }; + }, [license]); const tabs = [ { @@ -93,7 +119,7 @@ export const ReportingTabs: React.FunctionComponent< }, ]; - const { enableLinks, showLinks } = isAtLeast('trial'); + const { enableLinks, showLinks } = hasValidLicense(); const renderExportsList = useCallback(() => { return suspendedComponentWithProps( @@ -193,14 +219,11 @@ export const ReportingTabs: React.FunctionComponent< } data-test-subj="reportingPageHeader" pageTitle={ - + } description={ } 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 5a681c889ed7c..7d1917fa4bdbd 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 @@ -23,10 +23,10 @@ import { } from '@kbn/reporting-public'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import { QueryClientProvider } from '@tanstack/react-query'; -import { queryClient } from '../query_client'; import { EuiLoadingSpinner } from '@elastic/eui'; import { Route, Router, Routes } from '@kbn/shared-ux-router'; import { Redirect } from 'react-router-dom'; +import { queryClient } from '../query_client'; import { Section } from '../constants'; import { PolicyStatusContextProvider } from '../lib/default_status_context'; diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 884ea4bb0de41..f7908064a4886 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -33379,8 +33379,6 @@ "xpack.reporting.listing.infoPanel.tzInfo": "Fuseau horaire", "xpack.reporting.listing.infoPanel.unknownLabel": "inconnue", "xpack.reporting.listing.reports.ilmPolicyLinkText": "Modifier la politique ILM de reporting", - "xpack.reporting.listing.reports.subtitleStateful": "Obtenir les rapports générés dans les applications Kibana.", - "xpack.reporting.listing.reports.titleStateful": "Rapports", "xpack.reporting.listing.table.deleteCancelButton": "Annuler", "xpack.reporting.listing.table.deleteConfirmButton": "Supprimer", "xpack.reporting.listing.table.deleteConfirmMessage": "Vous ne pouvez pas récupérer les rapports supprimés.", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 01b29ecc158bd..a0638451b9197 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -33356,8 +33356,6 @@ "xpack.reporting.listing.infoPanel.tzInfo": "タイムゾーン", "xpack.reporting.listing.infoPanel.unknownLabel": "不明", "xpack.reporting.listing.reports.ilmPolicyLinkText": "レポートILMポリシーを編集", - "xpack.reporting.listing.reports.subtitleStateful": "Kibanaアプリケーションで生成されたレポートを取得します。", - "xpack.reporting.listing.reports.titleStateful": "レポート", "xpack.reporting.listing.table.deleteCancelButton": "キャンセル", "xpack.reporting.listing.table.deleteConfirmButton": "削除", "xpack.reporting.listing.table.deleteConfirmMessage": "削除されたレポートは復元できません。", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index ce4449fbf6856..5c16870facf3d 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -33413,8 +33413,6 @@ "xpack.reporting.listing.infoPanel.tzInfo": "时区", "xpack.reporting.listing.infoPanel.unknownLabel": "未知", "xpack.reporting.listing.reports.ilmPolicyLinkText": "编辑报告 ILM 策略", - "xpack.reporting.listing.reports.subtitleStateful": "获取在 Kibana 应用程序中生成的报告。", - "xpack.reporting.listing.reports.titleStateful": "报告", "xpack.reporting.listing.table.deleteCancelButton": "取消", "xpack.reporting.listing.table.deleteConfirmButton": "删除", "xpack.reporting.listing.table.deleteConfirmMessage": "您无法恢复删除的报告。", From d3da5c8f2808b7f6736f0efa61bd14394a81c324 Mon Sep 17 00:00:00 2001 From: Janki Salvi Date: Mon, 23 Jun 2025 14:23:40 +0100 Subject: [PATCH 04/13] sort list in descending --- .../public/management/components/report_schedules_table.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx index e795621563655..fd59bc3fc740d 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx @@ -20,6 +20,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; +import { orderBy } from 'lodash'; import { stringify } from 'query-string'; import { REPORTING_REDIRECT_APP, buildKibanaPath } from '@kbn/reporting-common'; import type { ScheduledReportApiJSON, BaseParamsV2 } from '@kbn/reporting-common/types'; @@ -62,6 +63,8 @@ export const ReportSchedulesTable = (props: ListingPropsInternal) => { toasts, }); + const sortedList = orderBy(scheduledList?.data || [], ['created_at'], ['desc']); + const tableColumns: Array> = [ { field: 'payload.objectType', @@ -281,7 +284,7 @@ export const ReportSchedulesTable = (props: ListingPropsInternal) => { Date: Mon, 23 Jun 2025 16:05:44 +0200 Subject: [PATCH 05/13] Restrict email recipients to user email for non reporting managers --- .../scheduled_report_flyout_content.test.tsx | 50 +++++++++++++++---- .../scheduled_report_flyout_content.tsx | 38 ++++++++++++-- .../scheduled_report_flyout_share_wrapper.tsx | 4 +- .../hooks/use_get_user_profile_query.ts | 24 +++++++++ .../scheduled_report_share_integration.tsx | 4 +- .../reporting/public/management/query_keys.ts | 1 + .../public/management/translations.ts | 15 ++++++ .../private/reporting/public/plugin.ts | 4 +- 8 files changed, 119 insertions(+), 21 deletions(-) create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.ts 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 8375c8ca3702e..82978314287b2 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 @@ -115,22 +115,35 @@ const TestProviders = ({ children }: PropsWithChildren) => ( {children} ); +const TEST_EMAIL = 'test@email.com'; + 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([]); +const mockValidateEmailAddresses = jest.fn().mockReturnValue([]); +const mockKibanaServices = { + ...coreServices, + application: { + ...coreServices.application, + capabilities: { + ...coreServices.application.capabilities, + manageReporting: { show: true }, + }, + }, + actions: { + validateEmailAddresses: mockValidateEmailAddresses, + }, + userProfile: { + getCurrent: jest.fn().mockResolvedValue({ user: { email: TEST_EMAIL } }), + }, +}; describe('ScheduledReportFlyoutContent', () => { beforeEach(() => { (useKibana as jest.Mock).mockReturnValue({ - services: { - ...coreServices, - actions: { - validateEmailAddresses: mockValidateEmailAddresses, - }, - }, + services: mockKibanaServices, }); jest.clearAllMocks(); testQueryClient.clear(); @@ -215,7 +228,20 @@ describe('ScheduledReportFlyoutContent', () => { 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 () => { + it('should disable the To field when user is not reporting manager', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + ...mockKibanaServices, + application: { + ...mockKibanaServices.application, + capabilities: { + ...mockKibanaServices.application.capabilities, + manageReporting: { show: false }, + }, + }, + }, + }); + 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(); + const emailField = await screen.findByTestId('emailRecipientsCombobox'); + const emailInput = within(emailField).getByTestId('comboBoxSearchInput'); + expect(emailInput).toBeDisabled(); + expect(screen.getByText('Sensitive information')).toBeInTheDocument(); }); it('should show a warning callout when the notification email connector is missing', async () => { @@ -324,7 +352,7 @@ describe('ScheduledReportFlyoutContent', () => { }); it('should show validation error on invalid email', async () => { - mockValidateEmailAddresses.mockReturnValue([{ valid: false, reason: 'notAllowed' }]); + mockValidateEmailAddresses.mockReturnValueOnce([{ valid: false, reason: 'notAllowed' }]); render( 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 fafa0de1244bb..fa1747276cb58 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,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import moment from 'moment'; import { EuiButton, @@ -38,6 +38,7 @@ 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 { useGetUserProfileQuery } from '../hooks/use_get_user_profile_query'; import { ResponsiveFormGroup } from './responsive_form_group'; import { getReportParams } from '../report_params'; import { getScheduledReportFormSchema } from '../schemas/scheduled_report_form_schema'; @@ -85,15 +86,26 @@ export const ScheduledReportFlyoutContent = ({ throw new Error('Cannot schedule an export without an objectType or sharingData'); } const { + application: { capabilities }, http, actions: { validateEmailAddresses }, notifications: { toasts }, + userProfile: userProfileService, } = useKibana().services; + const { data: userProfile, isLoading: isUserProfileLoading } = useGetUserProfileQuery({ + userProfileService, + }); const { data: reportingHealth, isLoading: isReportingHealthLoading, isError: isReportingHealthError, } = useGetReportingHealthQuery({ http }); + const hasManageReportingPrivilege = useMemo(() => { + if (!capabilities) { + return false; + } + return capabilities.manageReporting.show === true; + }, [capabilities]); const reportingPageLink = useMemo( () => ( @@ -179,6 +191,12 @@ export const ScheduledReportFlyoutContent = ({ watch: ['reportTypeId', 'sendByEmail'], }); + useEffect(() => { + if (!hasManageReportingPrivilege && userProfile?.user.email) { + form.setFieldValue('emailRecipients', [userProfile.user.email]); + } + }, [form, hasManageReportingPrivilege, userProfile?.user.email]); + const isRecurring = recurring || false; const isEmailActive = sendByEmail || false; @@ -204,7 +222,7 @@ export const ScheduledReportFlyoutContent = ({ - {isReportingHealthLoading ? ( + {isReportingHealthLoading || isUserProfileLoading ? ( ) : isReportingHealthError ? ( @@ -315,11 +340,14 @@ export const ScheduledReportFlyoutContent = ({ componentProps={{ compressed: true, fullWidth: true, - helpText: i18n.SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_HINT, + helpText: hasManageReportingPrivilege + ? i18n.SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_HINT + : i18n.SCHEDULED_REPORT_FORM_EMAIL_SELF_HINT, euiFieldProps: { compressed: true, fullWidth: true, readOnly, + isDisabled: !hasManageReportingPrivilege, 'data-test-subj': 'emailRecipientsCombobox', }, }} @@ -363,7 +391,7 @@ export const ScheduledReportFlyoutContent = ({ void; } diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.ts b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.ts new file mode 100644 index 0000000000000..17fd4b5e4908a --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.ts @@ -0,0 +1,24 @@ +/* + * 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 type { UserProfileService } from '@kbn/core-user-profile-browser'; +import { queryKeys } from '../query_keys'; + +export const getKey = queryKeys.getUserProfile; + +export const useGetUserProfileQuery = ({ + userProfileService, +}: { + userProfileService?: UserProfileService; +}) => { + return useQuery({ + queryKey: getKey(), + queryFn: () => userProfileService!.getCurrent(), + enabled: Boolean(userProfileService), + }); +}; 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 fa7fb54c8957c..cf73cf7890f3d 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 @@ -17,12 +17,12 @@ import { getKey as getReportingHealthQueryKey } from '../hooks/use_get_reporting 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 type { ReportingPublicPluginStartDependencies } from '../../plugin'; import { getReportingHealth } from '../apis/get_reporting_health'; export interface CreateScheduledReportProviderOptions { apiClient: ReportingAPIClient; - services: ReportingPublicPluginSetupDependencies; + services: ReportingPublicPluginStartDependencies; } export const shouldRegisterScheduledReportShareIntegration = async (http: HttpSetup) => { 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 index 8a99d4fe0e638..8ada852e0738e 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts @@ -9,6 +9,7 @@ const root = 'reporting'; export const queryKeys = { getScheduledList: (params: unknown) => [root, 'scheduledList', params] as const, getHealth: () => [root, 'health'] as const, + getUserProfile: () => [root, 'userProfile'] as const, }; export const mutationKeys = { 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 e2771b76c068d..6f937914fa6b7 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/translations.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/translations.ts @@ -168,6 +168,14 @@ export const SCHEDULED_REPORT_FORM_SEND_BY_EMAIL_LABEL = i18n.translate( } ); +export const SCHEDULED_REPORT_FORM_NO_USER_EMAIL_HINT = i18n.translate( + 'xpack.reporting.scheduledReportingForm.noUserEmailHint', + { + defaultMessage: + 'To receive reports by email, you must have an email address set in your user profile.', + } +); + export const SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_LABEL = i18n.translate( 'xpack.reporting.scheduledReportingForm.emailRecipientsLabel', { @@ -190,6 +198,13 @@ export const SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_HINT = i18n.translate( } ); +export const SCHEDULED_REPORT_FORM_EMAIL_SELF_HINT = i18n.translate( + 'xpack.reporting.scheduledReportingForm.emailSelfHint', + { + defaultMessage: "On the scheduled date, we'll also email the report to your address.", + } +); + export const SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_TITLE = i18n.translate( 'xpack.reporting.scheduledReportingForm.missingEmailConnectorTitle', { diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 4eb247b5f17d8..6f367441914e9 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -59,6 +59,7 @@ export interface ReportingPublicPluginStartDependencies { licensing: LicensingPluginStart; uiActions: UiActionsStart; share: SharePluginStart; + actions: ActionsPublicPluginSetup; } type StartServices$ = Observable; @@ -248,11 +249,12 @@ export class ReportingPublicPlugin shouldRegisterScheduledReportShareIntegration, createScheduledReportShareIntegration, }) => { + const [coreStart, startDeps] = await getStartServices(); if (await shouldRegisterScheduledReportShareIntegration(core.http)) { shareSetup.registerShareIntegration( createScheduledReportShareIntegration({ apiClient, - services: { ...core, ...setupDeps }, + services: { ...coreStart, ...startDeps, actions: actionsSetup }, }) ); } From adbcfd1fa42f1578f9e637584e478deb5e49d94e Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 23 Jun 2025 14:17:40 +0000 Subject: [PATCH 06/13] [CI] Auto-commit changed files from 'node scripts/notice' --- x-pack/platform/plugins/private/reporting/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/private/reporting/tsconfig.json b/x-pack/platform/plugins/private/reporting/tsconfig.json index 8ac22d028c0ca..61c4266ab83ff 100644 --- a/x-pack/platform/plugins/private/reporting/tsconfig.json +++ b/x-pack/platform/plugins/private/reporting/tsconfig.json @@ -64,7 +64,8 @@ "@kbn/core-http-browser-mocks", "@kbn/core-http-browser", "@kbn/response-ops-recurring-schedule-form", - "@kbn/core-mount-utils-browser-internal" + "@kbn/core-mount-utils-browser-internal", + "@kbn/core-user-profile-browser" ], "exclude": ["target/**/*"] } From ede089340d2b0d4a8a1ff7d5a2324b02cb21390a Mon Sep 17 00:00:00 2001 From: Janki Salvi Date: Mon, 23 Jun 2025 15:46:45 +0100 Subject: [PATCH 07/13] add more tests --- .../components/report_exports_table.test.tsx | 66 ++++++++++++++++++- .../components/report_exports_table.tsx | 2 +- .../report_schedules_table.test.tsx | 2 +- .../components/report_schedules_table.tsx | 4 +- 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx index 7d381f3921957..1a6ca68889096 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx @@ -12,11 +12,12 @@ import { notificationServiceMock, } from '@kbn/core/public/mocks'; import { ReportExportsTable } from './report_exports_table'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { Job, ReportingAPIClient } from '@kbn/reporting-public'; import { Observable } from 'rxjs'; import { ILicense } from '@kbn/licensing-plugin/public'; import { SharePluginSetup } from '@kbn/share-plugin/public'; +import { userEvent } from '@testing-library/user-event'; import { mockConfig } from '../__test__/report_listing.test.helpers'; import React from 'react'; import { REPORT_TABLE_ID, REPORT_TABLE_ROW_ID } from '@kbn/reporting-common'; @@ -24,6 +25,10 @@ import { mockJobs } from '../../../common/test'; import { RecursivePartial, UseEuiTheme } from '@elastic/eui'; import { ThemeProvider } from '@emotion/react'; +jest.mock('./report_info_flyout', () => ({ + ReportInfoFlyout: () =>
, +})); + const coreStart = coreMock.createStart(); const http = httpServiceMock.createSetupContract(); const uiSettingsClient = coreMock.createSetup().uiSettings; @@ -67,6 +72,8 @@ describe('ReportExportsTable', () => { .spyOn(reportingAPIClient, 'list') .mockImplementation(() => Promise.resolve(mockJobs.map((j) => new Job(j)))); jest.spyOn(reportingAPIClient, 'total').mockImplementation(() => Promise.resolve(18)); + window.open = jest.fn(); + window.focus = jest.fn(); }); it('renders table correctly', async () => { @@ -103,4 +110,61 @@ describe('ReportExportsTable', () => { expect(await screen.findByTestId(`viewReportingLink-${mockJobs[0].id}`)).toBeInTheDocument(); expect(await screen.findByTestId(`reportDownloadLink-${mockJobs[0].id}`)).toBeInTheDocument(); }); + + it('should show config flyout from table action', async () => { + render( + + + + ); + + userEvent.click((await screen.findAllByTestId('euiCollapsedItemActionsButton'))[0]); + + const firstReportViewConfig = await screen.findByTestId(`viewReportingLink-${mockJobs[0].id}`); + + expect(firstReportViewConfig).toBeInTheDocument(); + + userEvent.click(firstReportViewConfig, { pointerEventsCheck: 0 }); + + expect(await screen.findByTestId('reportInfoFlyout')).toBeInTheDocument(); + }); + + it('should show config flyout from title click', async () => { + render( + + + + ); + + const reportViewConfig = await screen.findByTestId(`viewReportingLink-${mockJobs[1].id}`); + + expect(reportViewConfig).toBeInTheDocument(); + + userEvent.click(reportViewConfig, { pointerEventsCheck: 0 }); + + expect(await screen.findByTestId('reportInfoFlyout')).toBeInTheDocument(); + }); + + it('should open dashboard', async () => { + render( + + + + ); + + userEvent.click((await screen.findAllByTestId('euiCollapsedItemActionsButton'))[0]); + + const firstOpenDashboard = await screen.findByTestId('reportOpenInKibanaApp'); + + expect(firstOpenDashboard).toBeInTheDocument(); + + userEvent.click(firstOpenDashboard, { pointerEventsCheck: 0 }); + + await waitFor(() => { + expect(window.open).toHaveBeenCalledWith( + '/s/my-space/app/reportingRedirect?jobId=k90e51pk1ieucbae0c3t8wo2', + '_blank' + ); + }); + }); }); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx index 13207f632f0cc..5b9e356129b78 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx @@ -402,7 +402,7 @@ export class ReportExportsTable extends Component { }, { name: i18n.translate('xpack.reporting.exports.table.openInKibanaAppLabel', { - defaultMessage: 'Open in Kibana', + defaultMessage: 'Open Dashboard', }), 'data-test-subj': 'reportOpenInKibanaApp', description: i18n.translate( diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx index cfdfbee0dcb52..18390583788b0 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx @@ -16,6 +16,7 @@ import { ReportingAPIClient, useKibana } from '@kbn/reporting-public'; import { Observable } from 'rxjs'; import { ILicense } from '@kbn/licensing-plugin/public'; import { SharePluginSetup } from '@kbn/share-plugin/public'; +import { userEvent } from '@testing-library/user-event'; import { mockConfig } from '../__test__/report_listing.test.helpers'; import React from 'react'; import { RecursivePartial, UseEuiTheme } from '@elastic/eui'; @@ -24,7 +25,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { useGetScheduledList } from '../hooks/use_get_scheduled_list'; import { mockScheduledReports } from '../../../common/test/fixtures'; -import { userEvent } from '@testing-library/user-event'; import { useBulkDisable } from '../hooks/use_bulk_disable'; jest.mock('@kbn/reporting-public', () => ({ diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx index fd59bc3fc740d..c27fb2a6aa83c 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx @@ -142,8 +142,8 @@ export const ReportSchedulesTable = (props: ListingPropsInternal) => { defaultMessage: 'Next schedule', }), width: '20%', - render: (_nextRun: string) => { - return moment(_nextRun).format('YYYY-MM-DD @ hh:mm A'); + render: (_nextRun: string, item) => { + return item.enabled ? moment(_nextRun).format('YYYY-MM-DD @ hh:mm A') : '—'; }, }, { From 505aeb0a968a6c3c1286d4cb63d7f516dd297b7e Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Mon, 23 Jun 2025 17:36:35 +0200 Subject: [PATCH 08/13] Add tests --- .../hooks/use_bulk_disable.test.tsx | 6 +- .../hooks/use_default_timezone.test.ts | 40 ++++++ .../use_get_reporting_health_query.test.tsx | 47 +++++++ .../hooks/use_get_scheduled_list.test.tsx | 6 +- .../hooks/use_get_user_profile_query.test.tsx | 57 ++++++++ .../hooks/use_schedule_report.test.tsx | 51 +++++++ .../reporting/public/management/utils.test.ts | 126 ++++++++++++++++++ .../validators/emails_validator.test.ts | 65 +++++++++ 8 files changed, 392 insertions(+), 6 deletions(-) create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.test.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.test.tsx create mode 100644 x-pack/platform/plugins/private/reporting/public/management/utils.test.ts create mode 100644 x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.test.ts diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx index 7cfd82a51fa17..63f26c8d23f82 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx @@ -7,10 +7,11 @@ import React from 'react'; import { httpServiceMock, notificationServiceMock } from '@kbn/core/public/mocks'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { useBulkDisable } from './use_bulk_disable'; import { bulkDisableScheduledReports } from '../apis/bulk_disable_scheduled_reports'; +import { testQueryClient } from '../test_utils/test_query_client'; jest.mock('../apis/bulk_disable_scheduled_reports', () => ({ bulkDisableScheduledReports: jest.fn(), @@ -19,10 +20,9 @@ jest.mock('../apis/bulk_disable_scheduled_reports', () => ({ describe('useBulkDisable', () => { const http = httpServiceMock.createStartContract(); const toasts = notificationServiceMock.createStartContract().toasts; - const queryClient = new QueryClient(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + {children} ); beforeEach(() => { diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.test.ts b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.test.ts new file mode 100644 index 0000000000000..49571694dbadf --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_default_timezone.test.ts @@ -0,0 +1,40 @@ +/* + * 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 'moment-timezone'; +import moment from 'moment'; +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; +import { useDefaultTimezone } from './use_default_timezone'; + +jest.mock('@kbn/kibana-react-plugin/public'); +const mockedUseUiSetting = jest.mocked(useUiSetting); + +describe('useDefaultTimezone', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns browser timezone when kibanaTz is "Browser"', () => { + mockedUseUiSetting.mockReturnValue('Browser'); + jest.spyOn(moment.tz, 'guess').mockReturnValue('Europe/Berlin'); + const result = useDefaultTimezone(); + expect(result).toEqual({ defaultTimezone: 'Europe/Berlin', isBrowser: true }); + }); + + it('returns UTC when kibanaTz is falsy', () => { + mockedUseUiSetting.mockReturnValue(undefined); + // @ts-expect-error testing fallback to UTC + jest.spyOn(moment.tz, 'guess').mockReturnValue(undefined); + const result = useDefaultTimezone(); + expect(result).toEqual({ defaultTimezone: 'UTC', isBrowser: true }); + }); + + it('returns kibanaTz when it is set', () => { + mockedUseUiSetting.mockReturnValue('America/New_York'); + const result = useDefaultTimezone(); + expect(result).toEqual({ defaultTimezone: 'America/New_York', isBrowser: false }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.test.tsx new file mode 100644 index 0000000000000..67c8b2377badb --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_reporting_health_query.test.tsx @@ -0,0 +1,47 @@ +/* + * 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, { type PropsWithChildren } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { testQueryClient } from '../test_utils/test_query_client'; +import { useGetReportingHealthQuery } from './use_get_reporting_health_query'; +import * as getReportingHealthModule from '../apis/get_reporting_health'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import type { HttpSetup } from '@kbn/core-http-browser'; + +jest.mock('../apis/get_reporting_health', () => ({ + getReportingHealth: jest.fn(), +})); + +const mockHttpService = httpServiceMock.create() as unknown as HttpSetup; + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +describe('useGetReportingHealthQuery', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls getReportingHealth with correct arguments', async () => { + const mockHealth = { status: 'ok' }; + (getReportingHealthModule.getReportingHealth as jest.Mock).mockResolvedValue(mockHealth); + + const { result } = renderHook(() => useGetReportingHealthQuery({ http: mockHttpService }), { + wrapper, + }); + + await waitFor(() => result.current.isSuccess); + + expect(getReportingHealthModule.getReportingHealth).toHaveBeenCalledWith({ + http: mockHttpService, + }); + expect(result.current.data).toEqual(mockHealth); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx index 8a7fff87387e7..d7ea38502e41c 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx @@ -7,10 +7,11 @@ import React from 'react'; import { httpServiceMock } from '@kbn/core/public/mocks'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { getScheduledReportsList } from '../apis/get_scheduled_reports_list'; import { useGetScheduledList } from './use_get_scheduled_list'; +import { testQueryClient } from '../test_utils/test_query_client'; jest.mock('../apis/get_scheduled_reports_list', () => ({ getScheduledReportsList: jest.fn(), @@ -18,10 +19,9 @@ jest.mock('../apis/get_scheduled_reports_list', () => ({ describe('useGetScheduledList', () => { const http = httpServiceMock.createStartContract(); - const queryClient = new QueryClient(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + {children} ); beforeEach(() => { diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.test.tsx new file mode 100644 index 0000000000000..d766648f2209b --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.test.tsx @@ -0,0 +1,57 @@ +/* + * 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 { renderHook, waitFor } from '@testing-library/react'; +import { useGetUserProfileQuery } from './use_get_user_profile_query'; +import { UserProfileService } from '@kbn/core/public'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { PropsWithChildren } from 'react'; +import { testQueryClient } from '../test_utils/test_query_client'; + +const mockUserProfileService = { + getCurrent: jest.fn(), +}; + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +describe('useGetUserProfileQuery', () => { + beforeEach(() => { + testQueryClient.clear(); + jest.clearAllMocks(); + }); + + it('should call userProfileService.getCurrent and returns the user profile', async () => { + const mockProfile = { user: 'test-user' }; + mockUserProfileService.getCurrent.mockResolvedValue(mockProfile); + + const { result } = renderHook( + () => + useGetUserProfileQuery({ + userProfileService: mockUserProfileService as unknown as UserProfileService, + }), + { wrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(mockUserProfileService.getCurrent).toHaveBeenCalled(); + expect(result.current.data).toEqual(mockProfile); + }); + + it('does not call getCurrent if userProfileService is not provided', async () => { + const { result } = renderHook(() => useGetUserProfileQuery({ userProfileService: undefined }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.test.tsx new file mode 100644 index 0000000000000..0d7c68ecaeb92 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_schedule_report.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { testQueryClient } from '../test_utils/test_query_client'; +import { useScheduleReport } from './use_schedule_report'; +import * as scheduleReportApi from '../apis/schedule_report'; +import { HttpSetup } from '@kbn/core/public'; + +const mockHttp = {} as HttpSetup; + +jest.mock('../apis/schedule_report', () => ({ + scheduleReport: jest.fn(), +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('useScheduleReport', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call scheduleReport with correct arguments and return data', async () => { + const mockResponse = { id: 'report-123' }; + (scheduleReportApi.scheduleReport as jest.Mock).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useScheduleReport({ http: mockHttp }), { + wrapper, + }); + + act(() => { + result.current.mutate({ reportTypeId: 'printablePdfV2', jobParams: '' }); + }); + + await waitFor(() => result.current.isSuccess); + + expect(scheduleReportApi.scheduleReport).toHaveBeenCalledWith({ + http: mockHttp, + params: { reportTypeId: 'printablePdfV2', jobParams: '' }, + }); + expect(result.current.data).toEqual(mockResponse); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/utils.test.ts b/x-pack/platform/plugins/private/reporting/public/management/utils.test.ts new file mode 100644 index 0000000000000..e2e801a4f8622 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/utils.test.ts @@ -0,0 +1,126 @@ +/* + * 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 { Frequency } from '@kbn/rrule'; +import { transformScheduledReport } from './utils'; +import { ScheduledReportApiJSON } from '@kbn/reporting-common/types'; +import { RecurrenceEnd } from '@kbn/response-ops-recurring-schedule-form/constants'; + +const baseReport = { + title: 'Test report', + reportTypeId: 'report123', + notification: { email: { to: ['test@example.com'] } }, + schedule: { + rrule: { + freq: Frequency.WEEKLY, + interval: 1, + tzid: 'America/New_York', + byweekday: ['MO'], + }, + }, + jobtype: 'report123', +} as unknown as ScheduledReportApiJSON; + +describe('transformScheduledReport', () => { + it('transforms a non-custom rRule with freq=WEEKLY and one weekday', () => { + expect(transformScheduledReport(baseReport)).toEqual( + expect.objectContaining({ + title: 'Test report', + reportTypeId: 'report123', + timezone: 'America/New_York', + recurring: true, + sendByEmail: true, + emailRecipients: ['test@example.com'], + recurringSchedule: { + frequency: Frequency.WEEKLY, + interval: 1, + ends: RecurrenceEnd.NEVER, + byweekday: { 1: true }, + }, + }) + ); + }); + + it('marks as custom when freq=DAILY and interval > 1', () => { + const report = { + ...baseReport, + schedule: { rrule: { freq: Frequency.DAILY, tzid: 'UTC', interval: 2 } }, + } as ScheduledReportApiJSON; + expect(transformScheduledReport(report).recurringSchedule).toEqual( + expect.objectContaining({ + frequency: 'CUSTOM', + customFrequency: Frequency.DAILY, + interval: 2, + }) + ); + }); + + it('marks as custom when freq=DAILY and no weekdays', () => { + const report = { + ...baseReport, + schedule: { rrule: { freq: Frequency.DAILY, tzid: 'UTC' } }, + } as ScheduledReportApiJSON; + expect(transformScheduledReport(report).recurringSchedule).toEqual( + expect.objectContaining({ frequency: 'CUSTOM', customFrequency: Frequency.DAILY }) + ); + }); + + it('marks as custom when freq=WEEKLY and multiple weekdays', () => { + const report = { + ...baseReport, + schedule: { rrule: { freq: Frequency.WEEKLY, tzid: 'UTC', byweekday: ['MO', 'TU'] } }, + } as ScheduledReportApiJSON; + expect(transformScheduledReport(report).recurringSchedule).toEqual( + expect.objectContaining({ frequency: 'CUSTOM', customFrequency: Frequency.WEEKLY }) + ); + }); + + it('handles monthly with bymonthday', () => { + const report = { + ...baseReport, + schedule: { rrule: { freq: Frequency.MONTHLY, tzid: 'UTC', bymonthday: [15] } }, + } as ScheduledReportApiJSON; + expect(transformScheduledReport(report).recurringSchedule).toEqual( + expect.objectContaining({ frequency: 'CUSTOM', bymonth: 'day', bymonthday: 15 }) + ); + }); + + it('handles monthly with byweekday', () => { + const report = { + ...baseReport, + schedule: { rrule: { freq: Frequency.MONTHLY, tzid: 'UTC', byweekday: ['FR'] } }, + } as ScheduledReportApiJSON; + expect(transformScheduledReport(report).recurringSchedule).toEqual( + expect.objectContaining({ bymonth: 'weekday', bymonthweekday: 'FR' }) + ); + }); + + it('extracts byhour and byminute', () => { + const report = { + ...baseReport, + schedule: { + rrule: { + freq: Frequency.WEEKLY, + tzid: 'UTC', + byhour: [8], + byminute: [30], + byweekday: ['MO'], + }, + }, + } as ScheduledReportApiJSON; + expect(transformScheduledReport(report).recurringSchedule).toEqual( + expect.objectContaining({ byhour: 8, byminute: 30 }) + ); + }); + + it('returns empty recipients if no notification', () => { + const report = { ...baseReport, notification: undefined } as ScheduledReportApiJSON; + expect(transformScheduledReport(report)).toEqual( + expect.objectContaining({ sendByEmail: false, emailRecipients: [] }) + ); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.test.ts b/x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.test.ts new file mode 100644 index 0000000000000..4df860021c4db --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/validators/emails_validator.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { getEmailsValidator } from './emails_validator'; + +jest.mock('../translations', () => ({ + getInvalidEmailAddress: (value: string) => `invalid: ${value}`, + getNotAllowedEmailAddress: (value: string) => `not allowed: ${value}`, +})); + +describe('getEmailsValidator', () => { + it('returns undefined for all valid emails', () => { + const validateEmailAddresses = jest.fn().mockReturnValue([{ valid: true }, { valid: true }]); + + const validator = getEmailsValidator(validateEmailAddresses); + expect( + // @ts-expect-error form lib type uses string as type + validator({ value: ['test1@example.com', 'test2@example.com'], path: 'some.path' }) + ).toBeUndefined(); + }); + + it('returns not allowed message if one email is not allowed', () => { + const validateEmailAddresses = jest + .fn() + .mockReturnValue([{ valid: false, reason: InvalidEmailReason.notAllowed }]); + + const validator = getEmailsValidator(validateEmailAddresses); + // @ts-expect-error form lib type uses string as type + expect(validator({ value: ['blocked@example.com'], path: 'some.path' })).toEqual({ + path: 'some.path', + message: 'not allowed: blocked@example.com', + }); + }); + + it('returns invalid message if one email is invalid', () => { + const validateEmailAddresses = jest + .fn() + .mockReturnValue([{ valid: false, reason: InvalidEmailReason.invalid }]); + + const validator = getEmailsValidator(validateEmailAddresses); + // @ts-expect-error form lib type uses string as type + expect(validator({ value: ['invalid@example.com'], path: 'some.path' })).toEqual({ + path: 'some.path', + message: 'invalid: invalid@example.com', + }); + }); + + it('validates a single string value as an array', () => { + const validateEmailAddresses = jest + .fn() + .mockReturnValue([{ valid: false, reason: InvalidEmailReason.invalid }]); + + const validator = getEmailsValidator(validateEmailAddresses); + // @ts-expect-error form lib type uses string as type + expect(validator({ value: 'not-an-email', path: 'some.path' })).toEqual({ + path: 'some.path', + message: 'invalid: not-an-email', + }); + }); +}); From 798f2cc56197430e8511c26fe65f263dec10f458 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Mon, 23 Jun 2025 20:12:52 +0200 Subject: [PATCH 09/13] Fix test --- .../hooks/use_get_user_profile_query.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.test.tsx index d766648f2209b..bd6c8459979df 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.test.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_user_profile_query.test.tsx @@ -10,9 +10,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useGetUserProfileQuery } from './use_get_user_profile_query'; import { UserProfileService } from '@kbn/core/public'; import { QueryClientProvider } from '@tanstack/react-query'; +import * as reactQuery from '@tanstack/react-query'; import { PropsWithChildren } from 'react'; import { testQueryClient } from '../test_utils/test_query_client'; +const useQuerySpy = jest.spyOn(reactQuery, 'useQuery'); + const mockUserProfileService = { getCurrent: jest.fn(), }; @@ -45,13 +48,16 @@ describe('useGetUserProfileQuery', () => { expect(result.current.data).toEqual(mockProfile); }); - it('does not call getCurrent if userProfileService is not provided', async () => { + it('should not enable the query if userProfileService is not provided', async () => { const { result } = renderHook(() => useGetUserProfileQuery({ userProfileService: undefined }), { wrapper, }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - + expect(useQuerySpy).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }) + ); expect(result.current.data).toBeUndefined(); }); }); From 6346bd156db2c7dcda11b734e68672d6b4a7d9d9 Mon Sep 17 00:00:00 2001 From: Janki Salvi Date: Tue, 24 Jun 2025 11:42:07 +0100 Subject: [PATCH 10/13] Fix licence message and translations conflicts --- .../public/management/components/license_prompt.tsx | 2 +- .../plugins/private/translations/translations/fr-FR.json | 8 ++++---- .../plugins/private/translations/translations/ja-JP.json | 6 +++--- .../plugins/private/translations/translations/zh-CN.json | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx index a44bbc2c8b936..156c9be373951 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx @@ -20,7 +20,7 @@ import { useKibana } from '@kbn/reporting-public'; const title = (

{i18n.translate('xpack.reporting.schedules.licenseCheck.title', { - defaultMessage: `Upgrade your license to use Machine Learning`, + defaultMessage: `Upgrade your license to use Scheduled Reporting`, })}

); diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index ec899348392f6..8691e73f3087c 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -17486,9 +17486,6 @@ "xpack.enterpriseSearch.nav.analyticsCollections.integrationTitle": "Intégration", "xpack.enterpriseSearch.nav.analyticsCollections.overviewTitle": "Aperçu", "xpack.enterpriseSearch.nav.applications.searchApplications.connectTitle": "Connecter", - "xpack.searchNavigation.classicNav.applicationsTitle": "Développer", - "xpack.searchNavigation.classicNav.homeTitle": "Accueil", - "xpack.searchNavigation.classicNav.relevanceTitle": "Pertinence", "xpack.enterpriseSearch.nav.searchApplication.contentTitle": "Contenu", "xpack.enterpriseSearch.nav.searchApplication.docsExplorerTitle": "Explorateur de documents", "xpack.enterpriseSearch.navigation.applicationsSearchApplicationsLinkLabel": "Applications de recherche", @@ -33917,7 +33914,10 @@ "xpack.searchInferenceEndpoints.taskType": "Type", "xpack.searchInferenceEndpoints.viewYourModels": "Modèles ML entraînés", "xpack.searchNavigation.breadcrumbs.home.title": "Elasticsearch", + "xpack.searchNavigation.classicNav.applicationsTitle": "Développer", + "xpack.searchNavigation.classicNav.homeTitle": "Accueil", "xpack.searchNavigation.classicNav.name": "Elasticsearch", + "xpack.searchNavigation.classicNav.relevanceTitle": "Pertinence", "xpack.searchNotebooks.introductionNotebook.description": "Apprenez tout ce qu’il faut savoir sur les carnets Jupyter, la façon de les prévisualiser dans l'interface utilisateur et de les exécuter.", "xpack.searchNotebooks.introductionNotebook.title": "Carnets Jupyter", "xpack.searchNotebooks.notebook.fetchError.body": "Nous ne pouvons pas récupérer le carnet à partir de Kibana en raison de l'erreur suivante :", @@ -48654,4 +48654,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "Ce champ est requis.", "xpack.watcher.watcherDescription": "Détectez les modifications survenant dans vos données en créant, gérant et monitorant des alertes." } -} \ No newline at end of file +} diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 662000e700ef5..21919393134de 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -17464,8 +17464,6 @@ "xpack.enterpriseSearch.nav.analyticsCollections.integrationTitle": "統合", "xpack.enterpriseSearch.nav.analyticsCollections.overviewTitle": "概要", "xpack.enterpriseSearch.nav.applications.searchApplications.connectTitle": "接続", - "xpack.searchNavigation.classicNav.applicationsTitle": "ビルド", - "xpack.searchNavigation.classicNav.homeTitle": "ホーム", "xpack.enterpriseSearch.nav.searchApplication.contentTitle": "コンテンツ", "xpack.enterpriseSearch.nav.searchApplication.docsExplorerTitle": "ドキュメントエクスプローラー", "xpack.enterpriseSearch.navigation.applicationsSearchApplicationsLinkLabel": "検索アプリケーション", @@ -33893,6 +33891,8 @@ "xpack.searchInferenceEndpoints.taskType": "型", "xpack.searchInferenceEndpoints.viewYourModels": "ML学習済みモデル", "xpack.searchNavigation.breadcrumbs.home.title": "Elasticsearch", + "xpack.searchNavigation.classicNav.applicationsTitle": "ビルド", + "xpack.searchNavigation.classicNav.homeTitle": "ホーム", "xpack.searchNavigation.classicNav.name": "Elasticsearch", "xpack.searchNotebooks.introductionNotebook.description": "Jupyter Notebook、UIでプレビューする方法、実行方法の詳細をご覧ください。", "xpack.searchNotebooks.introductionNotebook.title": "Jupyter Notebook", @@ -48607,4 +48607,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "フィールドを選択してください。", "xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。" } -} \ No newline at end of file +} diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index a581fee755830..de05ef8c4468a 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -17502,9 +17502,6 @@ "xpack.enterpriseSearch.nav.analyticsCollections.integrationTitle": "集成", "xpack.enterpriseSearch.nav.analyticsCollections.overviewTitle": "概览", "xpack.enterpriseSearch.nav.applications.searchApplications.connectTitle": "连接", - "xpack.searchNavigation.classicNav.applicationsTitle": "构建", - "xpack.searchNavigation.classicNav.homeTitle": "主页", - "xpack.searchNavigation.classicNav.relevanceTitle": "相关性", "xpack.enterpriseSearch.nav.searchApplication.contentTitle": "内容", "xpack.enterpriseSearch.nav.searchApplication.docsExplorerTitle": "文档浏览器", "xpack.enterpriseSearch.navigation.applicationsSearchApplicationsLinkLabel": "搜索应用程序", @@ -33951,7 +33948,10 @@ "xpack.searchInferenceEndpoints.taskType": "类型", "xpack.searchInferenceEndpoints.viewYourModels": "ML 已训练模型", "xpack.searchNavigation.breadcrumbs.home.title": "Elasticsearch", + "xpack.searchNavigation.classicNav.applicationsTitle": "构建", + "xpack.searchNavigation.classicNav.homeTitle": "主页", "xpack.searchNavigation.classicNav.name": "Elasticsearch", + "xpack.searchNavigation.classicNav.relevanceTitle": "相关性", "xpack.searchNotebooks.introductionNotebook.description": "了解有关 Jupyter Notebook 的所有信息,如何在 UI 中预览它们,以及如何运行它们。", "xpack.searchNotebooks.introductionNotebook.title": "Jupyter Notebook", "xpack.searchNotebooks.notebook.fetchError.body": "无法从 Kibana 中提取笔记本,因为出现以下错误:", @@ -48697,4 +48697,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "此字段必填。", "xpack.watcher.watcherDescription": "通过创建、管理和监测警报来检测数据中的更改。" } -} \ No newline at end of file +} From 8e80d587609042a2f4d465b8d2fd8265378332c7 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Tue, 24 Jun 2025 12:52:14 +0200 Subject: [PATCH 11/13] Remove texts and fix field not readOnly in read mode flyout --- .../scheduled_report_flyout_content.tsx | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 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 fa1747276cb58..4e6607719be5c 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 @@ -192,10 +192,10 @@ export const ScheduledReportFlyoutContent = ({ }); useEffect(() => { - if (!hasManageReportingPrivilege && userProfile?.user.email) { + if (!readOnly && !hasManageReportingPrivilege && userProfile?.user.email) { form.setFieldValue('emailRecipients', [userProfile.user.email]); } - }, [form, hasManageReportingPrivilege, userProfile?.user.email]); + }, [form, hasManageReportingPrivilege, readOnly, userProfile?.user.email]); const isRecurring = recurring || false; const isEmailActive = sendByEmail || false; @@ -222,7 +222,7 @@ export const ScheduledReportFlyoutContent = ({ - {isReportingHealthLoading || isUserProfileLoading ? ( + {!readOnly && (isReportingHealthLoading || isUserProfileLoading) ? ( ) : isReportingHealthError ? ( {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE}} description={ -

- {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION} {reportingPageLink}. -

+ !readOnly && ( +

+ {i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION} {reportingPageLink}. +

+ ) } > - -

{i18n.SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_MESSAGE}

-
+ {!readOnly && ( + +

{i18n.SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_MESSAGE}

+
+ )} ) From ea44ff44ab06707bb146afad35ea0fb4b888385c Mon Sep 17 00:00:00 2001 From: Janki Salvi Date: Tue, 24 Jun 2025 13:03:29 +0100 Subject: [PATCH 12/13] update text --- .../reporting/public/management/components/license_prompt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx index 156c9be373951..40b011d078ac9 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/license_prompt.tsx @@ -20,7 +20,7 @@ import { useKibana } from '@kbn/reporting-public'; const title = (

{i18n.translate('xpack.reporting.schedules.licenseCheck.title', { - defaultMessage: `Upgrade your license to use Scheduled Reporting`, + defaultMessage: `Upgrade your license to use Scheduled Exports`, })}

); From 7d281d5ef53672ccee99917a9a974a3d87a2c6d4 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Tue, 24 Jun 2025 15:14:45 +0200 Subject: [PATCH 13/13] Add beta badges --- .../management/components/reporting_tabs.tsx | 27 ++++++++++++++++--- .../scheduled_report_flyout_content.tsx | 11 +++++++- .../public/management/translations.ts | 12 +++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx index 1e340accc2333..67a26e178dc38 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx @@ -6,7 +6,13 @@ */ import React, { useCallback } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPageTemplate } from '@elastic/eui'; +import { + EuiBetaBadge, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPageTemplate, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Route, Routes } from '@kbn/shared-ux-router'; import { RouteComponentProps } from 'react-router-dom'; @@ -33,6 +39,7 @@ import { useIlmPolicyStatus } from '../../lib/ilm_policy_status_context'; import { MigrateIlmPolicyCallOut } from './migrate_ilm_policy_callout'; import ReportSchedulesTable from './report_schedules_table'; import { LicensePrompt } from './license_prompt'; +import { TECH_PREVIEW_DESCRIPTION, TECH_PREVIEW_LABEL } from '../translations'; export interface MatchParams { section: Section; @@ -116,6 +123,7 @@ export const ReportingTabs: React.FunctionComponent< name: i18n.translate('xpack.reporting.tabs.schedules', { defaultMessage: 'Schedules', }), + isBeta: true, }, ]; @@ -227,8 +235,21 @@ export const ReportingTabs: React.FunctionComponent< defaultMessage="Get reports generated in Kibana applications." /> } - tabs={tabs.map(({ id, name }) => ({ - label: name, + tabs={tabs.map(({ id, name, isBeta = false }) => ({ + label: !isBeta ? ( + name + ) : ( + <> + {name}{' '} + + + ), onClick: () => onSectionChange(id as Section), isSelected: id === section, key: id, 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 4e6607719be5c..c6ff0860c9b61 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 @@ -8,6 +8,7 @@ import React, { useEffect, useMemo } from 'react'; import moment from 'moment'; import { + EuiBetaBadge, EuiButton, EuiButtonEmpty, EuiCallOut, @@ -218,7 +219,15 @@ export const ScheduledReportFlyoutContent = ({ <> -

{i18n.SCHEDULED_REPORT_FLYOUT_TITLE}

+

+ {i18n.SCHEDULED_REPORT_FLYOUT_TITLE}{' '} + +

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 6f937914fa6b7..02791201e464e 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/translations.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/translations.ts @@ -297,6 +297,18 @@ export const CANNOT_LOAD_REPORTING_HEALTH_MESSAGE = i18n.translate( } ); +export const TECH_PREVIEW_LABEL = i18n.translate('xpack.reporting.technicalPreviewBadgeLabel', { + defaultMessage: 'Technical preview', +}); + +export const TECH_PREVIEW_DESCRIPTION = i18n.translate( + 'xpack.reporting.technicalPreviewBadgeDescription', + { + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', + } +); + export function getInvalidEmailAddress(email: string) { return i18n.translate('xpack.reporting.components.email.error.invalidEmail', { defaultMessage: 'Email address {email} is not valid',