mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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:
parent
0a07e18442
commit
0c377fafa8
97 changed files with 5316 additions and 1062 deletions
|
@ -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
|
||||
|
|
|
@ -21,5 +21,6 @@
|
|||
"@kbn/i18n",
|
||||
"@kbn/task-manager-plugin",
|
||||
"@kbn/deeplinks-analytics",
|
||||
"@kbn/licensing-plugin",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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(',') },
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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 = ({
|
||||
|
|
|
@ -27,5 +27,6 @@
|
|||
"@kbn/home-plugin",
|
||||
"@kbn/management-plugin",
|
||||
"@kbn/ui-actions-plugin",
|
||||
"@kbn/actions-plugin",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
@ -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'>;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -29,7 +29,8 @@
|
|||
"taskManager",
|
||||
"screenshotMode",
|
||||
"share",
|
||||
"features"
|
||||
"features",
|
||||
"actions"
|
||||
],
|
||||
"optionalPlugins": [
|
||||
"security",
|
||||
|
|
|
@ -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';
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }),
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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),
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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);
|
|
@ -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}
|
||||
|
|
|
@ -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';
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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} />;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
|
@ -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';
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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 });
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 }),
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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),
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 }),
|
||||
});
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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} />
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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: () => {},
|
||||
},
|
||||
});
|
|
@ -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 couldn’t schedule your export. Please try again.',
|
||||
}
|
||||
);
|
||||
|
||||
export const CANNOT_LOAD_REPORTING_HEALTH_TITLE = i18n.translate(
|
||||
'xpack.reporting.scheduledReportingForm.cannotLoadReportingHealthTitle',
|
||||
{
|
||||
defaultMessage: 'Cannot load reporting health',
|
||||
}
|
||||
);
|
||||
|
||||
export const UNMET_REPORTING_PREREQUISITES_TITLE = i18n.translate(
|
||||
'xpack.reporting.scheduledReportingForm.unmetReportingPrerequisitesTitle',
|
||||
{
|
||||
defaultMessage: 'Cannot schedule reports',
|
||||
}
|
||||
);
|
||||
|
||||
export const UNMET_REPORTING_PREREQUISITES_MESSAGE = i18n.translate(
|
||||
'xpack.reporting.scheduledReportingForm.unmetReportingPrerequisitesMessage',
|
||||
{
|
||||
defaultMessage:
|
||||
'One or more prerequisites for scheduling reports was not met. Contact your administrator to know more.',
|
||||
}
|
||||
);
|
||||
|
||||
export const CANNOT_LOAD_REPORTING_HEALTH_MESSAGE = i18n.translate(
|
||||
'xpack.reporting.scheduledReportingForm.cannotLoadReportingHealthMessage',
|
||||
{
|
||||
defaultMessage: 'Reporting health is a prerequisite to create scheduled exports',
|
||||
}
|
||||
);
|
||||
|
||||
export 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 },
|
||||
});
|
||||
}
|
|
@ -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: [] })
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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 || [])],
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
|
@ -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$);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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 l’application Kibana directement à l’emplacement de génération de ce rapport.",
|
||||
"xpack.reporting.listing.table.openInKibanaAppLabel": "Ouvrir dans Kibana",
|
||||
"xpack.reporting.listing.table.reportInfoAndErrorButtonTooltip": "Consultez les informations de rapport et le message d'erreur.",
|
||||
"xpack.reporting.listing.table.reportInfoAndWarningsButtonTooltip": "Consultez les informations de rapport et les avertissements.",
|
||||
"xpack.reporting.listing.table.reportInfoButtonTooltip": "Consultez les informations de rapport.",
|
||||
"xpack.reporting.listing.table.reportInfoUnableToFetch": "Impossible de récupérer les informations de rapport.",
|
||||
"xpack.reporting.listing.table.requestFailedErrorMessage": "Demande refusée",
|
||||
"xpack.reporting.listing.table.showReportInfoAriaLabel": "Afficher les informations de rapport",
|
||||
"xpack.reporting.listing.table.untitledReport": "Rapport sans titre",
|
||||
"xpack.reporting.listing.table.viewReportingInfoActionButtonDescription": "Accédez à des informations supplémentaires sur ce rapport.",
|
||||
"xpack.reporting.listing.table.viewReportingInfoActionButtonLabel": "Afficher les informations du rapport",
|
||||
"xpack.reporting.listing.tableColumns.actionsTitle": "Actions",
|
||||
"xpack.reporting.listing.tableColumns.content": "Contenu",
|
||||
"xpack.reporting.listing.tableColumns.createdAtTitle": "Créé à",
|
||||
"xpack.reporting.listing.tableColumns.reportTitle": "Titre",
|
||||
"xpack.reporting.listing.tableColumns.statusTitle": "Statut",
|
||||
"xpack.reporting.listing.tableColumns.typeTitle": "Type",
|
||||
"xpack.reporting.management.reportingTitle": "Reporting",
|
||||
"xpack.reporting.pdfFooterImageDescription": "Image personnalisée à utiliser dans le pied de page du PDF",
|
||||
"xpack.reporting.pdfFooterImageLabel": "Image de pied de page du PDF",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 页脚图像",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue