[ReponseOps][Reporting] Allow users to schedule reports and view schedules list (#224849)

## Summary

- Implements the flyout to schedule reports
- Adds a Schedules table to the Stack Management > Reporting page to
view schedules
- Updates the Reports table to show information about scheduled reports

<details>
<summary>

## Verification steps

</summary>

### 🐞 Happy Path
- Add the following configuration to your Kibana config file
    ```
    notifications.connectors.default.email: gmail
    xpack.actions.preconfigured:
      gmail:
        name: 'email: my gmail'
        actionTypeId: '.email'
    ````
- Log in as an admin user or user with Reporting privileges and a
license != `basic`
- If you don't have data in Kibana, navigate to Home > Try sample data
and activate a sample data set
- Create a Dashboard or Discover session
- Open the ⬇️ (Export) menu in the toolbar
- Click `Schedule export`
- Schedule reports with different combinations of file name, export
type, recurrence schedule and email notification settings
- Navigate to Stack Management > Reporting
- Check that the scheduled reports match the displayed items in the
Reports and Schedules tabs (⚠️ some jobs might not have started because
of the recurrence rule so you might not find the reports immediately)

### ️ Edge Cases
Missing default notifications email connector
- Start Kibana without the default email connector from point n.1 of the
happy path
- When trying to schedule a report, the flyout should show a callout
informing the user about the missing email connector

Unmet prerequisites
- Start ES with any of the following flags: `-E
xpack.security.enabled=false` or `-E
xpack.security.authc.api_key.enabled=false`
- The `Schedule export` button should not appear in the Export menu

Unsupported license
- Log in as a user with a basic license or without capabilities to
generate reports
- The `Schedule export` button should not appear in the Export menu

Users without `Manage Scheduled Reports` privilege
- Create a role with sufficient privileges to access and export any
object type (Dashboards, Discover, ...), do not grant the `Manage
Scheduled Reports` privilege (under `Stack Management`)
- Create a user with this role, _without an email address_
- Open the Schedule export flyout
- Check that the `Send by email` field is disabled, showing a hint about
the user profile missing an email address
- Add an email address to the user (for the changes to take effect you
might have to renew the session, logging back in)
- Check that the `Send by email` toggle is now enabled
- Check that when toggling email notifications on, the `To` field is
disabled and precompiled with the user's email address

Flyout form validation
- `File name` should be required
- `To` should not allow to insert invalid email addresses
- `To` should not allow to insert unallowed email addresses (not in
allowlist)
- Recurrence subform should show presets based on the current datetime

###  Failure Cases


</details>

<details>

<summary>

## Known issues

</summary>

- PDF print option is not displayed in readOnly mode
- Console error due to `compressed` attribute wrongly forwarded by
form-hook-lib to DOM element (this is likely a form lib issue):
<img width="916" alt="image"
src="https://github.com/user-attachments/assets/09d20ba9-8781-46d6-bcfa-862d8a4cbf90"
/>
- 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

</details>



<details>

<summary>

## Screenshots

</summary>

Health API error:
<img height="500" alt="Screenshot 2025-05-31 at 10 48 40"
src="https://github.com/user-attachments/assets/dd069597-971c-489f-9c07-eb5edfd7bede"
/>

Health API loading state:
<img height="500" alt="Screenshot 2025-05-31 at 10 49 04"
src="https://github.com/user-attachments/assets/27d95bf3-bf7d-42c7-9a40-2826f38aa837"
/>

Health API success with some missing prerequisites:
<img width="449" alt="Screenshot 2025-06-17 at 16 59 57"
src="https://github.com/user-attachments/assets/c44afa97-70ff-4618-8b73-41b816514459"
/>

Form validation:
<img height="500" alt="image"
src="https://github.com/user-attachments/assets/a8d4cae1-2819-4f71-a911-9300a6cf81f8"
/>

Success toast:
<img width="480" alt="image"
src="https://github.com/user-attachments/assets/a87c3af5-dbb0-40e8-915a-fc9d7e1d97f2"
/>

Failure toast:
<img width="518" alt="image"
src="https://github.com/user-attachments/assets/908f9dea-b5cb-4da9-b4a5-76e313837f18"
/>

Print format toggle:
<img width="502" alt="image"
src="https://github.com/user-attachments/assets/602f3ab9-07ef-4689-a305-dc1b2b5495cd"
/>

Missing notifications email connector callout:
<img width="499" alt="image"
src="https://github.com/user-attachments/assets/fe4997a5-75e6-4450-85e5-7d853049e085"
/>

User without `Manage Scheduled Reports` privilege and without email
address in profile
<img width="492" alt="Screenshot 2025-06-23 at 14 51 07"
src="https://github.com/user-attachments/assets/e0867b7b-3358-4cf0-8adf-c141a1ded76f"
/>

User without `Manage Scheduled Reports` privilege with email address in
profile
<img width="498" alt="image"
src="https://github.com/user-attachments/assets/c45a0c31-cac7-4acb-b068-b3cfc02aac68"
/>


</details>

## Release Notes

Added the ability to schedule reports with a recurring schedule and view
previously scheduled reports

## References

Closes #216321
Closes #216322

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Eyo O. Eyo <7893459+eokoneyo@users.noreply.github.com>
Co-authored-by: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com>
Co-authored-by: Janki Salvi <jankigaurav.salvi@elastic.co>
This commit is contained in:
Umberto Pepato 2025-06-24 17:32:14 +02:00 committed by GitHub
parent 0a07e18442
commit 0c377fafa8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 5316 additions and 1062 deletions

View file

@ -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
@ -66,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

View file

@ -21,5 +21,6 @@
"@kbn/i18n",
"@kbn/task-manager-plugin",
"@kbn/deeplinks-analytics",
"@kbn/licensing-plugin",
]
}

View file

@ -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;
}

View file

@ -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<KibanaContext>();

View file

@ -79,6 +79,7 @@ export class Job {
public readonly queue_time_ms?: Required<ReportFields>['queue_time_ms'][number];
public readonly execution_time_ms?: Required<ReportFields>['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() {

View file

@ -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({

View file

@ -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(',') },

View file

@ -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<ExportShare['config']> => {
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({

View file

@ -14,29 +14,69 @@ import { ShareContext } from '@kbn/share-plugin/public';
import React from 'react';
import { firstValueFrom } from 'rxjs';
import { ExportGenerationOpts, ExportShare } from '@kbn/share-plugin/public/types';
import { ReportParamsGetter, ReportParamsGetterOptions } from '../../types';
import { ExportModalShareOpts, JobParamsProviderOptions, ReportingSharingData } from '.';
import { checkLicense } from '../../license_check';
const getJobParams = (opts: JobParamsProviderOptions, type: 'pngV2' | 'printablePdfV2') => () => {
const {
objectType,
sharingData: { title, locatorParams },
optimizedForPrinting,
} = opts;
const getBaseParams = (objectType: string) => {
const el = document.querySelector('[data-shared-items-container]');
const { height, width } = el ? el.getBoundingClientRect() : { height: 768, width: 1024 };
const dimensions = { height, width };
const layoutId = optimizedForPrinting ? ('print' as const) : ('preserve_layout' as const);
const layout = { id: layoutId, dimensions };
const baseParams = { objectType, layout, title };
return {
objectType,
layout: {
id: 'preserve_layout' as 'preserve_layout' | 'print',
dimensions,
},
};
};
if (type === 'printablePdfV2') {
// multi locator for PDF V2
return { ...baseParams, locatorParams: [locatorParams] };
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,
};
};
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 = ({

View file

@ -27,5 +27,6 @@
"@kbn/home-plugin",
"@kbn/management-plugin",
"@kbn/ui-actions-plugin",
"@kbn/actions-plugin",
]
}

View file

@ -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;

View file

@ -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,

View file

@ -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 ? (
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s" alignItems="flexStart">
<EuiFlexItem>
<UseField
path="recurringSchedule.interval"
css={styles.flexField}
componentProps={{
'data-test-subj': 'interval-field',
id: 'interval',
euiFieldProps: {
'data-test-subj': 'customRecurringScheduleIntervalInput',
min: 1,
prepend: (
<EuiFormLabel htmlFor={'interval'}>
{RECURRING_SCHEDULE_FORM_INTERVAL_EVERY}
</EuiFormLabel>
),
return (
<>
{parsedSchedule?.frequency !== Frequency.DAILY ? (
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s" alignItems="flexStart">
<EuiFlexItem>
<UseField
path="recurringSchedule.interval"
css={styles.flexField}
componentProps={{
'data-test-subj': 'interval-field',
id: 'interval',
euiFieldProps: {
compressed,
'data-test-subj': 'customRecurringScheduleIntervalInput',
min: 1,
prepend: (
<EuiFormLabel htmlFor={'interval'}>
{RECURRING_SCHEDULE_FORM_INTERVAL_EVERY}
</EuiFormLabel>
),
readOnly,
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="recurringSchedule.customFrequency"
componentProps={{
'data-test-subj': 'custom-frequency-field',
euiFieldProps: {
compressed,
'data-test-subj': 'customRecurringScheduleFrequencySelect',
options: frequencyOptions,
disabled: readOnly,
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
) : null}
{Number(parsedSchedule?.customFrequency) === Frequency.WEEKLY ||
parsedSchedule?.frequency === Frequency.DAILY ? (
<UseField
path="recurringSchedule.byweekday"
config={{
type: FIELD_TYPES.MULTI_BUTTON_GROUP,
label: '',
validations: [
{
validator: ({ value }) => {
if (
Object.values(value as MultiButtonGroupFieldValue).every((v) => v === false)
) {
return {
message: RECURRING_SCHEDULE_FORM_BYWEEKDAY_REQUIRED,
};
}
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="recurringSchedule.customFrequency"
componentProps={{
'data-test-subj': 'custom-frequency-field',
euiFieldProps: {
'data-test-subj': 'customRecurringScheduleFrequencySelect',
options: frequencyOptions,
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
) : null}
{Number(parsedSchedule?.customFrequency) === Frequency.WEEKLY ||
parsedSchedule?.frequency === Frequency.DAILY ? (
<UseField
path="recurringSchedule.byweekday"
config={{
type: FIELD_TYPES.MULTI_BUTTON_GROUP,
label: '',
validations: [
{
validator: ({ value }) => {
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 ? (
<UseField
path="recurringSchedule.bymonth"
componentProps={{
'data-test-subj': 'bymonth-field',
euiFieldProps: {
legend: 'Repeat on weekday or month day',
options: bymonthOptions,
},
}}
/>
) : null}
</>
);
});
{Number(parsedSchedule?.customFrequency) === Frequency.MONTHLY ? (
<UseField
path="recurringSchedule.bymonth"
componentProps={{
'data-test-subj': 'bymonth-field',
compressed,
euiFieldProps: {
legend: 'Repeat on weekday or month day',
options: bymonthOptions,
readOnly,
},
}}
/>
) : null}
</>
);
}
);
CustomRecurringSchedule.displayName = 'CustomRecurringSchedule';

View file

@ -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>(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<EuiSelectOption & { 'data-test-subj'?: string }> =
DEFAULT_FREQUENCY_OPTIONS;
let _presets: Record<number, Partial<RecurringSchedule>> = 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') && (
<CustomRecurringSchedule startDate={startDate} />
)}
<UseField
path="recurringSchedule.ends"
componentProps={{
'data-test-subj': 'ends-field',
euiFieldProps: {
legend: 'Recurrence ends',
options: allowInfiniteRecurrence
? [RECURRENCE_END_NEVER, ...RECURRENCE_END_OPTIONS]
: RECURRENCE_END_OPTIONS,
},
}}
/>
{parsedSchedule?.ends === RecurrenceEnd.ON_DATE ? (
<>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="flexEnd">
<EuiFlexItem grow={3}>
<UseField
path="recurringSchedule.until"
config={{
type: FIELD_TYPES.DATE_PICKER,
label: '',
defaultValue: Boolean(endDate)
? moment(endDate).endOf('day').toISOString()
: '',
validations: [],
serializer: toString,
deserializer: toMoment,
}}
componentProps={{
'data-test-subj': 'until-field',
euiFieldProps: {
showTimeSelect: false,
minDate: today,
},
}}
/>
</EuiFlexItem>
{timezone ? (
<EuiFlexItem grow={1}>
<EuiComboBox
data-test-subj="disabled-timezone-field"
id="disabled-timezone"
isDisabled
singleSelection={{ asPlainText: true }}
selectedOptions={[{ label: timezone[0] }]}
isClearable={false}
prepend={
<EuiFormLabel htmlFor={'disabled-timezone'}>
{RECURRING_SCHEDULE_FORM_TIMEZONE}
</EuiFormLabel>
}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</>
) : null}
{parsedSchedule?.ends === RecurrenceEnd.AFTER_X ? (
<UseField
path="recurringSchedule.count"
componentProps={{
'data-test-subj': 'count-field',
id: 'count',
euiFieldProps: {
'data-test-subj': 'recurringScheduleAfterXOccurenceInput',
type: 'number',
min: 1,
prepend: (
<EuiFormLabel htmlFor={'count'}>
{RECURRING_SCHEDULE_FORM_COUNT_AFTER}
</EuiFormLabel>
),
append: (
<EuiFormLabel htmlFor={'count'}>
{RECURRING_SCHEDULE_FORM_COUNT_OCCURRENCE}
</EuiFormLabel>
),
},
}}
<CustomRecurringSchedule
startDate={startDate}
compressed={compressed}
minFrequency={minFrequency}
readOnly={readOnly}
/>
) : null}
)}
{supportsEndOptions && (
<>
<UseField
path="recurringSchedule.ends"
componentProps={{
'data-test-subj': 'ends-field',
euiFieldProps: {
compressed,
legend: 'Recurrence ends',
options: allowInfiniteRecurrence
? [RECURRENCE_END_NEVER, ...RECURRENCE_END_OPTIONS]
: RECURRENCE_END_OPTIONS,
isDisabled: readOnly,
},
}}
/>
{parsedSchedule?.ends === RecurrenceEnd.ON_DATE ? (
<>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="flexEnd">
<EuiFlexItem grow={3}>
<UseField
path="recurringSchedule.until"
config={{
type: FIELD_TYPES.DATE_PICKER,
label: '',
defaultValue: Boolean(endDate)
? moment(endDate).endOf('day').toISOString()
: '',
validations: [
{
// Using a custom validator since `emptyField()` won't error for undefined
// values
validator: ({ value }) => {
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,
},
}}
/>
</EuiFlexItem>
{timezone && !hideTimezone ? (
<EuiFlexItem grow={1}>
<EuiComboBox
data-test-subj="disabled-timezone-field"
id="disabled-timezone"
isDisabled
singleSelection={{ asPlainText: true }}
selectedOptions={[{ label: timezone[0] }]}
isClearable={false}
prepend={
<EuiFormLabel htmlFor={'disabled-timezone'}>
{i18n.RECURRING_SCHEDULE_FORM_TIMEZONE}
</EuiFormLabel>
}
compressed={compressed}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</>
) : null}
{parsedSchedule?.ends === RecurrenceEnd.AFTER_X ? (
<UseField
path="recurringSchedule.count"
componentProps={{
'data-test-subj': 'count-field',
id: 'count',
euiFieldProps: {
compressed,
'data-test-subj': 'recurringScheduleAfterXOccurenceInput',
type: 'number',
min: 1,
prepend: (
<EuiFormLabel htmlFor={'count'}>
{i18n.RECURRING_SCHEDULE_FORM_COUNT_AFTER}
</EuiFormLabel>
),
append: (
<EuiFormLabel htmlFor={'count'}>
{i18n.RECURRING_SCHEDULE_FORM_COUNT_OCCURRENCE}
</EuiFormLabel>
),
readOnly,
},
}}
/>
) : null}
</>
)}
</EuiSplitPanel.Inner>
<EuiHorizontalRule margin="none" />
<EuiSplitPanel.Inner>
{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,
})
)}
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>

View file

@ -100,6 +100,16 @@ export const ISO_WEEKDAYS_TO_RRULE: Record<number, string> = {
7: 'SU',
};
export const RRULE_TO_ISO_WEEKDAYS: Record<string, number> = {
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'),

View file

@ -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 },
});

View file

@ -23,6 +23,10 @@ export interface RecurringSchedule {
customFrequency?: RecurrenceFrequency;
byweekday?: Record<string, boolean>;
bymonth?: string;
bymonthweekday?: string;
bymonthday?: number;
byhour?: number;
byminute?: number;
}
export type RRuleParams = Partial<RRuleRecord> & Pick<RRuleRecord, 'dtstart' | 'tzid'>;

View file

@ -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({

View file

@ -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)

View file

@ -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');
});

View file

@ -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<RecurrenceFrequency, Partial<RecurringSchedule>>
) => {
export const recurringSummary = ({
startDate,
recurringSchedule,
presets,
showTime = false,
}: {
startDate?: Moment;
recurringSchedule?: RecurringSchedule;
presets: Record<RecurrenceFrequency, Partial<RecurringSchedule>>;
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;

View file

@ -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';

View file

@ -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',
},
];

View file

@ -29,7 +29,8 @@
"taskManager",
"screenshotMode",
"share",
"features"
"features",
"actions"
],
"optionalPlugins": [
"security",

View file

@ -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';

View file

@ -32,9 +32,17 @@ export const IlmPolicyStatusContextProvider: FC<PropsWithChildren<unknown>> = ({
export type UseIlmPolicyStatusReturn = ReturnType<typeof useIlmPolicyStatus>;
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;

View file

@ -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<typeof httpServiceMock.createSetupContract>;
@ -90,6 +93,21 @@ const license$ = {
},
} as Observable<ILicense>;
const routeProps: RouteComponentProps<MatchParams> = {
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(
<KibanaContextProvider services={{ http, application, uiSettings, data, share }}>
<InternalApiClientProvider apiClient={reportingAPIClient} http={http}>
<IlmPolicyStatusContextProvider>
<ReportListing
<ReportingTabs
coreStart={coreMock.createStart()}
dataService={data}
shareService={share}
license$={l$}
config={mockConfig}
redirect={jest.fn()}
navigateToUrl={jest.fn()}
urlService={urlService}
toasts={toasts}
apiClient={reportingAPIClient}
{...routeProps}
{...rest}
/>
</IlmPolicyStatusContextProvider>

View file

@ -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 }),
});
};

View file

@ -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<ReportingHealthInfo> => {
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,
};
};

View file

@ -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,
};
};

View file

@ -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<ScheduledReportingJobResponse>(
`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/${reportTypeId}`,
{
body: JSON.stringify(params),
}
);
};

View file

@ -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<ConfirmDisableReportModalProps> = ({
title,
message,
onCancel,
onConfirm,
}) => {
const titleId = useGeneratedHtmlId();
return (
<EuiConfirmModal
buttonColor="danger"
cancelButtonText={i18n.translate('xpack.reporting.schedules.disable.cancel', {
defaultMessage: 'Cancel',
})}
data-test-subj="confirm-disable-modal"
defaultFocusedButton="confirm"
onCancel={onCancel}
onConfirm={onConfirm}
title={title}
titleProps={{
id: titleId,
}}
confirmButtonText={i18n.translate('xpack.reporting.schedules.disable.confirm', {
defaultMessage: 'Disable',
})}
aria-labelledby={titleId}
>
{message}
</EuiConfirmModal>
);
};
DisableReportConfirmationModalComponent.displayName = 'DisableReportConfirmationModal';
export const DisableReportConfirmationModal = React.memo(DisableReportConfirmationModalComponent);

View file

@ -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<SerializableRecord>;
}
const i18nTexts = {
buttonLabel: i18n.translate('xpack.reporting.listing.reports.ilmPolicyLinkText', {
defaultMessage: 'Edit reporting ILM policy',
defaultMessage: 'Edit ILM policy',
}),
};
export const IlmPolicyLink: FunctionComponent<Props> = ({ locator, navigateToUrl }) => {
export const IlmPolicyLink: FunctionComponent<Props> = ({ locator }) => {
return (
<EuiButtonEmpty
data-test-subj="ilmPolicyLink"
size="xs"
size="s"
iconType="popout"
onClick={() => {
const url = locator.getRedirectUrl({
page: 'policy_edit',
policyName: ILM_POLICY_NAME,
});
navigateToUrl(url);
window.open(url, '_blank');
window.focus();
}}
>
{i18nTexts.buttonLabel}

View file

@ -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 = (
<h2 data-test-subj="license-prompt-title">
{i18n.translate('xpack.reporting.schedules.licenseCheck.title', {
defaultMessage: `Upgrade your license to use Scheduled Exports`,
})}
</h2>
);
export const LicensePrompt = React.memo(() => {
const { application } = useKibana().services;
return (
<EuiPageTemplate.EmptyPrompt
data-test-subj="schedules-license-prompt"
title={title}
body={
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButton
fill
data-test-subj="license-prompt-upgrade"
key="upgrade-subscription-button"
target="_blank"
href="https://www.elastic.co/subscriptions"
>
{i18n.translate('xpack.reporting.schedules.licenseCheck.upgrade', {
defaultMessage: `Upgrade`,
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="license-prompt-trial"
key="start-trial-button"
target="_blank"
href={application.getUrlForApp('management', {
path: 'stack/license_management/home',
})}
>
{i18n.translate('xpack.reporting.schedules.licenseCheck.startTrial', {
defaultMessage: `Start a trial`,
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
}
/>
);
});
LicensePrompt.displayName = 'LicensePrompt';

View file

@ -20,7 +20,7 @@ interface Props {
}
export const MigrateIlmPolicyCallOut: FunctionComponent<Props> = ({ toasts }) => {
const { isLoading, recheckStatus, status } = useIlmPolicyStatus();
const { isLoading, recheckStatus, status } = useIlmPolicyStatus(true);
if (isLoading || !status || status === 'ok') {
return null;

View file

@ -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 && (
<div>
{flyout}
<EuiButtonEmpty
data-test-subj="screenshotDiagnosticLink"
size="xs"
flush="left"
onClick={showFlyout}
>
<EuiButton data-test-subj="screenshotDiagnosticLink" size="s" onClick={showFlyout}>
<FormattedMessage
id="xpack.reporting.listing.diagnosticButton"
defaultMessage="Run screenshot diagnostics"
defaultMessage="Run diagnosis"
/>
</EuiButtonEmpty>
</EuiButton>
</div>
)}
</div>

View file

@ -0,0 +1,170 @@
/*
* 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, 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';
import { mockJobs } from '../../../common/test';
import { RecursivePartial, UseEuiTheme } from '@elastic/eui';
import { ThemeProvider } from '@emotion/react';
jest.mock('./report_info_flyout', () => ({
ReportInfoFlyout: () => <div data-test-subj="reportInfoFlyout" />,
}));
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<ILicense>;
export const getMockTheme = (partialTheme: RecursivePartial<UseEuiTheme>): 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));
window.open = jest.fn();
window.focus = jest.fn();
});
it('renders table correctly', async () => {
render(
<ThemeProvider theme={mockTheme}>
<ReportExportsTable {...defaultProps} />
</ThemeProvider>
);
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(
<ThemeProvider theme={mockTheme}>
<ReportExportsTable {...defaultProps} />
</ThemeProvider>
);
expect(await screen.findByText('No reports have been created')).toBeInTheDocument();
});
it('renders data correctly', async () => {
render(
<ThemeProvider theme={mockTheme}>
<ReportExportsTable {...defaultProps} />
</ThemeProvider>
);
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();
});
it('should show config flyout from table action', async () => {
render(
<ThemeProvider theme={mockTheme}>
<ReportExportsTable {...defaultProps} />
</ThemeProvider>
);
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(
<ThemeProvider theme={mockTheme}>
<ReportExportsTable {...defaultProps} />
</ThemeProvider>
);
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(
<ThemeProvider theme={mockTheme}>
<ReportExportsTable {...defaultProps} />
</ThemeProvider>
);
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'
);
});
});
});

View file

@ -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<Job>;
@ -44,7 +47,7 @@ interface State {
selectedJob: undefined | Job;
}
export class ReportListingTable extends Component<ListingPropsInternal, State> {
export class ReportExportsTable extends Component<ListingPropsInternal, State> {
private isInitialJobsFetch: boolean;
private licenseSubscription?: Subscription;
private mounted?: boolean;
@ -128,7 +131,7 @@ export class ReportListingTable extends Component<ListingPropsInternal, State> {
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<ListingPropsInternal, State> {
);
} 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<ListingPropsInternal, State> {
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<ListingPropsInternal, State> {
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',
})
);
@ -214,10 +218,11 @@ export class ReportListingTable extends Component<ListingPropsInternal, State> {
*/
private readonly tableColumnWidths = {
type: '5%',
title: '30%',
title: '25%',
status: '20%',
createdAt: '25%',
content: '10%',
createdAt: '21%',
content: '7%',
exportType: '12%',
actions: '10%',
};
@ -227,7 +232,7 @@ export class ReportListingTable extends Component<ListingPropsInternal, State> {
{
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<ListingPropsInternal, State> {
},
{
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<ListingPropsInternal, State> {
data-test-subj={`viewReportingLink-${job.id}`}
onClick={() => this.setState({ selectedJob: job })}
>
{objectTitle ||
i18n.translate('xpack.reporting.listing.table.noTitleLabel', {
defaultMessage: 'Untitled',
})}
<TruncatedTitle
text={
objectTitle ||
i18n.translate('xpack.reporting.exports.table.noTitleLabel', {
defaultMessage: 'Untitled',
})
}
/>
</EuiLink>
</div>
);
@ -278,7 +287,7 @@ export class ReportListingTable extends Component<ListingPropsInternal, State> {
{
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<ListingPropsInternal, State> {
{
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<ListingPropsInternal, State> {
{
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) => (
<div data-test-subj="reportJobContent">{prettyPrintJobType(job.jobtype)}</div>
),
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 (
<EuiBadge data-test-subj={`reportExportType-${exportType}`} color="hollow">
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIconTip type={_scheduledReportId ? 'calendar' : 'download'} size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{exportType}</EuiFlexItem>
</EuiFlexGroup>
</EuiBadge>
);
},
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<ListingPropsInternal, State> {
'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<ListingPropsInternal, State> {
},
{
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', {
defaultMessage: 'Open in Kibana',
name: i18n.translate('xpack.reporting.exports.table.openInKibanaAppLabel', {
defaultMessage: 'Open Dashboard',
}),
'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<ListingPropsInternal, State> {
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<ListingPropsInternal, State> {
return (
<Fragment>
<EuiSpacer size={'l'} />
{this.state.selectedJobs.length > 0 && (
<div>
<EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="m">
@ -405,22 +448,14 @@ export class ReportListingTable extends Component<ListingPropsInternal, State> {
</div>
)}
<EuiBasicTable
tableCaption={i18n.translate('xpack.reporting.listing.table.captionDescription', {
tableCaption={i18n.translate('xpack.reporting.exports.table.captionDescription', {
defaultMessage: 'Reports generated in Kibana applications',
})}
itemId="id"
items={this.state.jobs}
loading={this.state.isLoading}
columns={tableColumns}
noItemsMessage={
this.state.isLoading
? i18n.translate('xpack.reporting.listing.table.loadingReportsDescription', {
defaultMessage: 'Loading reports',
})
: i18n.translate('xpack.reporting.listing.table.noCreatedReportsDescription', {
defaultMessage: 'No reports have been created',
})
}
noItemsMessage={NO_CREATED_REPORTS_DESCRIPTION}
pagination={pagination}
selection={selection}
onChange={this.onTableChange}
@ -438,3 +473,6 @@ export class ReportListingTable extends Component<ListingPropsInternal, State> {
);
}
}
// eslint-disable-next-line import/no-default-export
export { ReportExportsTable as default };

View file

@ -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<ReportScheduleIndicatorProps> = ({ schedule }) => {
if (!schedule || !schedule.rrule) {
return null;
}
const statusText = translations[schedule.rrule.freq];
return (
<EuiBadge
data-test-subj={`reportScheduleIndicator-${schedule.rrule.freq}`}
color="default"
aria-label={statusText}
>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIconTip type="calendar" size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{statusText}</EuiFlexItem>
</EuiFlexGroup>
</EuiBadge>
);
};

View file

@ -0,0 +1,366 @@
/*
* 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, 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';
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 { 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: () => <div data-test-subj="scheduledReportFlyout" />,
}));
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<ILicense>;
export const getMockTheme = (partialTheme: RecursivePartial<UseEuiTheme>): 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,
},
},
});
const mockValidateEmailAddresses = jest.fn().mockResolvedValue([]);
describe('ReportSchedulesTable', () => {
const bulkDisableScheduledReportsMock = jest.fn();
useBulkDisableMock.mockReturnValue({
isLoading: false,
mutateAsync: bulkDisableScheduledReportsMock,
});
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
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 () => {
(useGetScheduledList as jest.Mock).mockReturnValueOnce({
data: {
page: 0,
size: 10,
total: 0,
data: [],
},
isLoading: false,
});
render(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<ReportSchedulesTable {...defaultProps} />
</QueryClientProvider>
</IntlProvider>
);
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(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<ReportSchedulesTable {...defaultProps} />
</QueryClientProvider>
</IntlProvider>
);
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(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<ReportSchedulesTable {...defaultProps} />
</QueryClientProvider>
</IntlProvider>
);
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(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<ReportSchedulesTable {...defaultProps} />
</QueryClientProvider>
</IntlProvider>
);
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(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<ReportSchedulesTable {...defaultProps} />
</QueryClientProvider>
</IntlProvider>
);
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],
});
});
});
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(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<ReportSchedulesTable {...defaultProps} />
</QueryClientProvider>
</IntlProvider>
);
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(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<ReportSchedulesTable {...defaultProps} />
</QueryClientProvider>
</IntlProvider>
);
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(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<ReportSchedulesTable {...defaultProps} />
</QueryClientProvider>
</IntlProvider>
);
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'
);
});
});
});

View file

@ -0,0 +1,333 @@
/*
* 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 { 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';
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<ScheduledReportApiJSON | null>(null);
const [configFlyOut, setConfigFlyOut] = useState<boolean>(false);
const [disableFlyOut, setDisableFlyOut] = useState<boolean>(false);
const [queryParams, setQueryParams] = useState<QueryParams>({
index: 1,
size: 10,
});
const { data: scheduledList, isLoading } = useGetScheduledList({
http,
...queryParams,
});
const { mutateAsync: bulkDisableScheduledReports } = useBulkDisable({
http,
toasts,
});
const sortedList = orderBy(scheduledList?.data || [], ['created_at'], ['desc']);
const tableColumns: Array<EuiBasicTableColumn<ScheduledReportApiJSON>> = [
{
field: 'payload.objectType',
name: i18n.translate('xpack.reporting.schedules.tableColumns.typeTitle', {
defaultMessage: 'Type',
}),
width: '5%',
render: (_objectType: string) => (
<EuiIconTip
data-test-subj="reportObjectType"
type={guessAppIconTypeFromObjectType(_objectType)}
size="s"
content={getDisplayNameFromObjectType(_objectType)}
/>
),
},
{
field: 'title',
name: i18n.translate('xpack.reporting.schedules.tableColumns.reportTitle', {
defaultMessage: 'Title',
}),
width: '22%',
render: (_title: string, item: ScheduledReportApiJSON) => (
<EuiLink
data-test-subj={`reportTitle`}
onClick={() => {
setSelectedReport(item);
setConfigFlyOut(true);
}}
>
<TruncatedTitle text={_title} />
</EuiLink>
),
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 (
<EuiHealth
color={item.enabled ? 'primary' : 'subdued'}
data-test-subj={`reprotStatus-${item.enabled ? 'active' : 'disabled'}`}
>
{item.enabled
? i18n.translate('xpack.reporting.schedules.status.active', {
defaultMessage: 'Active',
})
: i18n.translate('xpack.reporting.schedules.status.disabled', {
defaultMessage: 'Disabled',
})}
</EuiHealth>
);
},
},
{
field: 'schedule',
name: i18n.translate('xpack.reporting.schedules.tableColumns.scheduleTitle', {
defaultMessage: 'Schedule',
}),
width: '10%',
render: (_schedule: ScheduledReportApiJSON['schedule']) => (
<ReportScheduleIndicator schedule={_schedule} />
),
},
{
field: 'next_run',
name: i18n.translate('xpack.reporting.schedules.tableColumns.nextScheduleTitle', {
defaultMessage: 'Next schedule',
}),
width: '20%',
render: (_nextRun: string, item) => {
return item.enabled ? 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 (
<EuiFlexGroup
gutterSize="s"
alignItems="baseline"
data-test-subj="reportCreatedBy"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiAvatar name={_createdBy} size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false} className="eui-textTruncate">
<EuiText size="s" className="eui-textTruncate">
{_createdBy}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
},
},
{
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 (
<Fragment>
<EuiSpacer size={'l'} />
<EuiBasicTable
data-test-subj="reportSchedulesTable"
items={sortedList}
columns={tableColumns}
loading={isLoading}
pagination={{
pageIndex: queryParams.index - 1,
pageSize: queryParams.size,
totalItemCount: scheduledList?.total ?? 0,
}}
noItemsMessage={NO_CREATED_REPORTS_DESCRIPTION}
onChange={tableOnChangeCallback}
rowProps={() => ({ 'data-test-subj': 'scheduledReportRow' })}
/>
{selectedReport && configFlyOut && (
<ScheduledReportFlyout
apiClient={props.apiClient}
onClose={() => {
setSelectedReport(null);
setConfigFlyOut(false);
}}
scheduledReport={transformScheduledReport(selectedReport)}
availableReportTypes={[
{
id: selectedReport.jobtype,
label: prettyPrintJobType(selectedReport.jobtype),
},
]}
/>
)}
{selectedReport && disableFlyOut ? (
<DisableReportConfirmationModal
title={i18n.translate('xpack.reporting.schedules.table.disableSchedule.modalTitle', {
defaultMessage: 'Disable schedule',
})}
message={i18n.translate('xpack.reporting.schedules.table.disableSchedule.modalMessage', {
defaultMessage:
'Disabling this schedule will stop the generation of future exports. You will not be able to enable this schedule again.',
})}
onCancel={onCancel}
onConfirm={onConfirm}
/>
) : null}
</Fragment>
);
};
// eslint-disable-next-line import/no-default-export
export { ReportSchedulesTable as default };

View file

@ -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 () => <div data-test-subj="reportExportsTable">{'Render Report Exports Table'}</div>;
});
jest.mock('./report_schedules_table', () => {
return () => <div data-test-subj="reportSchedulesTable">{'Render Report Schedules Table'}</div>;
});
const queryClient = new QueryClient();
describe('Reporting tabs', () => {
const ilmLocator: LocatorPublic<SerializableRecord> = {
getUrl: jest.fn(),
} as unknown as LocatorPublic<SerializableRecord>;
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<ILicense>;
const reportDiagnostic = () => (
<ReportDiagnostic apiClient={reportingAPIClient} clientConfig={mockConfig} />
);
const routeProps: RouteComponentProps<MatchParams> = {
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<RouteComponentProps> & ReportingTabsProps,
newHttpService?: HttpSetupMock
) => {
const updatedReportingAPIClient = newHttpService
? new ReportingAPIClient(newHttpService, uiSettingsClient, 'x.x.x')
: reportingAPIClient;
return (
<EuiThemeProvider>
<KibanaContextProvider
services={{
http,
application,
uiSettings: uiSettingsClient,
data: dataService,
share: shareService,
}}
>
<InternalApiClientProvider apiClient={updatedReportingAPIClient} http={http}>
<IlmPolicyStatusContextProvider>
<IntlProvider locale="en">
<Router history={renderProps.history ?? props.history}>
<QueryClientProvider client={queryClient}>
<ReportingTabs {...renderProps} />
</QueryClientProvider>
</Router>
</IntlProvider>
</IlmPolicyStatusContextProvider>
</InternalApiClientProvider>
</KibanaContextProvider>
</EuiThemeProvider>
);
};
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<MatchParams> = {
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();
});
});
});

View file

@ -0,0 +1,269 @@
/*
* 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 {
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';
import { CoreStart, ScopedHistory } from '@kbn/core/public';
import { ILicense, LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import {
ClientConfigType,
ReportingAPIClient,
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 { 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';
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';
import { TECH_PREVIEW_DESCRIPTION, TECH_PREVIEW_LABEL } from '../translations';
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<RouteComponentProps> & 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<ILicense | null>(license$ ?? new Observable(), null);
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 = [
{
id: 'exports',
name: i18n.translate('xpack.reporting.tabs.exports', {
defaultMessage: 'Exports',
}),
},
{
id: 'schedules',
name: i18n.translate('xpack.reporting.tabs.schedules', {
defaultMessage: 'Schedules',
}),
isBeta: true,
},
];
const { enableLinks, showLinks } = hasValidLicense();
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 ? (
<EuiPageTemplate.Section grow={false} paddingSize="none">
{suspendedComponentWithProps(
ReportSchedulesTable,
'xl'
)({
apiClient,
toasts: notifications.toasts,
license$,
config,
capabilities,
redirect: navigateToApp,
navigateToUrl,
urlService: shareService.url,
http,
})}
</EuiPageTemplate.Section>
) : (
<LicensePrompt />
)}
</>
);
}, [
apiClient,
notifications.toasts,
license$,
config,
capabilities,
navigateToApp,
navigateToUrl,
shareService.url,
http,
enableLinks,
showLinks,
]);
const onSectionChange = (newSection: Section) => {
history.push(`/${newSection}`);
};
return (
<>
<EuiPageTemplate.Header
paddingSize="none"
bottomBorder
rightSideItems={
config.statefulSettings.enabled
? [
<MigrateIlmPolicyCallOut toasts={notifications.toasts} />,
<EuiFlexItem grow={false}>
<ReportDiagnostic clientConfig={config} apiClient={apiClient} />
</EuiFlexItem>,
<EuiFlexGroup justifyContent="flexEnd">
{capabilities?.management?.data?.index_lifecycle_management && (
<EuiFlexItem grow={false}>
{ilmPolicyContextValue?.isLoading ? (
<EuiLoadingSpinner />
) : (
showIlmPolicyLink && <IlmPolicyLink locator={ilmLocator!} />
)}
</EuiFlexItem>
)}
</EuiFlexGroup>,
]
: []
}
data-test-subj="reportingPageHeader"
pageTitle={
<FormattedMessage id="xpack.reporting.reports.titleStateful" defaultMessage="Reports" />
}
description={
<FormattedMessage
id="xpack.reporting.reports.subtitleStateful"
defaultMessage="Get reports generated in Kibana applications."
/>
}
tabs={tabs.map(({ id, name, isBeta = false }) => ({
label: !isBeta ? (
name
) : (
<>
{name}{' '}
<EuiBetaBadge
className="eui-alignMiddle"
size="s"
iconType="flask"
label={TECH_PREVIEW_LABEL}
tooltipContent={TECH_PREVIEW_DESCRIPTION}
/>
</>
),
onClick: () => onSectionChange(id as Section),
isSelected: id === section,
key: id,
'data-test-subj': `reportingTabs-${id}`,
}))}
/>
<Routes>
<Route exact path={REPORTING_EXPORTS_PATH} component={renderExportsList} />
<Route exact path={REPORTING_SCHEDULES_PATH} component={renderSchedulesList} />
</Routes>
</>
);
};
// eslint-disable-next-line import/no-default-export
export { ReportingTabs as default };

View file

@ -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 <EuiDescribedFormGroup {...props} />;
};

View file

@ -0,0 +1,45 @@
/*
* 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<ScheduledReport>;
availableReportTypes: ReportTypeData[];
onClose: () => void;
}
export const ScheduledReportFlyout = ({
apiClient,
scheduledReport,
availableReportTypes,
onClose,
}: ScheduledReportFlyoutProps) => {
return (
<EuiFlyout
size="m"
maxWidth={500}
paddingSize="l"
ownFocus={true}
onClose={onClose}
data-test-subj="scheduledReportFlyout"
>
<ScheduledReportFlyoutContent
apiClient={apiClient}
scheduledReport={scheduledReport}
availableReportTypes={availableReportTypes}
onClose={onClose}
readOnly
/>
</EuiFlyout>
);
};

View file

@ -0,0 +1,382 @@
/*
* 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: () => <div data-test-subj="recurring-schedule-form-fields" />,
})
);
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) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
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().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: mockKibanaServices,
});
jest.clearAllMocks();
testQueryClient.clear();
});
it('should not render the flyout footer when the form is in readOnly mode', () => {
render(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={scheduledReport}
availableReportTypes={availableFormats}
onClose={mockOnClose}
readOnly={true}
/>
</TestProviders>
);
expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
});
it('should show a callout in case of errors while fetching reporting health', async () => {
mockGetReportingHealth.mockRejectedValueOnce({});
render(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={scheduledReport}
availableReportTypes={availableFormats}
onClose={mockOnClose}
/>
</TestProviders>
);
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(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={scheduledReport}
availableReportTypes={availableFormats}
onClose={mockOnClose}
/>
</TestProviders>
);
expect(await screen.findByText('Cannot schedule reports')).toBeInTheDocument();
});
it('should render the initial form fields when all the prerequisites are met', async () => {
render(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={scheduledReport}
availableReportTypes={availableFormats}
onClose={mockOnClose}
/>
</TestProviders>
);
expect(await screen.findByText('Report name')).toBeInTheDocument();
expect(await screen.findByText('File type')).toBeInTheDocument();
expect(await screen.findByText('Send by email')).toBeInTheDocument();
});
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(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={scheduledReport}
availableReportTypes={availableFormats}
onClose={mockOnClose}
/>
</TestProviders>
);
const toggle = await screen.findByText('Send by email');
await userEvent.click(toggle);
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 () => {
mockGetReportingHealth.mockResolvedValueOnce({
isSufficientlySecure: true,
hasPermanentEncryptionKey: true,
areNotificationsEnabled: false,
});
render(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={scheduledReport}
availableReportTypes={availableFormats}
onClose={mockOnClose}
/>
</TestProviders>
);
expect(await screen.findByText("Email connector hasn't been created yet")).toBeInTheDocument();
});
it('should submit the form successfully and call onClose', async () => {
render(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={scheduledReport}
availableReportTypes={availableFormats}
onClose={mockOnClose}
/>
</TestProviders>
);
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(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={scheduledReport}
availableReportTypes={availableFormats}
onClose={mockOnClose}
/>
</TestProviders>
);
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(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={{ title: '', reportTypeId: scheduledReport.reportTypeId }}
availableReportTypes={availableFormats}
onClose={mockOnClose}
/>
</TestProviders>
);
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.mockReturnValueOnce([{ valid: false, reason: 'notAllowed' }]);
render(
<TestProviders>
<ScheduledReportFlyoutContent
apiClient={mockApiClient}
objectType={objectType}
sharingData={sharingData}
scheduledReport={scheduledReport}
availableReportTypes={availableFormats}
onClose={mockOnClose}
/>
</TestProviders>
);
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();
});
});

View file

@ -0,0 +1,421 @@
/*
* 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, { useEffect, useMemo } from 'react';
import moment from 'moment';
import {
EuiBetaBadge,
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_SCHEDULES } 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 { 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';
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<ScheduledReport>;
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 {
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(
() => (
<EuiLink href={http.basePath.prepend(REPORTING_MANAGEMENT_SCHEDULES)}>
{i18n.REPORTING_PAGE_LINK_TEXT}
</EuiLink>
),
[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<FormData>,
[availableReportTypes, validateEmailAddresses]
);
const recurring = true;
const startDate = defaultStartDateValue;
const timezone = defaultTimezone;
const { form } = useForm<FormData>({
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<FormData>({
form,
watch: ['reportTypeId', 'sendByEmail'],
});
useEffect(() => {
if (!readOnly && !hasManageReportingPrivilege && userProfile?.user.email) {
form.setFieldValue('emailRecipients', [userProfile.user.email]);
}
}, [form, hasManageReportingPrivilege, readOnly, userProfile?.user.email]);
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 (
<>
<EuiFlyoutHeader hasBorder={true}>
<EuiTitle size="s">
<h2>
{i18n.SCHEDULED_REPORT_FLYOUT_TITLE}{' '}
<EuiBetaBadge
className="eui-alignMiddle"
iconType="flask"
label={i18n.TECH_PREVIEW_LABEL}
tooltipContent={i18n.TECH_PREVIEW_DESCRIPTION}
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{!readOnly && (isReportingHealthLoading || isUserProfileLoading) ? (
<EuiLoadingSpinner size="l" />
) : isReportingHealthError ? (
<EuiCallOut
title={i18n.CANNOT_LOAD_REPORTING_HEALTH_TITLE}
iconType="error"
color="danger"
>
<p>{i18n.CANNOT_LOAD_REPORTING_HEALTH_MESSAGE}</p>
</EuiCallOut>
) : hasUnmetPrerequisites ? (
<EuiCallOut
title={i18n.UNMET_REPORTING_PREREQUISITES_TITLE}
iconType="error"
color="danger"
>
<p>{i18n.UNMET_REPORTING_PREREQUISITES_MESSAGE}</p>
</EuiCallOut>
) : (
<Form form={form} id={SCHEDULED_REPORT_FORM_ID}>
<ResponsiveFormGroup
title={<h3>{i18n.SCHEDULED_REPORT_FORM_DETAILS_SECTION_TITLE}</h3>}
>
<FormField
path="title"
componentProps={{
compressed: true,
fullWidth: true,
euiFieldProps: {
compressed: true,
fullWidth: true,
append: i18n.SCHEDULED_REPORT_FORM_FILE_NAME_SUFFIX,
readOnly,
},
}}
/>
<FormField
path="reportTypeId"
componentProps={{
compressed: true,
fullWidth: true,
euiFieldProps: {
compressed: true,
fullWidth: true,
options:
availableReportTypes?.map((f) => ({ inputDisplay: f.label, value: f.id })) ??
[],
readOnly,
},
}}
/>
{reportTypeId === 'printablePdfV2' && (
<FormField
path="optimizedForPrinting"
config={{
type: FIELD_TYPES.TOGGLE,
label: i18n.SCHEDULED_REPORT_FORM_OPTIMIZED_FOR_PRINTING_LABEL,
}}
componentProps={{
helpText: i18n.SCHEDULED_REPORT_FORM_OPTIMIZED_FOR_PRINTING_DESCRIPTION,
euiFieldProps: {
compressed: true,
disabled: readOnly,
},
}}
/>
)}
</ResponsiveFormGroup>
<ResponsiveFormGroup
title={<h3>{i18n.SCHEDULED_REPORT_FORM_SCHEDULE_SECTION_TITLE}</h3>}
>
{isRecurring && (
<RecurringScheduleFormFields
startDate={!readOnly ? startDate : undefined}
timezone={!readOnly ? (timezone ? [timezone] : [defaultTimezone]) : undefined}
hideTimezone
readOnly={readOnly}
supportsEndOptions={false}
minFrequency={Frequency.MONTHLY}
showTimeInSummary
compressed
/>
)}
</ResponsiveFormGroup>
<ResponsiveFormGroup
title={<h3>{i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_TITLE}</h3>}
description={
!readOnly && (
<p>
{i18n.SCHEDULED_REPORT_FORM_EXPORTS_SECTION_DESCRIPTION} {reportingPageLink}.
</p>
)
}
>
<FormField
path="sendByEmail"
componentProps={{
helpText:
!hasManageReportingPrivilege && !userProfile?.user.email
? i18n.SCHEDULED_REPORT_FORM_NO_USER_EMAIL_HINT
: undefined,
euiFieldProps: {
compressed: true,
disabled:
readOnly ||
!reportingHealth.areNotificationsEnabled ||
(!hasManageReportingPrivilege && !userProfile?.user.email),
},
}}
/>
{reportingHealth.areNotificationsEnabled ? (
isEmailActive && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup direction="column" gutterSize="s">
<FormField
path="emailRecipients"
componentProps={{
compressed: true,
fullWidth: true,
helpText: !readOnly
? hasManageReportingPrivilege
? i18n.SCHEDULED_REPORT_FORM_EMAIL_RECIPIENTS_HINT
: i18n.SCHEDULED_REPORT_FORM_EMAIL_SELF_HINT
: undefined,
euiFieldProps: {
compressed: true,
fullWidth: true,
isDisabled: readOnly || !hasManageReportingPrivilege,
'data-test-subj': 'emailRecipientsCombobox',
},
}}
/>
{!readOnly && (
<EuiCallOut
title={i18n.SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_TITLE}
iconType="iInCircle"
size="s"
>
<p>{i18n.SCHEDULED_REPORT_FORM_EMAIL_SENSITIVE_INFO_MESSAGE}</p>
</EuiCallOut>
)}
</EuiFlexGroup>
</>
)
) : (
<>
<EuiSpacer size="m" />
<EuiCallOut
title={i18n.SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_TITLE}
iconType="iInCircle"
size="s"
color="warning"
>
<p>{i18n.SCHEDULED_REPORT_FORM_MISSING_EMAIL_CONNECTOR_MESSAGE}</p>
</EuiCallOut>
</>
)}
</ResponsiveFormGroup>
</Form>
)}
</EuiFlyoutBody>
{!readOnly && (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose} flush="left">
{i18n.SCHEDULED_REPORT_FLYOUT_CANCEL_BUTTON_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
type="submit"
form={SCHEDULED_REPORT_FORM_ID}
isDisabled={isReportingHealthLoading || isUserProfileLoading}
onClick={onSubmit}
isLoading={isScheduleExportLoading}
fill
>
{i18n.SCHEDULED_REPORT_FLYOUT_SUBMIT_BUTTON_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
)}
</>
);
};

View file

@ -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 { ReportingPublicPluginStartDependencies } from '../../plugin';
import { ScheduledReportFlyoutContent } from './scheduled_report_flyout_content';
import { ReportTypeId } from '../../types';
export interface ScheduledReportMenuItem {
apiClient: ReportingAPIClient;
services: ReportingPublicPluginStartDependencies;
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 (
<KibanaContextProvider services={services}>
<QueryClientProvider client={queryClient}>
<ScheduledReportFlyoutContent
apiClient={apiClient}
objectType={objectType}
sharingData={sharingData}
availableReportTypes={availableReportTypes}
scheduledReport={scheduledReport}
onClose={onClose}
/>
</QueryClientProvider>
</KibanaContextProvider>
);
};

View file

@ -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<T = unknown>(
ComponentToSuspend: React.ComponentType<T>,
size?: 's' | 'm' | 'l' | 'xl' | 'xxl'
) {
return (props: T) => (
<Suspense fallback={<EuiLoadingSpinner size={size ?? 'm'} />}>
{/* @ts-expect-error upgrade typescript v4.9.5*/}
<ComponentToSuspend {...props} />
</Suspense>
);
}

View file

@ -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<Props> = ({ text }) => {
return (
<span css={getTextCss} title={text}>
{text}
</span>
);
};
TruncatedTitleComponent.displayName = 'TruncatedTitle';
export const TruncatedTitle = React.memo(TruncatedTitleComponent);

View file

@ -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';

View file

@ -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<ListingPropsInternal> = (props) => {
const { apiClient, capabilities, config, navigateToUrl, toasts, urlService, ...listingProps } =
props;
return (
<>
<EuiPageHeader
data-test-subj="reportingPageHeader"
bottomBorder
pageTitle={
<FormattedMessage id="xpack.reporting.listing.reportstitle" defaultMessage="Reports" />
}
description={
<FormattedMessage
id="xpack.reporting.listing.reports.subtitle"
defaultMessage="Get reports generated in Kibana applications."
/>
}
/>
<EuiSpacer size={'l'} />
<ReportListingTable
{...listingProps}
apiClient={apiClient}
capabilities={capabilities}
config={config}
toasts={toasts}
navigateToUrl={navigateToUrl}
urlService={urlService}
/>
</>
);
};

View file

@ -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 { 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(),
}));
describe('useBulkDisable', () => {
const http = httpServiceMock.createStartContract();
const toasts = notificationServiceMock.createStartContract().toasts;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
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();
});
});
});

View file

@ -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<ResponseErrorBody>;
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',
});
},
});
};

View file

@ -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 });
});
});

View file

@ -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 };
};

View file

@ -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) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
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);
});
});

View file

@ -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 }),
});
};

View file

@ -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 { 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(),
}));
describe('useGetScheduledList', () => {
const http = httpServiceMock.createStartContract();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
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,
});
});
});

View file

@ -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,
});
};

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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 * 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(),
};
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
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('should not enable the query if userProfileService is not provided', async () => {
const { result } = renderHook(() => useGetUserProfileQuery({ userProfileService: undefined }), {
wrapper,
});
expect(useQuerySpy).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
})
);
expect(result.current.data).toBeUndefined();
});
});

View file

@ -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),
});
};

View file

@ -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 }) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
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);
});
});

View file

@ -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 }),
});
};

View file

@ -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';

View file

@ -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 { ReportingPublicPluginStartDependencies } from '../../plugin';
import { getReportingHealth } from '../apis/get_reporting_health';
export interface CreateScheduledReportProviderOptions {
apiClient: ReportingAPIClient;
services: ReportingPublicPluginStartDependencies;
}
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<ExportShareDerivatives['config']> => {
const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData };
return {
label: ({ openFlyout }) => (
<EuiButton iconType="calendar" onClick={openFlyout}>
{SCHEDULE_EXPORT_BUTTON_LABEL}
</EuiButton>
),
flyoutContent: ({ closeFlyout }) => {
return (
<ScheduledReportFlyoutShareWrapper
apiClient={apiClient}
services={services}
sharingData={sharingData}
onClose={closeFlyout}
/>
);
},
flyoutSizing: { size: 'm', maxWidth: 500 },
};
},
prerequisiteCheck: ({ license }) => {
if (!license || !license.type) {
return false;
}
return SCHEDULED_REPORT_VALID_LICENSES.includes(license.type);
},
};
};

View file

@ -5,10 +5,10 @@
* 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 } 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,49 +21,92 @@ import {
ReportingAPIClient,
KibanaContext,
} from '@kbn/reporting-public';
import { ReportListing } from '.';
import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public';
import { QueryClientProvider } from '@tanstack/react-query';
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';
export async function mountManagementSection(
coreStart: CoreStart,
license$: LicensingPluginStart['license$'],
dataService: DataPublicPluginStart,
shareService: SharePluginStart,
config: ClientConfigType,
apiClient: ReportingAPIClient,
params: ManagementAppMountParams
) {
const ReportingTabs = lazy(() => import('./components/reporting_tabs'));
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,
};
const sections: Section[] = ['exports', 'schedules'];
const { element, history } = params;
render(
const sectionsRegex = sections.join('|');
ReactDOM.render(
<KibanaRenderContextProvider {...coreStart}>
<KibanaContextProvider services={services}>
<InternalApiClientProvider apiClient={apiClient} http={coreStart.http}>
<PolicyStatusContextProvider config={config}>
<ReportListing
apiClient={apiClient}
toasts={coreStart.notifications.toasts}
license$={license$}
config={config}
redirect={coreStart.application.navigateToApp}
navigateToUrl={coreStart.application.navigateToUrl}
urlService={shareService.url}
/>
<QueryClientProvider client={queryClient}>
<Router history={history}>
<Routes>
<Route
path={`/:section(${sectionsRegex})`}
render={(routerProps) => {
return (
<Suspense fallback={<EuiLoadingSpinner size="xl" />}>
<ReportingTabs
coreStart={coreStart}
apiClient={apiClient}
license$={license$}
config={config}
dataService={dataService}
shareService={shareService}
{...routerProps}
/>
</Suspense>
);
}}
/>
<Redirect from={'/'} to="/exports" />
</Routes>
</Router>
</QueryClientProvider>
</PolicyStatusContextProvider>
</InternalApiClientProvider>
</KibanaContextProvider>
</KibanaRenderContextProvider>,
params.element
element
);
return () => {
unmountComponentAtNode(params.element);
ReactDOM.unmountComponentAtNode(element);
};
}

View file

@ -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,
};

View file

@ -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.
*/
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 = {
bulkDisableScheduledReports: () => [root, 'bulkDisableScheduledReports'] as const,
};

View file

@ -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<Props>) => {
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<ILicense>;
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);
});
});
});

View file

@ -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 ? (
<ReportListingStateful {...props} apiClient={apiClient} capabilities={capabilities} />
) : (
<ReportListingDefault {...props} apiClient={apiClient} capabilities={capabilities} />
);
};

View file

@ -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,
})
);
};

View file

@ -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),
},
],
},
});

View file

@ -1,87 +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<ListingPropsInternal> = (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 (
<>
<EuiPageHeader
data-test-subj="reportingPageHeader"
bottomBorder
pageTitle={
<FormattedMessage
id="xpack.reporting.listing.reports.titleStateful"
defaultMessage="Reports"
/>
}
description={
<FormattedMessage
id="xpack.reporting.listing.reports.subtitleStateful"
defaultMessage="Get reports generated in Kibana applications."
/>
}
/>
<MigrateIlmPolicyCallOut toasts={toasts} />
<EuiSpacer size={'l'} />
<ReportListingTable
{...listingProps}
apiClient={apiClient}
capabilities={capabilities}
config={config}
toasts={toasts}
navigateToUrl={navigateToUrl}
urlService={urlService}
/>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd">
{capabilities?.management?.data?.index_lifecycle_management && (
<EuiFlexItem grow={false}>
{ilmPolicyContextValue?.isLoading ? (
<EuiLoadingSpinner />
) : (
showIlmPolicyLink && (
<IlmPolicyLink navigateToUrl={navigateToUrl} locator={ilmLocator!} />
)
)}
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<ReportDiagnostic clientConfig={config} apiClient={apiClient} />
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable no-console */
import { QueryClient } from '@tanstack/react-query';
export const testQueryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {},
},
});

View file

@ -0,0 +1,324 @@
/*
* 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_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',
{
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_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',
{
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 couldnt 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 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',
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 },
});
}

View file

@ -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: [] })
);
});
});

View file

@ -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<Record<string, boolean>>((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 || [])],
};
};

View file

@ -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',
});
});
});

View file

@ -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<ScheduledReport, string, string> =>
({ 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),
};
}
}
};

View file

@ -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<ScheduledReport, string, Moment> =>
({ value }) => {
if (value.isBefore(today)) {
return {
message: SCHEDULED_REPORT_FORM_START_DATE_TOO_EARLY_MESSAGE,
};
}
};

View file

@ -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,9 +35,12 @@ 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';
import { APP_DESC, APP_TITLE } from './translations';
import { APP_PATH } from './constants';
export interface ReportingPublicPluginSetupDependencies {
home: HomePublicPluginSetup;
@ -41,6 +49,7 @@ export interface ReportingPublicPluginSetupDependencies {
screenshotMode: ScreenshotModePluginSetup;
share: SharePluginSetup;
intl: InjectedIntl;
actions: ActionsPublicPluginSetup;
}
export interface ReportingPublicPluginStartDependencies {
@ -50,6 +59,7 @@ export interface ReportingPublicPluginStartDependencies {
licensing: LicensingPluginStart;
uiActions: UiActionsStart;
share: SharePluginStart;
actions: ActionsPublicPluginSetup;
}
type StartServices$ = Observable<StartServices>;
@ -108,6 +118,7 @@ export class ReportingPublicPlugin
screenshotMode: screenshotModeSetup,
share: shareSetup,
uiActions: uiActionsSetup,
actions: actionsSetup,
} = setupDeps;
const startServices$: Observable<StartServices> = from(getStartServices()).pipe(
@ -118,6 +129,7 @@ export class ReportingPublicPlugin
notifications: start.notifications,
rendering: start.rendering,
uiSettings: start.uiSettings,
chrome: start.chrome,
},
...rest,
];
@ -129,14 +141,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',
});
@ -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,23 @@ export class ReportingPublicPlugin
);
}
import('./management/integrations/scheduled_report_share_integration').then(
async ({
shouldRegisterScheduledReportShareIntegration,
createScheduledReportShareIntegration,
}) => {
const [coreStart, startDeps] = await getStartServices();
if (await shouldRegisterScheduledReportShareIntegration(core.http)) {
shareSetup.registerShareIntegration<ExportShareDerivatives>(
createScheduledReportShareIntegration({
apiClient,
services: { ...coreStart, ...startDeps, actions: actionsSetup },
})
);
}
}
);
this.startServices$ = startServices$;
return this.getContract(apiClient, startServices$);
}

View file

@ -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,
},
},
});

View file

@ -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<Props> = ({ 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 {

View file

@ -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',
}
);

View file

@ -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;
}

View file

@ -1,7 +1,7 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"outDir": "target/types"
},
"include": ["common/**/*", "public/**/*", "server/**/*", "../../../../../typings/**/*"],
"kbn_references": [
@ -58,8 +58,14 @@
"@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-user-profile-browser"
],
"exclude": [
"target/**/*",
]
"exclude": ["target/**/*"]
}

View file

@ -33325,41 +33325,18 @@
"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 {report} other {reports} }",
"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 lapplication Kibana directement à lemplacement 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",

View file

@ -33363,41 +33363,18 @@
"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, one {report} other {reports} }を削除",
"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フッター画像",
@ -33930,6 +33907,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",

View file

@ -33348,41 +33348,18 @@
"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, one {report} other {reports} }",
"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 页脚图像",

View file

@ -167,11 +167,11 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
const maintenanceWindow = {
title: formData.title,
duration: endDate.diff(startDate),
rRule: convertToRRule(
rRule: convertToRRule({
startDate,
formData.timezone ? formData.timezone[0] : defaultTimezone,
formData.recurringSchedule
),
timezone: formData.timezone ? formData.timezone[0] : defaultTimezone,
recurringSchedule: formData.recurringSchedule,
}),
categoryIds: formData.solutionId ? [formData.solutionId] : null,
scopedQuery: availableSolutions.length !== 0 ? scopedQueryPayload : null,
};

View file

@ -87,7 +87,7 @@ export const UpcomingEventsPopover: React.FC<UpcomingEventsPopoverProps> = React
>
<EuiPopoverTitle data-test-subj="upcoming-events-popover-title">
{i18n.CREATE_FORM_RECURRING_SUMMARY_PREFIX(
recurringSummary(startDate, recurringSchedule, presets)
recurringSummary({ startDate, recurringSchedule, presets })
)}
</EuiPopoverTitle>
<EuiFlexGroup direction="column" responsive={false} gutterSize="none">

View file

@ -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();
});
});

View file

@ -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();
});

View file

@ -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');
});
});
});
};

View file

@ -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 () => {

View file

@ -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();
});
});