[ML] Transforms: Improve transform list reloading behavior. (#164296)

## Summary

- If the transform list fails to load, it does no longer show a
non-refreshable full page error. Instead the error is shown in an inline
callout and the refresh button is still present.
- `AuthorizationProvider` is gone and has been replaced by a custom
hook `useTransformCapabilities`. All client side code no longer relies
on `privileges` being present but makes use of `capabilities` (like
`canGetTransform` etc.). The custom route to fetch privileges and
capabilities is also gone, instead capabilities are retrieved from
Kibana's own `application.capabilities.transform` which we register
server side.
- Refactors all remote data fetching to use `react-query`. This gets
rid of the custom code to refresh the transform list using observables,
instead all CRUD actions now make use of `react-query`'s `useMutation`
and trigger a cache invalidation of the transform list data to initiate
a refetch. The `useApi` hook is gone, instead we now have specific hooks
for data fetching that wrap `useQuery` (`useGetTransform`,
`useGetTransformStats` etc.) and the existing hooks for actions have
been refactored to use `useMutation` (`useStartTransforms`,
`useStopTransforms` etc.).
- Toasts for "success" messages have been removed.
- All tests that made use of `toMatchSnapshot` have been refactored
away from `enzyme` to `react-testing-lib` and no longer rely on
snapshots, instead we make basic assertions on the rendered components.

### Checklist

- [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] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Walter Rafelsberger 2023-09-01 21:52:30 +02:00 committed by GitHub
parent e52dd715ca
commit 0b705eba71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
163 changed files with 2415 additions and 4106 deletions

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import { schema, TypeOf } from '@kbn/config-schema'; import { schema, type TypeOf } from '@kbn/config-schema';
import type { ES_FIELD_TYPES } from '@kbn/field-types'; import type { ES_FIELD_TYPES } from '@kbn/field-types';

View file

@ -13,7 +13,7 @@ import { getTransformsRequestSchema } from './transforms';
export const getTransformsStatsRequestSchema = getTransformsRequestSchema; export const getTransformsStatsRequestSchema = getTransformsRequestSchema;
export type GetTransformsRequestSchema = TypeOf<typeof getTransformsStatsRequestSchema>; export type GetTransformsStatsRequestSchema = TypeOf<typeof getTransformsStatsRequestSchema>;
export interface GetTransformsStatsResponseSchema { export interface GetTransformsStatsResponseSchema {
node_failures?: object; node_failures?: object;

View file

@ -1,147 +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 type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import type { EsIndex } from '../types/es_index';
import type { EsIngestPipeline } from '../types/es_ingest_pipeline';
// To be able to use the type guards on the client side, we need to make sure we don't import
// the code of '@kbn/config-schema' but just its types, otherwise the client side code will
// fail to build.
import type { FieldHistogramsResponseSchema } from './field_histograms';
import type { GetTransformsAuditMessagesResponseSchema } from './audit_messages';
import type { DeleteTransformsResponseSchema } from './delete_transforms';
import type { ResetTransformsResponseSchema } from './reset_transforms';
import type { StartTransformsResponseSchema } from './start_transforms';
import type { StopTransformsResponseSchema } from './stop_transforms';
import type { ScheduleNowTransformsResponseSchema } from './schedule_now_transforms';
import type {
GetTransformNodesResponseSchema,
GetTransformsResponseSchema,
PostTransformsPreviewResponseSchema,
PutTransformsResponseSchema,
} from './transforms';
import type { GetTransformsStatsResponseSchema } from './transforms_stats';
import type { PostTransformsUpdateResponseSchema } from './update_transforms';
const isGenericResponseSchema = <T>(arg: any): arg is T => {
return isPopulatedObject(arg, ['count', 'transforms']) && Array.isArray(arg.transforms);
};
export const isGetTransformNodesResponseSchema = (
arg: unknown
): arg is GetTransformNodesResponseSchema => {
return isPopulatedObject(arg, ['count']) && typeof arg.count === 'number';
};
export const isGetTransformsResponseSchema = (arg: unknown): arg is GetTransformsResponseSchema => {
return isGenericResponseSchema<GetTransformsResponseSchema>(arg);
};
export const isGetTransformsStatsResponseSchema = (
arg: unknown
): arg is GetTransformsStatsResponseSchema => {
return isGenericResponseSchema<GetTransformsStatsResponseSchema>(arg);
};
export const isDeleteTransformsResponseSchema = (
arg: unknown
): arg is DeleteTransformsResponseSchema => {
return (
isPopulatedObject(arg) &&
Object.values(arg).every((d) => isPopulatedObject(d, ['transformDeleted']))
);
};
export const isResetTransformsResponseSchema = (
arg: unknown
): arg is ResetTransformsResponseSchema => {
return (
isPopulatedObject(arg) &&
Object.values(arg).every((d) => isPopulatedObject(d, ['transformReset']))
);
};
export const isEsIndices = (arg: unknown): arg is EsIndex[] => {
return Array.isArray(arg);
};
export const isEsIngestPipelines = (arg: unknown): arg is EsIngestPipeline[] => {
return Array.isArray(arg) && arg.every((d) => isPopulatedObject(d, ['name']));
};
export const isEsSearchResponse = (arg: unknown): arg is estypes.SearchResponse => {
return isPopulatedObject(arg, ['hits']);
};
type SearchResponseWithAggregations = Required<Pick<estypes.SearchResponse, 'aggregations'>> &
estypes.SearchResponse;
export const isEsSearchResponseWithAggregations = (
arg: unknown
): arg is SearchResponseWithAggregations => {
return isEsSearchResponse(arg) && {}.hasOwnProperty.call(arg, 'aggregations');
};
export const isFieldHistogramsResponseSchema = (
arg: unknown
): arg is FieldHistogramsResponseSchema => {
return Array.isArray(arg);
};
export const isGetTransformsAuditMessagesResponseSchema = (
arg: unknown
): arg is GetTransformsAuditMessagesResponseSchema => {
return isPopulatedObject(arg, ['messages', 'total']);
};
export const isPostTransformsPreviewResponseSchema = (
arg: unknown
): arg is PostTransformsPreviewResponseSchema => {
return (
isPopulatedObject(arg, ['generated_dest_index', 'preview']) &&
typeof arg.generated_dest_index !== undefined &&
Array.isArray(arg.preview)
);
};
export const isPostTransformsUpdateResponseSchema = (
arg: unknown
): arg is PostTransformsUpdateResponseSchema => {
return isPopulatedObject(arg, ['id']) && typeof arg.id === 'string';
};
export const isPutTransformsResponseSchema = (arg: unknown): arg is PutTransformsResponseSchema => {
return (
isPopulatedObject(arg, ['transformsCreated', 'errors']) &&
Array.isArray(arg.transformsCreated) &&
Array.isArray(arg.errors)
);
};
const isGenericSuccessResponseSchema = (arg: unknown) =>
isPopulatedObject(arg) && Object.values(arg).every((d) => isPopulatedObject(d, ['success']));
export const isStartTransformsResponseSchema = (
arg: unknown
): arg is StartTransformsResponseSchema => {
return isGenericSuccessResponseSchema(arg);
};
export const isStopTransformsResponseSchema = (
arg: unknown
): arg is StopTransformsResponseSchema => {
return isGenericSuccessResponseSchema(arg);
};
export const isScheduleNowTransformsResponseSchema = (
arg: unknown
): arg is ScheduleNowTransformsResponseSchema => {
return isGenericSuccessResponseSchema(arg);
};

View file

@ -32,6 +32,21 @@ const EXTERNAL_API_BASE_PATH = '/api/transform/';
export const addInternalBasePath = (uri: string): string => `${INTERNAL_API_BASE_PATH}${uri}`; export const addInternalBasePath = (uri: string): string => `${INTERNAL_API_BASE_PATH}${uri}`;
export const addExternalBasePath = (uri: string): string => `${EXTERNAL_API_BASE_PATH}${uri}`; export const addExternalBasePath = (uri: string): string => `${EXTERNAL_API_BASE_PATH}${uri}`;
export const TRANSFORM_REACT_QUERY_KEYS = {
DATA_SEARCH: 'transform.data_search',
DATA_VIEW_EXISTS: 'transform.data_view_exists',
GET_DATA_VIEW_TITLES: 'transform.get_data_view_titles',
GET_ES_INDICES: 'transform.get_es_indices',
GET_ES_INGEST_PIPELINES: 'transform.get_es_ingest_pipelines',
GET_HISTOGRAMS_FOR_FIELDS: 'transform.get_histograms_for_fields',
GET_TRANSFORM: 'transform.get_transform',
GET_TRANSFORM_NODES: 'transform.get_transform_nodes',
GET_TRANSFORM_AUDIT_MESSAGES: 'transform.get_transform_audit_messages',
GET_TRANSFORM_STATS: 'transform.get_transform_stats',
GET_TRANSFORMS: 'transform.get_transforms',
GET_TRANSFORMS_PREVIEW: 'transform.get_transforms_preview',
} as const;
// In order to create a transform, the API requires the following privileges: // In order to create a transform, the API requires the following privileges:
// - transform_admin (builtin) // - transform_admin (builtin)
// - cluster privileges: manage_transform // - cluster privileges: manage_transform
@ -71,22 +86,6 @@ export const APP_CLUSTER_PRIVILEGES = [
// Minimum privileges required to return transform node count // Minimum privileges required to return transform node count
export const NODES_INFO_PRIVILEGES = ['cluster:monitor/transform/get']; export const NODES_INFO_PRIVILEGES = ['cluster:monitor/transform/get'];
// Equivalent of capabilities.canGetTransform
export const APP_GET_TRANSFORM_CLUSTER_PRIVILEGES = [
'cluster.cluster:monitor/transform/get',
'cluster.cluster:monitor/transform/stats/get',
];
// Equivalent of capabilities.canCreateTransform
export const APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES = [
'cluster.cluster:monitor/transform/get',
'cluster.cluster:monitor/transform/stats/get',
'cluster.cluster:admin/transform/preview',
'cluster.cluster:admin/transform/put',
'cluster.cluster:admin/transform/start',
'cluster.cluster:admin/transform/start_task',
];
export const APP_INDEX_PRIVILEGES = ['monitor']; export const APP_INDEX_PRIVILEGES = ['monitor'];
// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/transforms/TransformStats.java#L214 // reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/transforms/TransformStats.java#L214

View file

@ -1,217 +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 { i18n } from '@kbn/i18n';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import { cloneDeep } from 'lodash';
import { APP_INDEX_PRIVILEGES } from '../constants';
import { Privileges } from '../types/privileges';
export interface PrivilegesAndCapabilities {
privileges: Privileges;
capabilities: Capabilities;
}
export interface TransformCapabilities {
canGetTransform: boolean;
canDeleteTransform: boolean;
canPreviewTransform: boolean;
canCreateTransform: boolean;
canReauthorizeTransform: boolean;
canScheduleNowTransform: boolean;
canStartStopTransform: boolean;
canCreateTransformAlerts: boolean;
canUseTransformAlerts: boolean;
canResetTransform: boolean;
}
export type Capabilities = { [k in keyof TransformCapabilities]: boolean };
export const INITIAL_CAPABILITIES = Object.freeze<Capabilities>({
canGetTransform: false,
canDeleteTransform: false,
canPreviewTransform: false,
canCreateTransform: false,
canReauthorizeTransform: false,
canScheduleNowTransform: false,
canStartStopTransform: false,
canCreateTransformAlerts: false,
canUseTransformAlerts: false,
canResetTransform: false,
});
export type Privilege = [string, string];
function isPrivileges(arg: unknown): arg is Privileges {
return (
isPopulatedObject(arg, ['hasAllPrivileges', 'missingPrivileges']) &&
typeof arg.hasAllPrivileges === 'boolean' &&
typeof arg.missingPrivileges === 'object' &&
arg.missingPrivileges !== null
);
}
export const toArray = (value: string | string[]): string[] =>
Array.isArray(value) ? value : [value];
export const hasPrivilegeFactory =
(privileges: Privileges | undefined | null) => (privilege: Privilege) => {
const [section, requiredPrivilege] = privilege;
if (isPrivileges(privileges) && !privileges.missingPrivileges[section]) {
// if the section does not exist in our missingPrivileges, everything is OK
return true;
}
if (isPrivileges(privileges) && privileges.missingPrivileges[section]!.length === 0) {
return true;
}
if (requiredPrivilege === '*') {
// If length > 0 and we require them all... KO
return false;
}
// If we require _some_ privilege, we make sure that the one
// we require is *not* in the missingPrivilege array
return (
isPrivileges(privileges) &&
!privileges.missingPrivileges[section]!.includes(requiredPrivilege)
);
};
export const extractMissingPrivileges = (
privilegesObject: { [key: string]: boolean } = {}
): string[] =>
Object.keys(privilegesObject).reduce((privileges: string[], privilegeName: string): string[] => {
if (!privilegesObject[privilegeName]) {
privileges.push(privilegeName);
}
return privileges;
}, []);
export const getPrivilegesAndCapabilities = (
clusterPrivileges: Record<string, boolean>,
hasOneIndexWithAllPrivileges: boolean,
hasAllPrivileges: boolean
): PrivilegesAndCapabilities => {
const privilegesResult: Privileges = {
hasAllPrivileges: true,
missingPrivileges: {
cluster: [],
index: [],
},
};
// Find missing cluster privileges and set overall app privileges
privilegesResult.missingPrivileges.cluster = extractMissingPrivileges(clusterPrivileges);
privilegesResult.hasAllPrivileges = hasAllPrivileges;
if (!hasOneIndexWithAllPrivileges) {
privilegesResult.missingPrivileges.index = [...APP_INDEX_PRIVILEGES];
}
const hasPrivilege = hasPrivilegeFactory(privilegesResult);
const capabilities = cloneDeep<Capabilities>(INITIAL_CAPABILITIES);
capabilities.canGetTransform =
hasPrivilege(['cluster', 'cluster:monitor/transform/get']) &&
hasPrivilege(['cluster', 'cluster:monitor/transform/stats/get']);
capabilities.canCreateTransform = hasPrivilege(['cluster', 'cluster:admin/transform/put']);
capabilities.canDeleteTransform = hasPrivilege(['cluster', 'cluster:admin/transform/delete']);
capabilities.canResetTransform = hasPrivilege(['cluster', 'cluster:admin/transform/reset']);
capabilities.canPreviewTransform = hasPrivilege(['cluster', 'cluster:admin/transform/preview']);
capabilities.canStartStopTransform =
hasPrivilege(['cluster', 'cluster:admin/transform/start']) &&
hasPrivilege(['cluster', 'cluster:admin/transform/start_task']) &&
hasPrivilege(['cluster', 'cluster:admin/transform/stop']);
capabilities.canCreateTransformAlerts = capabilities.canCreateTransform;
capabilities.canUseTransformAlerts = capabilities.canGetTransform;
capabilities.canScheduleNowTransform = capabilities.canStartStopTransform;
capabilities.canReauthorizeTransform = capabilities.canStartStopTransform;
return { privileges: privilegesResult, capabilities };
};
// create the text for button's tooltips if the user
// doesn't have the permission to press that button
export function createCapabilityFailureMessage(
capability: keyof TransformCapabilities | 'noTransformNodes'
) {
let message = '';
switch (capability) {
case 'canCreateTransform':
message = i18n.translate('xpack.transform.capability.noPermission.createTransformTooltip', {
defaultMessage: 'You do not have permission to create transforms.',
});
break;
case 'canCreateTransformAlerts':
message = i18n.translate(
'xpack.transform.capability.noPermission.canCreateTransformAlertsTooltip',
{
defaultMessage: 'You do not have permission to create transform alert rules.',
}
);
break;
case 'canScheduleNowTransform':
message = i18n.translate(
'xpack.transform.capability.noPermission.scheduleNowTransformTooltip',
{
defaultMessage:
'You do not have permission to schedule transforms to process data instantly.',
}
);
break;
case 'canStartStopTransform':
message = i18n.translate(
'xpack.transform.capability.noPermission.startOrStopTransformTooltip',
{
defaultMessage: 'You do not have permission to start or stop transforms.',
}
);
break;
case 'canReauthorizeTransform':
message = i18n.translate(
'xpack.transform.capability.noPermission.reauthorizeTransformTooltip',
{
defaultMessage: 'You do not have permission to reauthorize transforms.',
}
);
break;
case 'canDeleteTransform':
message = i18n.translate('xpack.transform.capability.noPermission.deleteTransformTooltip', {
defaultMessage: 'You do not have permission to delete transforms.',
});
break;
case 'canResetTransform':
message = i18n.translate('xpack.transform.capability.noPermission.resetTransformTooltip', {
defaultMessage: 'You do not have permission to reset transforms.',
});
break;
case 'noTransformNodes':
message = i18n.translate('xpack.transform.capability.noPermission.noTransformNodesTooltip', {
defaultMessage: 'There are no transform nodes available.',
});
break;
}
return i18n.translate('xpack.transform.capability.pleaseContactAdministratorTooltip', {
defaultMessage: '{message} Please contact your administrator.',
values: {
message,
},
});
}

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
export const getInitialTransformCapabilities = (initialSetting = false) => ({
canCreateTransform: initialSetting,
canCreateTransformAlerts: initialSetting,
canDeleteIndex: initialSetting,
canDeleteTransform: initialSetting,
canGetTransform: initialSetting,
canPreviewTransform: initialSetting,
canReauthorizeTransform: initialSetting,
canResetTransform: initialSetting,
canScheduleNowTransform: initialSetting,
canStartStopTransform: initialSetting,
canUseTransformAlerts: initialSetting,
});
export const isTransformCapabilities = (arg: unknown): arg is TransformCapabilities => {
return (
isPopulatedObject(arg, Object.keys(getInitialTransformCapabilities())) &&
Object.values(arg).every((d) => typeof d === 'boolean')
);
};
export type TransformCapabilities = ReturnType<typeof getInitialTransformCapabilities>;
export type TransformCapability = keyof TransformCapabilities;
export interface PrivilegesAndCapabilities {
privileges: Privileges;
capabilities: TransformCapabilities;
}
export type Privilege = [string, string];
export interface MissingPrivileges {
[key: string]: string[] | undefined;
}
export interface Privileges {
hasAllPrivileges: boolean;
missingPrivileges: MissingPrivileges;
}

View file

@ -1,15 +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.
*/
export interface MissingPrivileges {
[key: string]: string[] | undefined;
}
export interface Privileges {
hasAllPrivileges: boolean;
missingPrivileges: MissingPrivileges;
}

View file

@ -0,0 +1,85 @@
/*
* 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';
import type { TransformCapabilities } from '../types/capabilities';
// create the text for button's tooltips if the user
// doesn't have the permission to press that button
export function createCapabilityFailureMessage(
capability: keyof TransformCapabilities | 'noTransformNodes'
) {
let message = '';
switch (capability) {
case 'canCreateTransform':
message = i18n.translate('xpack.transform.capability.noPermission.createTransformTooltip', {
defaultMessage: 'You do not have permission to create transforms.',
});
break;
case 'canCreateTransformAlerts':
message = i18n.translate(
'xpack.transform.capability.noPermission.canCreateTransformAlertsTooltip',
{
defaultMessage: 'You do not have permission to create transform alert rules.',
}
);
break;
case 'canScheduleNowTransform':
message = i18n.translate(
'xpack.transform.capability.noPermission.scheduleNowTransformTooltip',
{
defaultMessage:
'You do not have permission to schedule transforms to process data instantly.',
}
);
break;
case 'canStartStopTransform':
message = i18n.translate(
'xpack.transform.capability.noPermission.startOrStopTransformTooltip',
{
defaultMessage: 'You do not have permission to start or stop transforms.',
}
);
break;
case 'canReauthorizeTransform':
message = i18n.translate(
'xpack.transform.capability.noPermission.reauthorizeTransformTooltip',
{
defaultMessage: 'You do not have permission to reauthorize transforms.',
}
);
break;
case 'canDeleteTransform':
message = i18n.translate('xpack.transform.capability.noPermission.deleteTransformTooltip', {
defaultMessage: 'You do not have permission to delete transforms.',
});
break;
case 'canResetTransform':
message = i18n.translate('xpack.transform.capability.noPermission.resetTransformTooltip', {
defaultMessage: 'You do not have permission to reset transforms.',
});
break;
case 'noTransformNodes':
message = i18n.translate('xpack.transform.capability.noPermission.noTransformNodesTooltip', {
defaultMessage: 'There are no transform nodes available.',
});
break;
}
return i18n.translate('xpack.transform.capability.pleaseContactAdministratorTooltip', {
defaultMessage: '{message} Please contact your administrator.',
values: {
message,
},
});
}

View file

@ -5,4 +5,4 @@
* 2.0. * 2.0.
*/ */
export * from './components'; export const toArray = <T>(value: T | T[]): T[] => (Array.isArray(value) ? value : [value]);

View file

@ -6,16 +6,15 @@
*/ */
import { EuiForm, EuiSpacer } from '@elastic/eui'; import { EuiForm, EuiSpacer } from '@elastic/eui';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import React, { FC, useCallback, useEffect, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import type { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; import type { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import type { TransformHealthRuleParams } from '../../../common/types/alerting'; import type { TransformHealthRuleParams } from '../../../common/types/alerting';
import { TestsSelectionControl } from './tests_selection_control'; import { TestsSelectionControl } from './tests_selection_control';
import { TransformSelectorControl } from './transform_selector_control'; import { TransformSelectorControl } from './transform_selector_control';
import { useApi } from '../../app/hooks'; import { useGetTransforms } from '../../app/hooks';
import { useToastNotifications } from '../../app/app_dependencies'; import { useToastNotifications } from '../../app/app_dependencies';
import { GetTransformsResponseSchema } from '../../../common/api_schemas/transforms';
import { ALL_TRANSFORMS_SELECTION } from '../../../common/constants'; import { ALL_TRANSFORMS_SELECTION } from '../../../common/constants';
export type TransformHealthRuleTriggerProps = export type TransformHealthRuleTriggerProps =
@ -29,9 +28,12 @@ const TransformHealthRuleTrigger: FC<TransformHealthRuleTriggerProps> = ({
const formErrors = Object.values(errors).flat(); const formErrors = Object.values(errors).flat();
const isFormInvalid = formErrors.length > 0; const isFormInvalid = formErrors.length > 0;
const api = useApi();
const toast = useToastNotifications(); const toast = useToastNotifications();
const [transformOptions, setTransformOptions] = useState<string[]>([]); const { error, data } = useGetTransforms();
const transformOptions = useMemo(
() => data?.transforms.filter((v) => v.config.sync).map((v) => v.id) ?? [],
[data]
);
const onAlertParamChange = useCallback( const onAlertParamChange = useCallback(
<T extends keyof TransformHealthRuleParams>(param: T) => <T extends keyof TransformHealthRuleParams>(param: T) =>
@ -41,20 +43,9 @@ const TransformHealthRuleTrigger: FC<TransformHealthRuleTriggerProps> = ({
[setRuleParams] [setRuleParams]
); );
useEffect( useEffect(() => {
function fetchTransforms() { if (error !== null) {
let unmounted = false; toast.addError(error, {
api
.getTransforms()
.then((r) => {
if (!unmounted) {
setTransformOptions(
(r as GetTransformsResponseSchema).transforms.filter((v) => v.sync).map((v) => v.id)
);
}
})
.catch((e) => {
toast.addError(e, {
title: i18n.translate( title: i18n.translate(
'xpack.transform.alertingRuleTypes.transformHealth.fetchErrorMessage', 'xpack.transform.alertingRuleTypes.transformHealth.fetchErrorMessage',
{ {
@ -62,13 +53,8 @@ const TransformHealthRuleTrigger: FC<TransformHealthRuleTriggerProps> = ({
} }
), ),
}); });
}); }
return () => { }, [error, toast]);
unmounted = true;
};
},
[api, toast]
);
const excludeTransformOptions = useMemo(() => { const excludeTransformOptions = useMemo(() => {
if (ruleParams.includeTransforms?.some((v) => v === ALL_TRANSFORMS_SELECTION)) { if (ruleParams.includeTransforms?.some((v) => v === ALL_TRANSFORMS_SELECTION)) {

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { useContext, FC } from 'react'; import React, { type FC } from 'react';
import { render, unmountComponentAtNode } from 'react-dom'; import { render, unmountComponentAtNode } from 'react-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@ -13,36 +13,15 @@ import { EuiErrorBoundary } from '@elastic/eui';
import { Router, Routes, Route } from '@kbn/shared-ux-router'; import { Router, Routes, Route } from '@kbn/shared-ux-router';
import { ScopedHistory } from '@kbn/core/public'; import { ScopedHistory } from '@kbn/core/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { addInternalBasePath } from '../../common/constants';
import { SectionError } from './components';
import { SECTION_SLUG } from './common/constants'; import { SECTION_SLUG } from './common/constants';
import { AuthorizationContext, AuthorizationProvider } from './lib/authorization';
import { AppDependencies } from './app_dependencies'; import { AppDependencies } from './app_dependencies';
import { CloneTransformSection } from './sections/clone_transform'; import { CloneTransformSection } from './sections/clone_transform';
import { CreateTransformSection } from './sections/create_transform'; import { CreateTransformSection } from './sections/create_transform';
import { TransformManagementSection } from './sections/transform_management'; import { TransformManagementSection } from './sections/transform_management';
export const App: FC<{ history: ScopedHistory }> = ({ history }) => { export const App: FC<{ history: ScopedHistory }> = ({ history }) => (
const { apiError } = useContext(AuthorizationContext);
if (apiError !== null) {
return (
<SectionError
title={
<FormattedMessage
id="xpack.transform.app.checkingPrivilegesErrorMessage"
defaultMessage="Error fetching user privileges from the server"
/>
}
error={apiError}
/>
);
}
return (
<Router history={history}> <Router history={history}>
<Routes> <Routes>
<Route <Route
@ -57,24 +36,27 @@ export const App: FC<{ history: ScopedHistory }> = ({ history }) => {
</Routes> </Routes>
</Router> </Router>
); );
};
export const renderApp = (element: HTMLElement, appDependencies: AppDependencies) => { export const renderApp = (element: HTMLElement, appDependencies: AppDependencies) => {
const I18nContext = appDependencies.i18n.Context; const I18nContext = appDependencies.i18n.Context;
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
retry: false,
},
},
});
render( render(
<EuiErrorBoundary> <EuiErrorBoundary>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<KibanaThemeProvider theme$={appDependencies.theme.theme$}> <KibanaThemeProvider theme$={appDependencies.theme.theme$}>
<KibanaContextProvider services={appDependencies}> <KibanaContextProvider services={appDependencies}>
<AuthorizationProvider
privilegesEndpoint={{ path: addInternalBasePath(`privileges`), version: '1' }}
>
<I18nContext> <I18nContext>
<App history={appDependencies.history} /> <App history={appDependencies.history} />
</I18nContext> </I18nContext>
</AuthorizationProvider>
</KibanaContextProvider> </KibanaContextProvider>
</KibanaThemeProvider> </KibanaThemeProvider>
</QueryClientProvider> </QueryClientProvider>

View file

@ -19,12 +19,7 @@ export {
toggleSelectedField, toggleSelectedField,
} from './fields'; } from './fields';
export type { DropDownLabel, DropDownOption, Label } from './dropdown'; export type { DropDownLabel, DropDownOption, Label } from './dropdown';
export { export { isTransformIdValid } from './transform';
isTransformIdValid,
refreshTransformList$,
useRefreshTransformList,
REFRESH_TRANSFORM_LIST_STATE,
} from './transform';
export type { TransformListAction, TransformListRow } from './transform_list'; export type { TransformListAction, TransformListRow } from './transform_list';
export { TRANSFORM_LIST_COLUMN } from './transform_list'; export { TRANSFORM_LIST_COLUMN } from './transform_list';
export { getTransformProgress, isCompletedBatchTransform } from './transform_stats'; export { getTransformProgress, isCompletedBatchTransform } from './transform_stats';

View file

@ -5,7 +5,8 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { SECTION_SLUG } from './constants'; import { SECTION_SLUG } from './constants';

View file

@ -5,11 +5,8 @@
* 2.0. * 2.0.
*/ */
import { useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';
import { filter, distinctUntilChanged } from 'rxjs/operators';
import { Subscription } from 'rxjs';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import type { TransformConfigUnion, TransformId } from '../../../common/types/transform'; import type { TransformConfigUnion, TransformId } from '../../../common/types/transform';
// Via https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/utils/TransformStrings.java#L24 // Via https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/utils/TransformStrings.java#L24
@ -23,64 +20,6 @@ export function isTransformIdValid(transformId: TransformId) {
export const TRANSFORM_ERROR_TYPE = { export const TRANSFORM_ERROR_TYPE = {
DANGLING_TASK: 'dangling_task', DANGLING_TASK: 'dangling_task',
} as const; } as const;
export enum REFRESH_TRANSFORM_LIST_STATE {
ERROR = 'error',
IDLE = 'idle',
LOADING = 'loading',
REFRESH = 'refresh',
}
export const refreshTransformList$ = new BehaviorSubject<REFRESH_TRANSFORM_LIST_STATE>(
REFRESH_TRANSFORM_LIST_STATE.IDLE
);
export const useRefreshTransformList = (
callback: {
isLoading?(d: boolean): void;
onRefresh?(): void;
} = {}
) => {
useEffect(() => {
const distinct$ = refreshTransformList$.pipe(distinctUntilChanged());
const subscriptions: Subscription[] = [];
if (typeof callback.onRefresh === 'function') {
// initial call to refresh
callback.onRefresh();
subscriptions.push(
distinct$
.pipe(filter((state) => state === REFRESH_TRANSFORM_LIST_STATE.REFRESH))
.subscribe(() => typeof callback.onRefresh === 'function' && callback.onRefresh())
);
}
if (typeof callback.isLoading === 'function') {
subscriptions.push(
distinct$.subscribe(
(state) =>
typeof callback.isLoading === 'function' &&
callback.isLoading(state === REFRESH_TRANSFORM_LIST_STATE.LOADING)
)
);
}
return () => {
subscriptions.map((sub) => sub.unsubscribe());
};
// The effect should only be called once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
refresh: () => {
// A refresh is followed immediately by setting the state to loading
// to trigger data fetching and loading indicators in one go.
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH);
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.LOADING);
},
};
};
export const overrideTransformForCloning = (originalConfig: TransformConfigUnion) => { export const overrideTransformForCloning = (originalConfig: TransformConfigUnion) => {
// 'Managed' means job is preconfigured and deployed by other solutions // 'Managed' means job is preconfigured and deployed by other solutions

View file

@ -0,0 +1,59 @@
/*
* 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 FC } from 'react';
import { EuiFlexItem, EuiFlexGroup, EuiPageTemplate, EuiEmptyPrompt } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { TransformCapability } from '../../../common/types/capabilities';
import { toArray } from '../../../common/utils/to_array';
import { useTransformCapabilities } from '../hooks';
interface Props {
title: React.ReactNode;
message: React.ReactNode | string;
}
export const NotAuthorizedSection = ({ title, message }: Props) => (
<EuiEmptyPrompt iconType="securityApp" title={<h2>{title}</h2>} body={<p>{message}</p>} />
);
const MissingCapabilities: FC = () => (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiPageTemplate.EmptyPrompt color="danger">
<NotAuthorizedSection
title={
<FormattedMessage
id="xpack.transform.app.missingCapabilitiesTitle"
defaultMessage="Missing permission"
/>
}
message={
<FormattedMessage
id="xpack.transform.app.missingCapabilitiesDescription"
defaultMessage="You're missing permissions to use this section of Transforms."
/>
}
/>
</EuiPageTemplate.EmptyPrompt>
</EuiFlexItem>
</EuiFlexGroup>
);
export const CapabilitiesWrapper: FC<{
requiredCapabilities: TransformCapability | TransformCapability[];
}> = ({ children, requiredCapabilities }) => {
const capabilities = useTransformCapabilities();
const hasCapabilities = toArray(requiredCapabilities).every((c) => capabilities[c]);
return hasCapabilities ? <>{children}</> : <MissingCapabilities />;
};

View file

@ -5,6 +5,4 @@
* 2.0. * 2.0.
*/ */
export { SectionError } from './section_error';
export { SectionLoading } from './section_loading';
export { ToastNotificationText } from './toast_notification_text'; export { ToastNotificationText } from './toast_notification_text';

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { EuiIcon, EuiToolTip } from '@elastic/eui'; import { EuiIcon, EuiToolTip } from '@elastic/eui';
import { AuditMessageBase } from '../../../common/types/messages'; import { AuditMessageBase } from '../../../common/types/messages';

View file

@ -1,38 +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 { EuiPageTemplate } from '@elastic/eui';
interface Props {
title: React.ReactNode;
error: Error | null;
actions?: JSX.Element;
}
export const SectionError: React.FunctionComponent<Props> = ({
title,
error,
actions,
...rest
}) => {
const errorMessage = error?.message ?? JSON.stringify(error, null, 2);
return (
<EuiPageTemplate.EmptyPrompt
color={'danger'}
iconType="warning"
title={<h2>{title}</h2>}
body={
<p>
<pre>{errorMessage}</pre>
{actions ? actions : null}
</p>
}
/>
);
};

View file

@ -1,48 +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 {
EuiEmptyPrompt,
EuiLoadingSpinner,
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiTextColor,
} from '@elastic/eui';
interface Props {
inline?: boolean;
children: React.ReactNode;
[key: string]: any;
}
export const SectionLoading: React.FunctionComponent<Props> = ({ inline, children, ...rest }) => {
if (inline) {
return (
<EuiFlexGroup justifyContent="flexStart" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText {...rest}>
<EuiTextColor color="subdued">{children}</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiEmptyPrompt
title={<EuiLoadingSpinner size="xl" />}
body={<EuiText color="subdued">{children}</EuiText>}
data-test-subj="sectionLoading"
/>
);
};

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { import {
EuiButtonEmpty, EuiButtonEmpty,
@ -19,41 +19,45 @@ import {
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { CoreStart } from '@kbn/core/public'; import { toMountPoint } from '@kbn/react-kibana-mount';
import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { useAppDependencies } from '../app_dependencies';
const MAX_SIMPLE_MESSAGE_LENGTH = 140; const MAX_SIMPLE_MESSAGE_LENGTH = 140;
// Because of the use of `toMountPoint`, `useKibanaContext` doesn't work via `useAppDependencies`.
// That's why we need to pass in `overlays` as a prop cannot get it via context.
interface ToastNotificationTextProps { interface ToastNotificationTextProps {
overlays: CoreStart['overlays'];
theme: CoreStart['theme'];
text: any; text: any;
previewTextLength?: number; previewTextLength?: number;
inline?: boolean;
forceModal?: boolean;
} }
export const ToastNotificationText: FC<ToastNotificationTextProps> = ({ export const ToastNotificationText: FC<ToastNotificationTextProps> = ({
overlays,
text, text,
theme,
previewTextLength, previewTextLength,
inline = false,
forceModal = false,
}) => { }) => {
if (typeof text === 'string' && text.length <= MAX_SIMPLE_MESSAGE_LENGTH) { const { overlays, theme, i18n: i18nStart } = useAppDependencies();
if (!forceModal && typeof text === 'string' && text.length <= MAX_SIMPLE_MESSAGE_LENGTH) {
return text; return text;
} }
if ( if (
!forceModal &&
typeof text === 'object' && typeof text === 'object' &&
text !== null &&
typeof text.message === 'string' && typeof text.message === 'string' &&
text.message.length <= MAX_SIMPLE_MESSAGE_LENGTH text.message.length <= MAX_SIMPLE_MESSAGE_LENGTH
) { ) {
return text.message; return text.message;
} }
const unformattedText = text.message ? text.message : text; const unformattedText =
const formattedText = typeof unformattedText === 'object' ? JSON.stringify(text, null, 2) : text; typeof text === 'object' && text !== null && text.message ? text.message : text;
const formattedText =
typeof unformattedText === 'object' ? JSON.stringify(text, null, 2) : unformattedText;
const textLength = previewTextLength ?? 140; const textLength = previewTextLength ?? 140;
const previewText = `${formattedText.substring(0, textLength)}${ const previewText = `${formattedText.substring(0, textLength)}${
formattedText.length > textLength ? ' ...' : '' formattedText.length > textLength ? ' ...' : ''
@ -83,15 +87,19 @@ export const ToastNotificationText: FC<ToastNotificationTextProps> = ({
</EuiButtonEmpty> </EuiButtonEmpty>
</EuiModalFooter> </EuiModalFooter>
</EuiModal>, </EuiModal>,
{ theme$: theme.theme$ } { theme, i18n: i18nStart }
) )
); );
}; };
return ( return (
<> <>
<pre>{previewText}</pre> {!inline && <pre>{previewText}</pre>}
<EuiButtonEmpty onClick={openModal}> <EuiButtonEmpty
onClick={openModal}
css={inline ? { blockSize: 0 } : {}}
size={inline ? 's' : undefined}
>
{i18n.translate('xpack.transform.toastText.openModalButtonText', { {i18n.translate('xpack.transform.toastText.openModalButtonText', {
defaultMessage: 'View details', defaultMessage: 'View details',
})} })}

View file

@ -1,150 +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 type { IHttpFetchError } from '@kbn/core-http-browser';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { DEFAULT_SAMPLER_SHARD_SIZE } from '@kbn/ml-agg-utils';
import type { TransformId } from '../../../../common/types/transform';
import type { FieldHistogramsResponseSchema } from '../../../../common/api_schemas/field_histograms';
import type { GetTransformsAuditMessagesResponseSchema } from '../../../../common/api_schemas/audit_messages';
import type {
DeleteTransformsRequestSchema,
DeleteTransformsResponseSchema,
} from '../../../../common/api_schemas/delete_transforms';
import type {
StartTransformsRequestSchema,
StartTransformsResponseSchema,
} from '../../../../common/api_schemas/start_transforms';
import type {
StopTransformsRequestSchema,
StopTransformsResponseSchema,
} from '../../../../common/api_schemas/stop_transforms';
import type {
GetTransformsResponseSchema,
PostTransformsPreviewRequestSchema,
PostTransformsPreviewResponseSchema,
PutTransformsRequestSchema,
PutTransformsResponseSchema,
} from '../../../../common/api_schemas/transforms';
import type { GetTransformsStatsResponseSchema } from '../../../../common/api_schemas/transforms_stats';
import type {
PostTransformsUpdateRequestSchema,
PostTransformsUpdateResponseSchema,
} from '../../../../common/api_schemas/update_transforms';
import type { EsIndex } from '../../../../common/types/es_index';
import type { SavedSearchQuery } from '../use_search_items';
export interface FieldHistogramRequestConfig {
fieldName: string;
type?: KBN_FIELD_TYPES;
}
const apiFactory = () => ({
async getTransform(
transformId: TransformId
): Promise<GetTransformsResponseSchema | IHttpFetchError> {
return Promise.resolve({ count: 0, transforms: [] });
},
async getTransforms(): Promise<GetTransformsResponseSchema | IHttpFetchError> {
return Promise.resolve({ count: 0, transforms: [] });
},
async getTransformStats(
transformId: TransformId
): Promise<GetTransformsStatsResponseSchema | IHttpFetchError> {
return Promise.resolve({ count: 0, transforms: [] });
},
async getTransformsStats(): Promise<GetTransformsStatsResponseSchema | IHttpFetchError> {
return Promise.resolve({ count: 0, transforms: [] });
},
async createTransform(
transformId: TransformId,
transformConfig: PutTransformsRequestSchema
): Promise<PutTransformsResponseSchema | IHttpFetchError> {
return Promise.resolve({ transformsCreated: [], errors: [] });
},
async updateTransform(
transformId: TransformId,
transformConfig: PostTransformsUpdateRequestSchema
): Promise<PostTransformsUpdateResponseSchema | IHttpFetchError> {
return Promise.resolve({
id: 'the-test-id',
source: { index: ['the-index-name'], query: { match_all: {} } },
dest: { index: 'user-the-destination-index-name' },
frequency: '10m',
pivot: {
group_by: { the_group: { terms: { field: 'the-group-by-field' } } },
aggregations: { the_agg: { value_count: { field: 'the-agg-field' } } },
},
description: 'the-description',
settings: { docs_per_second: null },
version: '8.0.0',
create_time: 1598860879097,
});
},
async deleteTransforms(
reqBody: DeleteTransformsRequestSchema
): Promise<DeleteTransformsResponseSchema | IHttpFetchError> {
return Promise.resolve({});
},
async getTransformsPreview(
obj: PostTransformsPreviewRequestSchema
): Promise<PostTransformsPreviewResponseSchema | IHttpFetchError> {
return Promise.resolve({
generated_dest_index: {
mappings: {
_meta: {
_transform: {
transform: 'the-transform',
version: { create: 'the-version' },
creation_date_in_millis: 0,
},
created_by: 'mock',
},
properties: {},
},
settings: { index: { number_of_shards: '1', auto_expand_replicas: '0-1' } },
aliases: {},
},
preview: [],
});
},
async startTransforms(
reqBody: StartTransformsRequestSchema
): Promise<StartTransformsResponseSchema | IHttpFetchError> {
return Promise.resolve({});
},
async stopTransforms(
transformsInfo: StopTransformsRequestSchema
): Promise<StopTransformsResponseSchema | IHttpFetchError> {
return Promise.resolve({});
},
async getTransformAuditMessages(
transformId: TransformId
): Promise<GetTransformsAuditMessagesResponseSchema | IHttpFetchError> {
return Promise.resolve({ messages: [], total: 0 });
},
async getEsIndices(): Promise<EsIndex[] | IHttpFetchError> {
return Promise.resolve([]);
},
async getHistogramsForFields(
dataViewTitle: string,
fields: FieldHistogramRequestConfig[],
query: string | SavedSearchQuery,
samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE
): Promise<FieldHistogramsResponseSchema | IHttpFetchError> {
return Promise.resolve([]);
},
});
export const useApi = () => {
return apiFactory();
};

View file

@ -5,10 +5,23 @@
* 2.0. * 2.0.
*/ */
export { useApi } from './use_api'; export { useCreateTransform } from './use_create_transform';
export { useDocumentationLinks } from './use_documentation_links';
export { useGetDataViewTitles } from './use_get_data_view_titles';
export { useGetEsIndices } from './use_get_es_indices';
export { useGetEsIngestPipelines } from './use_get_es_ingest_pipelines';
export { useGetTransformAuditMessages } from './use_get_transform_audit_messages';
export { useGetTransform } from './use_get_transform';
export { useGetTransformNodes } from './use_get_transform_nodes';
export { useGetTransforms } from './use_get_transforms'; export { useGetTransforms } from './use_get_transforms';
export { useGetTransformsPreview } from './use_get_transforms_preview';
export { useGetTransformStats } from './use_get_transform_stats';
export { useDeleteTransforms, useDeleteIndexAndTargetIndex } from './use_delete_transform'; export { useDeleteTransforms, useDeleteIndexAndTargetIndex } from './use_delete_transform';
export { useRefreshTransformList } from './use_refresh_transform_list';
export { useResetTransforms } from './use_reset_transform'; export { useResetTransforms } from './use_reset_transform';
export { useSearchItems } from './use_search_items';
export { useScheduleNowTransforms } from './use_schedule_now_transform'; export { useScheduleNowTransforms } from './use_schedule_now_transform';
export { useStartTransforms } from './use_start_transform'; export { useStartTransforms } from './use_start_transform';
export { useStopTransforms } from './use_stop_transform'; export { useStopTransforms } from './use_stop_transform';
export { useTransformCapabilities } from './use_transform_capabilities';
export { useUpdateTransform } from './use_update_transform';

View file

@ -1,301 +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 { useMemo } from 'react';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { DEFAULT_SAMPLER_SHARD_SIZE } from '@kbn/ml-agg-utils';
import {
ReauthorizeTransformsRequestSchema,
ReauthorizeTransformsResponseSchema,
} from '../../../common/api_schemas/reauthorize_transforms';
import type { GetTransformsAuditMessagesResponseSchema } from '../../../common/api_schemas/audit_messages';
import type {
DeleteTransformsRequestSchema,
DeleteTransformsResponseSchema,
} from '../../../common/api_schemas/delete_transforms';
import type {
FieldHistogramsRequestSchema,
FieldHistogramsResponseSchema,
} from '../../../common/api_schemas/field_histograms';
import type {
ResetTransformsRequestSchema,
ResetTransformsResponseSchema,
} from '../../../common/api_schemas/reset_transforms';
import type {
StartTransformsRequestSchema,
StartTransformsResponseSchema,
} from '../../../common/api_schemas/start_transforms';
import type {
StopTransformsRequestSchema,
StopTransformsResponseSchema,
} from '../../../common/api_schemas/stop_transforms';
import type {
ScheduleNowTransformsRequestSchema,
ScheduleNowTransformsResponseSchema,
} from '../../../common/api_schemas/schedule_now_transforms';
import type {
GetTransformNodesResponseSchema,
GetTransformsResponseSchema,
PostTransformsPreviewRequestSchema,
PostTransformsPreviewResponseSchema,
PutTransformsRequestSchema,
PutTransformsResponseSchema,
} from '../../../common/api_schemas/transforms';
import type {
PostTransformsUpdateRequestSchema,
PostTransformsUpdateResponseSchema,
} from '../../../common/api_schemas/update_transforms';
import type { GetTransformsStatsResponseSchema } from '../../../common/api_schemas/transforms_stats';
import type { TransformId } from '../../../common/types/transform';
import { addInternalBasePath } from '../../../common/constants';
import type { EsIndex } from '../../../common/types/es_index';
import type { EsIngestPipeline } from '../../../common/types/es_ingest_pipeline';
import { useAppDependencies } from '../app_dependencies';
import type { SavedSearchQuery } from './use_search_items';
export interface FieldHistogramRequestConfig {
fieldName: string;
type?: KBN_FIELD_TYPES;
}
interface FetchOptions {
asSystemRequest?: boolean;
}
export const useApi = () => {
const { http } = useAppDependencies();
return useMemo(
() => ({
async getTransformNodes(): Promise<GetTransformNodesResponseSchema | IHttpFetchError> {
try {
return await http.get(addInternalBasePath(`transforms/_nodes`), { version: '1' });
} catch (e) {
return e;
}
},
async getTransform(
transformId: TransformId
): Promise<GetTransformsResponseSchema | IHttpFetchError> {
try {
return await http.get(addInternalBasePath(`transforms/${transformId}`), {
version: '1',
});
} catch (e) {
return e;
}
},
async getTransforms(
fetchOptions: FetchOptions = {}
): Promise<GetTransformsResponseSchema | IHttpFetchError> {
try {
return await http.get(addInternalBasePath(`transforms`), {
...fetchOptions,
version: '1',
});
} catch (e) {
return e;
}
},
async getTransformStats(
transformId: TransformId
): Promise<GetTransformsStatsResponseSchema | IHttpFetchError> {
try {
return await http.get(addInternalBasePath(`transforms/${transformId}/_stats`), {
version: '1',
});
} catch (e) {
return e;
}
},
async getTransformsStats(
fetchOptions: FetchOptions = {}
): Promise<GetTransformsStatsResponseSchema | IHttpFetchError> {
try {
return await http.get(addInternalBasePath(`transforms/_stats`), {
...fetchOptions,
version: '1',
});
} catch (e) {
return e;
}
},
async createTransform(
transformId: TransformId,
transformConfig: PutTransformsRequestSchema
): Promise<PutTransformsResponseSchema | IHttpFetchError> {
try {
return await http.put(addInternalBasePath(`transforms/${transformId}`), {
body: JSON.stringify(transformConfig),
version: '1',
});
} catch (e) {
return e;
}
},
async updateTransform(
transformId: TransformId,
transformConfig: PostTransformsUpdateRequestSchema
): Promise<PostTransformsUpdateResponseSchema | IHttpFetchError> {
try {
return await http.post(addInternalBasePath(`transforms/${transformId}/_update`), {
body: JSON.stringify(transformConfig),
version: '1',
});
} catch (e) {
return e;
}
},
async deleteTransforms(
reqBody: DeleteTransformsRequestSchema
): Promise<DeleteTransformsResponseSchema | IHttpFetchError> {
try {
return await http.post(addInternalBasePath(`delete_transforms`), {
body: JSON.stringify(reqBody),
version: '1',
});
} catch (e) {
return e;
}
},
async getTransformsPreview(
obj: PostTransformsPreviewRequestSchema
): Promise<PostTransformsPreviewResponseSchema | IHttpFetchError> {
try {
return await http.post(addInternalBasePath(`transforms/_preview`), {
body: JSON.stringify(obj),
version: '1',
});
} catch (e) {
return e;
}
},
async reauthorizeTransforms(
reqBody: ReauthorizeTransformsRequestSchema
): Promise<ReauthorizeTransformsResponseSchema | IHttpFetchError> {
try {
return await http.post(addInternalBasePath(`reauthorize_transforms`), {
body: JSON.stringify(reqBody),
version: '1',
});
} catch (e) {
return e;
}
},
async resetTransforms(
reqBody: ResetTransformsRequestSchema
): Promise<ResetTransformsResponseSchema | IHttpFetchError> {
try {
return await http.post(addInternalBasePath(`reset_transforms`), {
body: JSON.stringify(reqBody),
version: '1',
});
} catch (e) {
return e;
}
},
async startTransforms(
reqBody: StartTransformsRequestSchema
): Promise<StartTransformsResponseSchema | IHttpFetchError> {
try {
return await http.post(addInternalBasePath(`start_transforms`), {
body: JSON.stringify(reqBody),
version: '1',
});
} catch (e) {
return e;
}
},
async stopTransforms(
transformsInfo: StopTransformsRequestSchema
): Promise<StopTransformsResponseSchema | IHttpFetchError> {
try {
return await http.post(addInternalBasePath(`stop_transforms`), {
body: JSON.stringify(transformsInfo),
version: '1',
});
} catch (e) {
return e;
}
},
async scheduleNowTransforms(
transformsInfo: ScheduleNowTransformsRequestSchema
): Promise<ScheduleNowTransformsResponseSchema | IHttpFetchError> {
try {
return await http.post(addInternalBasePath(`schedule_now_transforms`), {
body: JSON.stringify(transformsInfo),
version: '1',
});
} catch (e) {
return e;
}
},
async getTransformAuditMessages(
transformId: TransformId,
sortField: string,
sortDirection: 'asc' | 'desc'
): Promise<
{ messages: GetTransformsAuditMessagesResponseSchema; total: number } | IHttpFetchError
> {
try {
return await http.get(addInternalBasePath(`transforms/${transformId}/messages`), {
query: {
sortField,
sortDirection,
},
version: '1',
});
} catch (e) {
return e;
}
},
async getEsIndices(): Promise<EsIndex[] | IHttpFetchError> {
try {
return await http.get(`/api/index_management/indices`, { version: '1' });
} catch (e) {
return e;
}
},
async getEsIngestPipelines(): Promise<EsIngestPipeline[] | IHttpFetchError> {
try {
return await http.get('/api/ingest_pipelines', { version: '1' });
} catch (e) {
return e;
}
},
async getHistogramsForFields(
dataViewTitle: string,
fields: FieldHistogramRequestConfig[],
query: string | SavedSearchQuery,
runtimeMappings?: FieldHistogramsRequestSchema['runtimeMappings'],
samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE
): Promise<FieldHistogramsResponseSchema | IHttpFetchError> {
try {
return await http.post(addInternalBasePath(`field_histograms/${dataViewTitle}`), {
body: JSON.stringify({
query,
fields,
samplerShardSize,
...(runtimeMappings !== undefined ? { runtimeMappings } : {}),
}),
version: '1',
});
} catch (e) {
return e;
}
},
}),
[http]
);
};

View file

@ -0,0 +1,71 @@
/*
* 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 { useMutation } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import type {
PutTransformsRequestSchema,
PutTransformsResponseSchema,
} from '../../../common/api_schemas/transforms';
import { addInternalBasePath } from '../../../common/constants';
import type { TransformId } from '../../../common/types/transform';
import { getErrorMessage } from '../../../common/utils/errors';
import { useAppDependencies, useToastNotifications } from '../app_dependencies';
import { ToastNotificationText } from '../components';
import { useRefreshTransformList } from './use_refresh_transform_list';
interface CreateTransformArgs {
transformId: TransformId;
transformConfig: PutTransformsRequestSchema;
}
export const useCreateTransform = () => {
const { http, i18n: i18nStart, theme } = useAppDependencies();
const refreshTransformList = useRefreshTransformList();
const toastNotifications = useToastNotifications();
function errorToast(error: unknown, { transformId }: CreateTransformArgs) {
toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepCreateForm.createTransformErrorMessage', {
defaultMessage: 'An error occurred creating the transform {transformId}:',
values: { transformId },
}),
text: toMountPoint(<ToastNotificationText text={getErrorMessage(error)} />, {
theme,
i18n: i18nStart,
}),
});
}
const mutation = useMutation({
mutationFn: ({ transformId, transformConfig }: CreateTransformArgs) => {
return http.put<PutTransformsResponseSchema>(
addInternalBasePath(`transforms/${transformId}`),
{
body: JSON.stringify(transformConfig),
version: '1',
}
);
},
onError: errorToast,
onSuccess: (resp, options) => {
if (resp.errors.length > 0) {
errorToast(resp.errors.length === 1 ? resp.errors[0] : resp.errors, options);
}
refreshTransformList();
},
});
return mutation.mutate;
};

View file

@ -5,37 +5,37 @@
* 2.0. * 2.0.
*/ */
import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { IKibanaSearchRequest } from '@kbn/data-plugin/common'; import type { IKibanaSearchRequest } from '@kbn/data-plugin/common';
import { TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import { useAppDependencies } from '../app_dependencies'; import { useAppDependencies } from '../app_dependencies';
export const useDataSearch = () => { export const useDataSearch = (
esSearchRequestParams: IKibanaSearchRequest['params'],
enabled?: boolean
) => {
const { data } = useAppDependencies(); const { data } = useAppDependencies();
return useCallback( return useQuery<estypes.SearchResponse>(
async (esSearchRequestParams: IKibanaSearchRequest['params'], abortSignal?: AbortSignal) => { [TRANSFORM_REACT_QUERY_KEYS.DATA_SEARCH, esSearchRequestParams],
try { async ({ signal }) => {
const { rawResponse: resp } = await lastValueFrom( const { rawResponse: resp } = await lastValueFrom(
data.search.search( data.search.search(
{ {
params: esSearchRequestParams, params: esSearchRequestParams,
}, },
{ abortSignal } { abortSignal: signal }
) )
); );
return resp; return resp;
} catch (error) {
if (error.name === 'AbortError') {
// ignore abort errors
} else {
return error;
}
}
}, },
[data] { enabled }
); );
}; };

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { ErrorType } from '@kbn/ml-error-utils';
import { TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import { useAppDependencies } from '../app_dependencies';
import type { TransformListRow } from '../common';
export const useDataViewExists = (items: TransformListRow[]) => {
const {
data: { dataViews: dataViewsContract },
} = useAppDependencies();
return useQuery<boolean, ErrorType>(
[TRANSFORM_REACT_QUERY_KEYS.DATA_VIEW_EXISTS, items],
async () => {
if (items.length !== 1) {
return false;
}
const config = items[0].config;
const indexName = Array.isArray(config.dest.index) ? config.dest.index[0] : config.dest.index;
if (indexName === undefined) {
return false;
}
return (await dataViewsContract.find(indexName)).some(({ title }) => title === indexName);
}
);
};

View file

@ -6,34 +6,40 @@
*/ */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { toMountPoint } from '@kbn/react-kibana-mount';
import { extractErrorMessage } from '@kbn/ml-error-utils'; import { extractErrorMessage } from '@kbn/ml-error-utils';
import { addInternalBasePath } from '../../../common/constants';
import type { import type {
DeleteTransformStatus,
DeleteTransformsRequestSchema, DeleteTransformsRequestSchema,
DeleteTransformsResponseSchema,
} from '../../../common/api_schemas/delete_transforms'; } from '../../../common/api_schemas/delete_transforms';
import { isDeleteTransformsResponseSchema } from '../../../common/api_schemas/type_guards';
import { getErrorMessage } from '../../../common/utils/errors'; import { getErrorMessage } from '../../../common/utils/errors';
import { useAppDependencies, useToastNotifications } from '../app_dependencies'; import { useAppDependencies, useToastNotifications } from '../app_dependencies';
import { REFRESH_TRANSFORM_LIST_STATE, refreshTransformList$, TransformListRow } from '../common'; import { type TransformListRow } from '../common';
import { ToastNotificationText } from '../components'; import { ToastNotificationText } from '../components';
import { useApi } from './use_api';
import { indexService } from '../services/es_index_service'; import { useTransformCapabilities } from './use_transform_capabilities';
import { useDataViewExists } from './use_data_view_exists';
import { useRefreshTransformList } from './use_refresh_transform_list';
export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => {
const { const {
http,
data: { dataViews: dataViewsContract },
application: { capabilities }, application: { capabilities },
} = useAppDependencies(); } = useAppDependencies();
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const { canDeleteIndex: userCanDeleteIndex } = useTransformCapabilities();
const userCanDeleteDataView =
capabilities.savedObjectsManagement?.delete === true ||
capabilities.indexPatterns?.save === true;
const [deleteDestIndex, setDeleteDestIndex] = useState<boolean>(true); const [deleteDestIndex, setDeleteDestIndex] = useState<boolean>(true);
const [deleteDataView, setDeleteDataView] = useState<boolean>(true); const [deleteDataView, setDeleteDataView] = useState<boolean>(userCanDeleteDataView);
const [userCanDeleteIndex, setUserCanDeleteIndex] = useState<boolean>(false);
const [dataViewExists, setDataViewExists] = useState<boolean>(false);
const [userCanDeleteDataView, setUserCanDeleteDataView] = useState<boolean>(false);
const toggleDeleteIndex = useCallback( const toggleDeleteIndex = useCallback(
() => setDeleteDestIndex(!deleteDestIndex), () => setDeleteDestIndex(!deleteDestIndex),
@ -43,67 +49,31 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => {
() => setDeleteDataView(!deleteDataView), () => setDeleteDataView(!deleteDataView),
[deleteDataView] [deleteDataView]
); );
const checkDataViewExists = useCallback(
async (indexName: string) => { const { error: dataViewExistsError, data: dataViewExists = items.length !== 1 } =
try { useDataViewExists(items);
const dvExists = await indexService.dataViewExists(dataViewsContract, indexName);
setDataViewExists(dvExists); useEffect(() => {
} catch (e) { if (dataViewExistsError !== null && items.length === 1) {
const error = extractErrorMessage(e); const config = items[0].config;
const indexName = Array.isArray(config.dest.index) ? config.dest.index[0] : config.dest.index;
toastNotifications.addDanger( toastNotifications.addDanger(
i18n.translate( i18n.translate(
'xpack.transform.deleteTransform.errorWithCheckingIfDataViewExistsNotificationErrorMessage', 'xpack.transform.deleteTransform.errorWithCheckingIfDataViewExistsNotificationErrorMessage',
{ {
defaultMessage: 'An error occurred checking if data view {dataView} exists: {error}', defaultMessage: 'An error occurred checking if data view {dataView} exists: {error}',
values: { dataView: indexName, error }, values: {
} dataView: indexName,
) error: extractErrorMessage(dataViewExistsError),
);
}
}, },
[dataViewsContract, toastNotifications]
);
const checkUserIndexPermission = useCallback(async () => {
try {
const userCanDelete = await indexService.canDeleteIndex(http);
if (userCanDelete) {
setUserCanDeleteIndex(true);
}
const canDeleteDataView =
capabilities.savedObjectsManagement.delete === true ||
capabilities.indexPatterns.save === true;
setUserCanDeleteDataView(canDeleteDataView);
if (canDeleteDataView === false) {
setDeleteDataView(false);
}
} catch (e) {
toastNotifications.addDanger(
i18n.translate(
'xpack.transform.transformList.errorWithCheckingIfUserCanDeleteIndexNotificationErrorMessage',
{
defaultMessage: 'An error occurred checking if user can delete destination index',
} }
) )
); );
} }
}, [http, toastNotifications, capabilities]); // custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { }, [dataViewExistsError]);
checkUserIndexPermission();
// if user only deleting one transform
if (items.length === 1) {
const config = items[0].config;
const destinationIndex = Array.isArray(config.dest.index)
? config.dest.index[0]
: config.dest.index;
checkDataViewExists(destinationIndex);
} else {
setDataViewExists(true);
}
}, [checkDataViewExists, checkUserIndexPermission, items]);
return { return {
userCanDeleteIndex, userCanDeleteIndex,
@ -116,86 +86,34 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => {
}; };
}; };
type SuccessCountField = keyof Omit<DeleteTransformStatus, 'destinationIndex'>;
export const useDeleteTransforms = () => { export const useDeleteTransforms = () => {
const { overlays, theme } = useAppDependencies(); const { http, i18n: i18nStart, theme } = useAppDependencies();
const refreshTransformList = useRefreshTransformList();
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const api = useApi();
return async (reqBody: DeleteTransformsRequestSchema) => { const mutation = useMutation({
const results = await api.deleteTransforms(reqBody); mutationFn: (reqBody: DeleteTransformsRequestSchema) =>
http.post<DeleteTransformsResponseSchema>(addInternalBasePath('delete_transforms'), {
if (!isDeleteTransformsResponseSchema(results)) { body: JSON.stringify(reqBody),
version: '1',
}),
onError: (error) =>
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate('xpack.transform.transformList.deleteTransformGenericErrorMessage', { title: i18n.translate('xpack.transform.transformList.deleteTransformGenericErrorMessage', {
defaultMessage: 'An error occurred calling the API endpoint to delete transforms.', defaultMessage: 'An error occurred calling the API endpoint to delete transforms.',
}), }),
text: toMountPoint( text: toMountPoint(
<ToastNotificationText <ToastNotificationText previewTextLength={50} text={getErrorMessage(error)} />,
previewTextLength={50} { theme, i18n: i18nStart }
overlays={overlays}
theme={theme}
text={getErrorMessage(results)}
/>,
{ theme$: theme.theme$ }
), ),
}); }),
return; onSuccess: (results) => {
}
const isBulk = Object.keys(results).length > 1;
const successCount: Record<SuccessCountField, number> = {
transformDeleted: 0,
destIndexDeleted: 0,
destDataViewDeleted: 0,
};
for (const transformId in results) { for (const transformId in results) {
// hasOwnProperty check to ensure only properties on object itself, and not its prototypes // hasOwnProperty check to ensure only properties on object itself, and not its prototypes
if (results.hasOwnProperty(transformId)) { if (results.hasOwnProperty(transformId)) {
const status = results[transformId]; const status = results[transformId];
const destinationIndex = status.destinationIndex; const destinationIndex = status.destinationIndex;
// if we are only deleting one transform, show the success toast messages
if (!isBulk && status.transformDeleted) {
if (status.transformDeleted?.success) {
toastNotifications.addSuccess(
i18n.translate('xpack.transform.transformList.deleteTransformSuccessMessage', {
defaultMessage: 'Request to delete transform {transformId} acknowledged.',
values: { transformId },
})
);
}
if (status.destIndexDeleted?.success) {
toastNotifications.addSuccess(
i18n.translate(
'xpack.transform.deleteTransform.deleteAnalyticsWithIndexSuccessMessage',
{
defaultMessage:
'Request to delete destination index {destinationIndex} acknowledged.',
values: { destinationIndex },
}
)
);
}
if (status.destDataViewDeleted?.success) {
toastNotifications.addSuccess(
i18n.translate(
'xpack.transform.deleteTransform.deleteAnalyticsWithDataViewSuccessMessage',
{
defaultMessage: 'Request to delete data view {destinationIndex} acknowledged.',
values: { destinationIndex },
}
)
);
}
} else {
(Object.keys(successCount) as SuccessCountField[]).forEach((key) => {
if (status[key]?.success) {
successCount[key] = successCount[key] + 1;
}
});
}
if (status.transformDeleted?.error) { if (status.transformDeleted?.error) {
const error = status.transformDeleted.error.reason; const error = status.transformDeleted.error.reason;
toastNotifications.addDanger({ toastNotifications.addDanger({
@ -203,15 +121,10 @@ export const useDeleteTransforms = () => {
defaultMessage: 'An error occurred deleting the transform {transformId}', defaultMessage: 'An error occurred deleting the transform {transformId}',
values: { transformId }, values: { transformId },
}), }),
text: toMountPoint( text: toMountPoint(<ToastNotificationText previewTextLength={50} text={error} />, {
<ToastNotificationText theme,
previewTextLength={50} i18n: i18nStart,
overlays={overlays} }),
theme={theme}
text={error}
/>,
{ theme$: theme.theme$ }
),
}); });
} }
@ -225,15 +138,10 @@ export const useDeleteTransforms = () => {
values: { destinationIndex }, values: { destinationIndex },
} }
), ),
text: toMountPoint( text: toMountPoint(<ToastNotificationText previewTextLength={50} text={error} />, {
<ToastNotificationText theme,
previewTextLength={50} i18n: i18nStart,
overlays={overlays} }),
theme={theme}
text={error}
/>,
{ theme$: theme.theme$ }
),
}); });
} }
@ -247,52 +155,18 @@ export const useDeleteTransforms = () => {
values: { destinationIndex }, values: { destinationIndex },
} }
), ),
text: toMountPoint( text: toMountPoint(<ToastNotificationText previewTextLength={50} text={error} />, {
<ToastNotificationText theme,
previewTextLength={50} i18n: i18nStart,
overlays={overlays} }),
theme={theme}
text={error}
/>,
{ theme$: theme.theme$ }
),
}); });
} }
} }
} }
// if we are deleting multiple transforms, combine the success messages refreshTransformList();
if (isBulk) { },
if (successCount.transformDeleted > 0) { });
toastNotifications.addSuccess(
i18n.translate('xpack.transform.transformList.bulkDeleteTransformSuccessMessage', {
defaultMessage:
'Successfully deleted {count} {count, plural, one {transform} other {transforms}}.',
values: { count: successCount.transformDeleted },
})
);
}
if (successCount.destIndexDeleted > 0) { return mutation.mutate;
toastNotifications.addSuccess(
i18n.translate('xpack.transform.transformList.bulkDeleteDestIndexSuccessMessage', {
defaultMessage:
'Successfully deleted {count} destination {count, plural, one {index} other {indices}}.',
values: { count: successCount.destIndexDeleted },
})
);
}
if (successCount.destDataViewDeleted > 0) {
toastNotifications.addSuccess(
i18n.translate('xpack.transform.transformList.bulkDeleteDestDataViewSuccessMessage', {
defaultMessage:
'Successfully deleted {count} destination data {count, plural, one {view} other {views}}.',
values: { count: successCount.destDataViewDeleted },
})
);
}
}
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH);
};
}; };

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import { useAppDependencies } from '../app_dependencies';
export const useGetDataViewTitles = () => {
const { data } = useAppDependencies();
return useQuery<string[], IHttpFetchError>(
[TRANSFORM_REACT_QUERY_KEYS.GET_DATA_VIEW_TITLES],
() => data.dataViews.getTitles()
);
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import type { EsIndex } from '../../../common/types/es_index';
import { useAppDependencies } from '../app_dependencies';
export const useGetEsIndices = () => {
const { http } = useAppDependencies();
return useQuery<EsIndex[], IHttpFetchError>(
[TRANSFORM_REACT_QUERY_KEYS.GET_ES_INDICES],
({ signal }) =>
http.get<EsIndex[]>('/api/index_management/indices', {
version: '1',
signal,
})
);
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import type { EsIngestPipeline } from '../../../common/types/es_ingest_pipeline';
import { useAppDependencies } from '../app_dependencies';
export const useGetEsIngestPipelines = () => {
const { http } = useAppDependencies();
return useQuery<EsIngestPipeline[], IHttpFetchError>(
[TRANSFORM_REACT_QUERY_KEYS.GET_ES_INGEST_PIPELINES],
({ signal }) =>
http.get<EsIngestPipeline[]>('/api/ingest_pipelines', {
version: '1',
signal,
})
);
};

View file

@ -0,0 +1,66 @@
/*
* 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 { IHttpFetchError } from '@kbn/core-http-browser';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { DEFAULT_SAMPLER_SHARD_SIZE } from '@kbn/ml-agg-utils';
import { addInternalBasePath, TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import type {
FieldHistogramsRequestSchema,
FieldHistogramsResponseSchema,
} from '../../../common/api_schemas/field_histograms';
import { useAppDependencies } from '../app_dependencies';
import type { SavedSearchQuery } from './use_search_items';
export interface FieldHistogramRequestConfig {
fieldName: string;
type?: KBN_FIELD_TYPES;
}
export const useGetHistogramsForFields = (
dataViewTitle: string,
fields: FieldHistogramRequestConfig[],
query: string | SavedSearchQuery,
runtimeMappings?: FieldHistogramsRequestSchema['runtimeMappings'],
enabled?: boolean,
samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE
) => {
const { http } = useAppDependencies();
return useQuery<FieldHistogramsResponseSchema, IHttpFetchError>(
[
TRANSFORM_REACT_QUERY_KEYS.GET_HISTOGRAMS_FOR_FIELDS,
{
dataViewTitle,
fields,
query,
runtimeMappings,
samplerShardSize,
},
],
({ signal }) =>
http.post<FieldHistogramsResponseSchema>(
addInternalBasePath(`field_histograms/${dataViewTitle}`),
{
body: JSON.stringify({
query,
fields,
samplerShardSize,
...(runtimeMappings !== undefined ? { runtimeMappings } : {}),
}),
version: '1',
signal,
}
),
{ enabled }
);
};

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { GetTransformsResponseSchema } from '../../../common/api_schemas/transforms';
import { addInternalBasePath, TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import type { TransformId } from '../../../common/types/transform';
import { useAppDependencies } from '../app_dependencies';
export const useGetTransform = (transformId: TransformId, enabled?: boolean) => {
const { http } = useAppDependencies();
return useQuery<GetTransformsResponseSchema, IHttpFetchError>(
[TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM, transformId],
({ signal }) =>
http.get<GetTransformsResponseSchema>(addInternalBasePath(`transforms/${transformId}`), {
version: '1',
signal,
}),
{ enabled }
);
};

View file

@ -0,0 +1,39 @@
/*
* 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 { IHttpFetchError } from '@kbn/core-http-browser';
import type { GetTransformsAuditMessagesResponseSchema } from '../../../common/api_schemas/audit_messages';
import { addInternalBasePath, TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import { TransformMessage } from '../../../common/types/messages';
import { useAppDependencies } from '../app_dependencies';
export const useGetTransformAuditMessages = (
transformId: string,
sortField: keyof TransformMessage,
sortDirection: 'asc' | 'desc'
) => {
const { http } = useAppDependencies();
const query = { sortField, sortDirection };
return useQuery<GetTransformsAuditMessagesResponseSchema, IHttpFetchError>(
[TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM_AUDIT_MESSAGES, transformId, query],
({ signal }) =>
http.get<GetTransformsAuditMessagesResponseSchema>(
addInternalBasePath(`transforms/${transformId}/messages`),
{
query,
version: '1',
signal,
}
)
);
};

View file

@ -0,0 +1,41 @@
/*
* 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 { IHttpFetchError } from '@kbn/core-http-browser';
import type { GetTransformNodesResponseSchema } from '../../../common/api_schemas/transforms';
import {
addInternalBasePath,
DEFAULT_REFRESH_INTERVAL_MS,
TRANSFORM_REACT_QUERY_KEYS,
} from '../../../common/constants';
import { useAppDependencies } from '../app_dependencies';
export const useGetTransformNodes = () => {
const { http } = useAppDependencies();
return useQuery<number, IHttpFetchError>(
[TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM_NODES],
async ({ signal }) => {
const transformNodes = await http.get<GetTransformNodesResponseSchema>(
addInternalBasePath('transforms/_nodes'),
{
version: '1',
signal,
}
);
return transformNodes.count;
},
{
refetchInterval: DEFAULT_REFRESH_INTERVAL_MS,
}
);
};

View file

@ -0,0 +1,37 @@
/*
* 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 { IHttpFetchError } from '@kbn/core-http-browser';
import type { GetTransformsStatsResponseSchema } from '../../../common/api_schemas/transforms_stats';
import { addInternalBasePath, TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import type { TransformId } from '../../../common/types/transform';
import { useAppDependencies } from '../app_dependencies';
export const useGetTransformStats = (
transformId: TransformId,
enabled?: boolean,
refetchInterval?: number | false
) => {
const { http } = useAppDependencies();
return useQuery<GetTransformsStatsResponseSchema, IHttpFetchError>(
[TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM_STATS, transformId],
({ signal }) =>
http.get<GetTransformsStatsResponseSchema>(
addInternalBasePath(`transforms/${transformId}/_stats`),
{
version: '1',
signal,
}
),
{ enabled, refetchInterval }
);
};

View file

@ -5,75 +5,64 @@
* 2.0. * 2.0.
*/ */
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser'; import type { IHttpFetchError } from '@kbn/core-http-browser';
import { isDefined } from '@kbn/ml-is-defined'; import { isDefined } from '@kbn/ml-is-defined';
import type { GetTransformsResponseSchema } from '../../../common/api_schemas/transforms';
import type { GetTransformsStatsResponseSchema } from '../../../common/api_schemas/transforms_stats';
import { import {
isGetTransformNodesResponseSchema, addInternalBasePath,
isGetTransformsResponseSchema, DEFAULT_REFRESH_INTERVAL_MS,
isGetTransformsStatsResponseSchema, TRANSFORM_REACT_QUERY_KEYS,
} from '../../../common/api_schemas/type_guards'; TRANSFORM_MODE,
import { TRANSFORM_MODE } from '../../../common/constants'; } from '../../../common/constants';
import { isTransformStats } from '../../../common/types/transform_stats'; import { isTransformStats } from '../../../common/types/transform_stats';
import { import { type TransformListRow } from '../common';
type TransformListRow, import { useAppDependencies } from '../app_dependencies';
refreshTransformList$,
REFRESH_TRANSFORM_LIST_STATE,
} from '../common';
import { useApi } from './use_api';
import { TRANSFORM_ERROR_TYPE } from '../common/transform'; import { TRANSFORM_ERROR_TYPE } from '../common/transform';
export type GetTransforms = (forceRefresh?: boolean) => void; interface UseGetTransformsResponse {
transforms: TransformListRow[];
export const useGetTransforms = ( transformIds: string[];
setTransforms: React.Dispatch<React.SetStateAction<TransformListRow[]>>, transformIdsWithoutConfig?: string[];
setTransformNodes: React.Dispatch<React.SetStateAction<number>>,
setErrorMessage: React.Dispatch<React.SetStateAction<IHttpFetchError | undefined>>,
setTransformIdsWithoutConfig: React.Dispatch<React.SetStateAction<string[] | undefined>>,
setIsInitialized: React.Dispatch<React.SetStateAction<boolean>>,
blockRefresh: boolean
): GetTransforms => {
const api = useApi();
let concurrentLoads = 0;
const getTransforms = async (forceRefresh = false) => {
if (forceRefresh === true || blockRefresh === false) {
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.LOADING);
concurrentLoads++;
if (concurrentLoads > 1) {
return;
} }
const fetchOptions = { asSystemRequest: true }; const getInitialData = (): UseGetTransformsResponse => ({
const transformNodes = await api.getTransformNodes(); transforms: [],
const transformConfigs = await api.getTransforms(fetchOptions); transformIds: [],
const transformStats = await api.getTransformsStats(fetchOptions); });
if ( interface UseGetTransformsOptions {
!isGetTransformsResponseSchema(transformConfigs) || enabled?: boolean;
!isGetTransformsStatsResponseSchema(transformStats) ||
!isGetTransformNodesResponseSchema(transformNodes)
) {
// An error is followed immediately by setting the state to idle.
// This way we're able to treat ERROR as a one-time-event like REFRESH.
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.ERROR);
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.IDLE);
setTransformNodes(0);
setTransforms([]);
setIsInitialized(true);
if (!isGetTransformsResponseSchema(transformConfigs)) {
setErrorMessage(transformConfigs);
} else if (!isGetTransformsStatsResponseSchema(transformStats)) {
setErrorMessage(transformStats);
} }
return; export const useGetTransforms = ({ enabled }: UseGetTransformsOptions = {}) => {
const { http } = useAppDependencies();
const { data = getInitialData(), ...rest } = useQuery<UseGetTransformsResponse, IHttpFetchError>(
[TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORMS],
async ({ signal }) => {
const update = getInitialData();
const transformConfigs = await http.get<GetTransformsResponseSchema>(
addInternalBasePath('transforms'),
{
version: '1',
asSystemRequest: true,
signal,
} }
);
const transformStats = await http.get<GetTransformsStatsResponseSchema>(
addInternalBasePath(`transforms/_stats`),
{
version: '1',
asSystemRequest: true,
signal,
}
);
// There might be some errors with fetching certain transforms // There might be some errors with fetching certain transforms
// For example, when task exists and is running but the config is deleted // For example, when task exists and is running but the config is deleted
@ -87,17 +76,12 @@ export const useGetTransforms = (
}) })
.filter(isDefined); .filter(isDefined);
setTransformIdsWithoutConfig( update.transformIdsWithoutConfig =
danglingTaskIdMatches.length > 0 ? danglingTaskIdMatches : undefined danglingTaskIdMatches.length > 0 ? danglingTaskIdMatches : undefined;
);
} else {
setTransformIdsWithoutConfig(undefined);
} }
const tableRows = transformConfigs.transforms.reduce((reducedtableRows, config) => { update.transforms = transformConfigs.transforms.reduce((reducedtableRows, config) => {
const stats = isGetTransformsStatsResponseSchema(transformStats) const stats = transformStats.transforms.find((d) => config.id === d.id);
? transformStats.transforms.find((d) => config.id === d.id)
: undefined;
// A newly created transform might not have corresponding stats yet. // A newly created transform might not have corresponding stats yet.
// If that's the case we just skip the transform and don't add it to the transform list yet. // If that's the case we just skip the transform and don't add it to the transform list yet.
@ -117,21 +101,15 @@ export const useGetTransforms = (
return reducedtableRows; return reducedtableRows;
}, [] as TransformListRow[]); }, [] as TransformListRow[]);
setTransformNodes(transformNodes.count); update.transformIds = update.transforms.map(({ id }) => id);
setTransforms(tableRows);
setErrorMessage(undefined);
setIsInitialized(true);
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.IDLE);
concurrentLoads--; return update;
},
if (concurrentLoads > 0) { {
concurrentLoads = 0; enabled,
getTransforms(true); refetchInterval: DEFAULT_REFRESH_INTERVAL_MS,
return;
} }
} );
};
return getTransforms; return { data, ...rest };
}; };

View file

@ -0,0 +1,36 @@
/*
* 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 { IHttpFetchError } from '@kbn/core-http-browser';
import type {
PostTransformsPreviewRequestSchema,
PostTransformsPreviewResponseSchema,
} from '../../../common/api_schemas/transforms';
import { addInternalBasePath, TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
import { useAppDependencies } from '../app_dependencies';
export const useGetTransformsPreview = (
obj: PostTransformsPreviewRequestSchema,
enabled?: boolean
) => {
const { http } = useAppDependencies();
return useQuery<PostTransformsPreviewResponseSchema, IHttpFetchError>(
[TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORMS_PREVIEW, obj],
({ signal }) =>
http.post<PostTransformsPreviewResponseSchema>(addInternalBasePath('transforms/_preview'), {
body: JSON.stringify(obj),
version: '1',
signal,
}),
{ enabled }
);
};

View file

@ -5,13 +5,13 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '@testing-library/jest-dom/extend-expect'; import '@testing-library/jest-dom/extend-expect';
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { CoreSetup } from '@kbn/core/public'; import { CoreSetup } from '@kbn/core/public';
import { DataGrid, type UseIndexDataReturnType } from '@kbn/ml-data-grid'; import { DataGrid, type UseIndexDataReturnType } from '@kbn/ml-data-grid';
import type { RuntimeMappings } from '@kbn/ml-runtime-field-utils'; import type { RuntimeMappings } from '@kbn/ml-runtime-field-utils';
@ -25,7 +25,6 @@ import { useIndexData } from './use_index_data';
jest.mock('../../shared_imports'); jest.mock('../../shared_imports');
jest.mock('../app_dependencies'); jest.mock('../app_dependencies');
jest.mock('./use_api');
import { MlSharedContext } from '../__mocks__/shared_context'; import { MlSharedContext } from '../__mocks__/shared_context';
@ -45,13 +44,17 @@ const runtimeMappings: RuntimeMappings = {
}, },
}; };
const queryClient = new QueryClient();
describe('Transform: useIndexData()', () => { describe('Transform: useIndexData()', () => {
test('dataView set triggers loading', async () => { test('dataView set triggers loading', async () => {
const mlShared = await getMlSharedImports(); const mlShared = await getMlSharedImports();
const wrapper: FC = ({ children }) => ( const wrapper: FC = ({ children }) => (
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en"> <IntlProvider locale="en">
<MlSharedContext.Provider value={mlShared}>{children}</MlSharedContext.Provider> <MlSharedContext.Provider value={mlShared}>{children}</MlSharedContext.Provider>
</IntlProvider> </IntlProvider>
</QueryClientProvider>
); );
const { result, waitForNextUpdate } = renderHook( const { result, waitForNextUpdate } = renderHook(
@ -102,11 +105,13 @@ describe('Transform: <DataGrid /> with useIndexData()', () => {
}; };
render( render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en"> <IntlProvider locale="en">
<MlSharedContext.Provider value={mlSharedImports}> <MlSharedContext.Provider value={mlSharedImports}>
<Wrapper /> <Wrapper />
</MlSharedContext.Provider> </MlSharedContext.Provider>
</IntlProvider> </IntlProvider>
</QueryClientProvider>
); );
// Act // Act
@ -142,11 +147,13 @@ describe('Transform: <DataGrid /> with useIndexData()', () => {
}; };
render( render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en"> <IntlProvider locale="en">
<MlSharedContext.Provider value={mlSharedImports}> <MlSharedContext.Provider value={mlSharedImports}>
<Wrapper /> <Wrapper />
</MlSharedContext.Provider> </MlSharedContext.Provider>
</IntlProvider> </IntlProvider>
</QueryClientProvider>
); );
// Act // Act

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { EuiDataGridColumn } from '@elastic/eui'; import type { EuiDataGridColumn } from '@elastic/eui';
@ -28,10 +28,6 @@ import {
} from '@kbn/ml-data-grid'; } from '@kbn/ml-data-grid';
import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker';
import {
isEsSearchResponse,
isFieldHistogramsResponseSchema,
} from '../../../common/api_schemas/type_guards';
import { import {
hasKeywordDuplicate, hasKeywordDuplicate,
isKeywordDuplicate, isKeywordDuplicate,
@ -44,7 +40,7 @@ import { useToastNotifications, useAppDependencies } from '../app_dependencies';
import type { StepDefineExposedState } from '../sections/create_transform/components/step_define/common'; import type { StepDefineExposedState } from '../sections/create_transform/components/step_define/common';
import { SearchItems } from './use_search_items'; import { SearchItems } from './use_search_items';
import { useApi } from './use_api'; import { useGetHistogramsForFields } from './use_get_histograms_for_fields';
import { useDataSearch } from './use_data_search'; import { useDataSearch } from './use_data_search';
export const useIndexData = ( export const useIndexData = (
@ -52,7 +48,7 @@ export const useIndexData = (
query: TransformConfigQuery, query: TransformConfigQuery,
combinedRuntimeMappings?: StepDefineExposedState['runtimeMappings'], combinedRuntimeMappings?: StepDefineExposedState['runtimeMappings'],
timeRangeMs?: TimeRangeMs, timeRangeMs?: TimeRangeMs,
populatedFields?: Set<string> | null populatedFields?: string[]
): UseIndexDataReturnType => { ): UseIndexDataReturnType => {
const { analytics } = useAppDependencies(); const { analytics } = useAppDependencies();
@ -61,13 +57,8 @@ export const useIndexData = (
const loadIndexDataStartTime = useRef<number | undefined>(window.performance.now()); const loadIndexDataStartTime = useRef<number | undefined>(window.performance.now());
const indexPattern = useMemo(() => dataView.getIndexPattern(), [dataView]); const indexPattern = useMemo(() => dataView.getIndexPattern(), [dataView]);
const api = useApi();
const dataSearch = useDataSearch();
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const [dataViewFields, setDataViewFields] = useState<string[]>();
const baseFilterCriteria = buildBaseFilterCriteria( const baseFilterCriteria = buildBaseFilterCriteria(
dataView.timeFieldName, dataView.timeFieldName,
timeRangeMs?.from, timeRangeMs?.from,
@ -86,26 +77,17 @@ export const useIndexData = (
}, },
}; };
useEffect(() => {
if (dataView.timeFieldName !== undefined && timeRangeMs === undefined) {
return;
}
const abortController = new AbortController();
// Fetch 500 random documents to determine populated fields. // Fetch 500 random documents to determine populated fields.
// This is a workaround to avoid passing potentially thousands of unpopulated fields // This is a workaround to avoid passing potentially thousands of unpopulated fields
// (for example, as part of filebeat/metricbeat/ECS based indices) // (for example, as part of filebeat/metricbeat/ECS based indices)
// to the data grid component which would significantly slow down the page. // to the data grid component which would significantly slow down the page.
const fetchDataGridSampleDocuments = async function () { const {
let populatedDataViewFields = populatedFields ? [...populatedFields] : []; error: dataViewFieldsError,
let isMissingFields = populatedDataViewFields.length === 0; data: dataViewFieldsData,
isError: dataViewFieldsIsError,
// If populatedFields are not provided, make own request to calculate isLoading: dataViewFieldsIsLoading,
if (populatedFields === undefined) { } = useDataSearch(
setErrorMessage(''); {
setStatus(INDEX_STATUS.LOADING);
const esSearchRequest = {
index: indexPattern, index: indexPattern,
body: { body: {
fields: ['*'], fields: ['*'],
@ -118,41 +100,50 @@ export const useIndexData = (
}, },
size: 500, size: 500,
}, },
}; },
// Check whether fetching should be enabled
// If populatedFields are not provided, make own request to calculate
!Array.isArray(populatedFields) &&
!(dataView.timeFieldName !== undefined && timeRangeMs === undefined)
);
const resp = await dataSearch(esSearchRequest, abortController.signal); useEffect(() => {
if (dataViewFieldsIsLoading && !dataViewFieldsIsError) {
if (!isEsSearchResponse(resp)) { setErrorMessage('');
setErrorMessage(getErrorMessage(resp)); setStatus(INDEX_STATUS.LOADING);
} else if (dataViewFieldsError !== null) {
setErrorMessage(getErrorMessage(dataViewFieldsError));
setStatus(INDEX_STATUS.ERROR); setStatus(INDEX_STATUS.ERROR);
return; } else if (
} !dataViewFieldsIsLoading &&
const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); !dataViewFieldsIsError &&
isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); dataViewFieldsData !== undefined
) {
populatedDataViewFields = [...new Set(docs.map(Object.keys).flat(1))];
}
const isCrossClusterSearch = indexPattern.includes(':'); const isCrossClusterSearch = indexPattern.includes(':');
const isMissingFields = dataViewFieldsData.hits.hits.every(
// Get all field names for each returned doc and flatten it (d) => typeof d.fields === 'undefined'
// to a list of unique field names used across all docs. );
const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView);
const filteredDataViewFields = populatedDataViewFields
.filter((d) => allDataViewFields.includes(d))
.sort();
setCcsWarning(isCrossClusterSearch && isMissingFields); setCcsWarning(isCrossClusterSearch && isMissingFields);
setStatus(INDEX_STATUS.LOADED); setStatus(INDEX_STATUS.LOADED);
setDataViewFields(filteredDataViewFields); }
};
fetchDataGridSampleDocuments();
return () => {
abortController.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRangeMs, populatedFields?.size]); }, [dataViewFieldsData, dataViewFieldsError, dataViewFieldsIsError, dataViewFieldsIsLoading]);
const dataViewFields = useMemo(() => {
let allPopulatedFields = Array.isArray(populatedFields) ? populatedFields : [];
if (populatedFields === undefined && dataViewFieldsData) {
// Get all field names for each returned doc and flatten it
// to a list of unique field names used across all docs.
const docs = dataViewFieldsData.hits.hits.map((d) => getProcessedFields(d.fields ?? {}));
allPopulatedFields = [...new Set(docs.map(Object.keys).flat(1))];
}
const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView);
return allPopulatedFields.filter((d) => allDataViewFields.includes(d)).sort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataViewFieldsData, populatedFields]);
const columns: EuiDataGridColumn[] = useMemo(() => { const columns: EuiDataGridColumn[] = useMemo(() => {
if (typeof dataViewFields === 'undefined') { if (typeof dataViewFields === 'undefined') {
@ -206,22 +197,18 @@ export const useIndexData = (
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify([query, timeRangeMs])]); }, [JSON.stringify([query, timeRangeMs])]);
useEffect(() => {
if (typeof dataViewFields === 'undefined') {
return;
}
const abortController = new AbortController();
const fetchDataGridData = async function () {
setErrorMessage('');
setStatus(INDEX_STATUS.LOADING);
const sort: EsSorting = sortingColumns.reduce((s, column) => { const sort: EsSorting = sortingColumns.reduce((s, column) => {
s[column.id] = { order: column.direction }; s[column.id] = { order: column.direction };
return s; return s;
}, {} as EsSorting); }, {} as EsSorting);
const esSearchRequest = { const {
error: dataGridDataError,
data: dataGridData,
isError: dataGridDataIsError,
isLoading: dataGridDataIsLoading,
} = useDataSearch(
{
index: indexPattern, index: indexPattern,
body: { body: {
fields: ['*'], fields: ['*'],
@ -234,56 +221,44 @@ export const useIndexData = (
? { runtime_mappings: combinedRuntimeMappings } ? { runtime_mappings: combinedRuntimeMappings }
: {}), : {}),
}, },
}; },
const resp = await dataSearch(esSearchRequest, abortController.signal); // Check whether fetching should be enabled
dataViewFields !== undefined
);
if (!isEsSearchResponse(resp)) { useEffect(() => {
setErrorMessage(getErrorMessage(resp)); if (dataGridDataIsLoading && !dataGridDataIsError) {
setErrorMessage('');
setStatus(INDEX_STATUS.LOADING);
} else if (dataGridDataError !== null) {
setErrorMessage(getErrorMessage(dataGridDataError));
setStatus(INDEX_STATUS.ERROR); setStatus(INDEX_STATUS.ERROR);
return; } else if (!dataGridDataIsLoading && !dataGridDataIsError && dataGridData !== undefined) {
}
const isCrossClusterSearch = indexPattern.includes(':'); const isCrossClusterSearch = indexPattern.includes(':');
const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); const isMissingFields = dataGridData.hits.hits.every((d) => typeof d.fields === 'undefined');
const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); const docs = dataGridData.hits.hits.map((d) => getProcessedFields(d.fields ?? {}));
setCcsWarning(isCrossClusterSearch && isMissingFields); setCcsWarning(isCrossClusterSearch && isMissingFields);
setRowCountInfo({ setRowCountInfo({
rowCount: typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total!.value, rowCount:
typeof dataGridData.hits.total === 'number'
? dataGridData.hits.total
: dataGridData.hits.total!.value,
rowCountRelation: rowCountRelation:
typeof resp.hits.total === 'number' typeof dataGridData.hits.total === 'number'
? ('eq' as estypes.SearchTotalHitsRelation) ? ('eq' as estypes.SearchTotalHitsRelation)
: resp.hits.total!.relation, : dataGridData.hits.total!.relation,
}); });
setTableItems(docs); setTableItems(docs);
setStatus(INDEX_STATUS.LOADED); setStatus(INDEX_STATUS.LOADED);
}; }
fetchDataGridData();
return () => {
abortController.abort();
};
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [dataGridDataError, dataGridDataIsError, dataGridDataIsLoading]);
indexPattern,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify([
query,
pagination,
sortingColumns,
dataViewFields,
combinedRuntimeMappings,
timeRangeMs,
]),
]);
useEffect(() => {
const fetchColumnChartsData = async function () {
const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name));
const columnChartsData = await api.getHistogramsForFields( const { error: histogramsForFieldsError, data: histogramsForFieldsData } =
useGetHistogramsForFields(
indexPattern, indexPattern,
columns columns
.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) .filter((cT) => dataGrid.visibleColumns.includes(cT.id))
@ -302,36 +277,33 @@ export const useIndexData = (
}; };
}), }),
isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria,
combinedRuntimeMappings combinedRuntimeMappings,
chartsVisible
); );
if (!isFieldHistogramsResponseSchema(columnChartsData)) { useEffect(() => {
showDataGridColumnChartErrorMessageToast(columnChartsData, toastNotifications); if (histogramsForFieldsError !== null) {
return; showDataGridColumnChartErrorMessageToast(histogramsForFieldsError, toastNotifications);
} }
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [histogramsForFieldsError]);
useEffect(() => {
if (histogramsForFieldsData) {
setColumnCharts( setColumnCharts(
// revert field names with `.keyword` used to do aggregations to their original column name // revert field names with `.keyword` used to do aggregations to their original column name
columnChartsData.map((d) => ({ histogramsForFieldsData.map((d) => ({
...d, ...d,
...(isKeywordDuplicate(d.id, allDataViewFieldNames) ...(isKeywordDuplicate(d.id, allDataViewFieldNames)
? { id: removeKeywordPostfix(d.id) } ? { id: removeKeywordPostfix(d.id) }
: {}), : {}),
})) }))
); );
};
if (chartsVisible) {
fetchColumnChartsData();
} }
// custom comparison // custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [histogramsForFieldsData]);
chartsVisible,
indexPattern,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify([query, dataGrid.visibleColumns, combinedRuntimeMappings, timeRangeMs]),
]);
const renderCellValue = useRenderCellValue(dataView, pagination, tableItems); const renderCellValue = useRenderCellValue(dataView, pagination, tableItems);

View file

@ -6,31 +6,39 @@
*/ */
import React from 'react'; import React from 'react';
import { useMutation } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { toMountPoint } from '@kbn/react-kibana-mount';
import type { StartTransformsRequestSchema } from '../../../common/api_schemas/start_transforms';
import { isStartTransformsResponseSchema } from '../../../common/api_schemas/type_guards';
import { addInternalBasePath } from '../../../common/constants';
import { getErrorMessage } from '../../../common/utils/errors'; import { getErrorMessage } from '../../../common/utils/errors';
import type {
ReauthorizeTransformsRequestSchema,
ReauthorizeTransformsResponseSchema,
} from '../../../common/api_schemas/reauthorize_transforms';
import { useAppDependencies, useToastNotifications } from '../app_dependencies'; import { useAppDependencies, useToastNotifications } from '../app_dependencies';
import { refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common';
import { ToastNotificationText } from '../components'; import { ToastNotificationText } from '../components';
import { useApi } from './use_api'; import { useRefreshTransformList } from './use_refresh_transform_list';
export const useReauthorizeTransforms = () => { export const useReauthorizeTransforms = () => {
const { overlays, theme } = useAppDependencies(); const { http, i18n: i18nStart, theme } = useAppDependencies();
const refreshTransformList = useRefreshTransformList();
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const api = useApi();
return async (transformsInfo: StartTransformsRequestSchema) => { const mutation = useMutation({
const results = await api.reauthorizeTransforms(transformsInfo); mutationFn: (reqBody: ReauthorizeTransformsRequestSchema) =>
http.post<ReauthorizeTransformsResponseSchema>(
if (!isStartTransformsResponseSchema(results)) { addInternalBasePath('reauthorize_transforms'),
{
body: JSON.stringify(reqBody),
version: '1',
}
),
onError: (error) =>
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate( title: i18n.translate(
'xpack.transform.stepCreateForm.reauthorizeTransformResponseSchemaErrorMessage', 'xpack.transform.stepCreateForm.reauthorizeTransformResponseSchemaErrorMessage',
@ -38,31 +46,20 @@ export const useReauthorizeTransforms = () => {
defaultMessage: 'An error occurred calling the reauthorize transforms request.', defaultMessage: 'An error occurred calling the reauthorize transforms request.',
} }
), ),
text: toMountPoint( text: toMountPoint(<ToastNotificationText text={getErrorMessage(error)} />, {
<ToastNotificationText theme,
overlays={overlays} i18n: i18nStart,
theme={theme} }),
text={getErrorMessage(results)} }),
/>, onSuccess: (results) => {
{ theme$: theme.theme$ }
),
});
return;
}
for (const transformId in results) { for (const transformId in results) {
// hasOwnProperty check to ensure only properties on object itself, and not its prototypes // hasOwnProperty check to ensure only properties on object itself, and not its prototypes
if (results.hasOwnProperty(transformId)) { if (results.hasOwnProperty(transformId)) {
const result = results[transformId]; const result = results[transformId];
if (result.success === true) { if (!result.success) {
toastNotifications.addSuccess( toastNotifications.addError(
i18n.translate('xpack.transform.transformList.reauthorizeTransformSuccessMessage', { new Error(JSON.stringify(result.error!.caused_by, null, 2)),
defaultMessage: 'Request to reauthorize transform {transformId} acknowledged.', {
values: { transformId },
})
);
} else {
toastNotifications.addError(new Error(JSON.stringify(result.error!.caused_by, null, 2)), {
title: i18n.translate( title: i18n.translate(
'xpack.transform.transformList.reauthorizeTransformErrorMessage', 'xpack.transform.transformList.reauthorizeTransformErrorMessage',
{ {
@ -71,11 +68,15 @@ export const useReauthorizeTransforms = () => {
} }
), ),
toastMessage: result.error!.reason, toastMessage: result.error!.reason,
}); }
);
} }
} }
} }
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); refreshTransformList();
}; },
});
return mutation.mutate;
}; };

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQueryClient } from '@tanstack/react-query';
import { TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
export const useRefreshTransformList = () => {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM_NODES]);
queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORMS]);
queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM_AUDIT_MESSAGES]);
};
};

View file

@ -6,73 +6,53 @@
*/ */
import React from 'react'; import React from 'react';
import { i18n } from '@kbn/i18n'; import { useMutation } from '@tanstack/react-query';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import type {
ResetTransformStatus,
ResetTransformsRequestSchema,
} from '../../../common/api_schemas/reset_transforms';
import { isResetTransformsResponseSchema } from '../../../common/api_schemas/type_guards';
import { getErrorMessage } from '../../../common/utils/errors';
import { useAppDependencies, useToastNotifications } from '../app_dependencies';
import { REFRESH_TRANSFORM_LIST_STATE, refreshTransformList$ } from '../common';
import { ToastNotificationText } from '../components';
import { useApi } from './use_api';
type SuccessCountField = keyof Omit<ResetTransformStatus, 'destinationIndex'>; import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import type {
ResetTransformsRequestSchema,
ResetTransformsResponseSchema,
} from '../../../common/api_schemas/reset_transforms';
import { addInternalBasePath } from '../../../common/constants';
import { getErrorMessage } from '../../../common/utils/errors';
import { useAppDependencies, useToastNotifications } from '../app_dependencies';
import { ToastNotificationText } from '../components';
import { useRefreshTransformList } from './use_refresh_transform_list';
export const useResetTransforms = () => { export const useResetTransforms = () => {
const { overlays, theme } = useAppDependencies(); const { http, i18n: i18nStart, theme } = useAppDependencies();
const refreshTransformList = useRefreshTransformList();
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const api = useApi();
return async (reqBody: ResetTransformsRequestSchema) => { const mutation = useMutation({
const results = await api.resetTransforms(reqBody); mutationFn: (reqBody: ResetTransformsRequestSchema) =>
http.post<ResetTransformsResponseSchema>(addInternalBasePath('reset_transforms'), {
if (!isResetTransformsResponseSchema(results)) { body: JSON.stringify(reqBody),
version: '1',
}),
onError: (error) =>
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate('xpack.transform.transformList.resetTransformGenericErrorMessage', { title: i18n.translate('xpack.transform.transformList.resetTransformGenericErrorMessage', {
defaultMessage: 'An error occurred calling the API endpoint to reset transforms.', defaultMessage: 'An error occurred calling the API endpoint to reset transforms.',
}), }),
text: toMountPoint( text: toMountPoint(
<ToastNotificationText <ToastNotificationText previewTextLength={50} text={getErrorMessage(error)} />,
previewTextLength={50} {
overlays={overlays} theme,
theme={theme} i18n: i18nStart,
text={getErrorMessage(results)}
/>,
{ theme$: theme.theme$ }
),
});
return;
} }
),
const isBulk = Object.keys(results).length > 1; }),
const successCount: Record<SuccessCountField, number> = { onSuccess: (results) => {
transformReset: 0,
};
for (const transformId in results) { for (const transformId in results) {
// hasOwnProperty check to ensure only properties on object itself, and not its prototypes // hasOwnProperty check to ensure only properties on object itself, and not its prototypes
if (results.hasOwnProperty(transformId)) { if (results.hasOwnProperty(transformId)) {
const status = results[transformId]; const status = results[transformId];
// if we are only resetting one transform, show the success toast messages
if (!isBulk && status.transformReset) {
if (status.transformReset?.success) {
toastNotifications.addSuccess(
i18n.translate('xpack.transform.transformList.resetTransformSuccessMessage', {
defaultMessage: 'Request to reset transform {transformId} acknowledged.',
values: { transformId },
})
);
}
} else {
(Object.keys(successCount) as SuccessCountField[]).forEach((key) => {
if (status[key]?.success) {
successCount[key] = successCount[key] + 1;
}
});
}
if (status.transformReset?.error) { if (status.transformReset?.error) {
const error = status.transformReset.error.reason; const error = status.transformReset.error.reason;
toastNotifications.addDanger({ toastNotifications.addDanger({
@ -80,33 +60,18 @@ export const useResetTransforms = () => {
defaultMessage: 'An error occurred resetting the transform {transformId}', defaultMessage: 'An error occurred resetting the transform {transformId}',
values: { transformId }, values: { transformId },
}), }),
text: toMountPoint( text: toMountPoint(<ToastNotificationText previewTextLength={50} text={error} />, {
<ToastNotificationText theme,
previewTextLength={50} i18n: i18nStart,
overlays={overlays} }),
theme={theme}
text={error}
/>,
{ theme$: theme.theme$ }
),
}); });
} }
} }
} }
// if we are deleting multiple transforms, combine the success messages refreshTransformList();
if (isBulk) { },
if (successCount.transformReset > 0) { });
toastNotifications.addSuccess(
i18n.translate('xpack.transform.transformList.bulkResetTransformSuccessMessage', {
defaultMessage:
'Successfully reset {count} {count, plural, one {transform} other {transforms}}.',
values: { count: successCount.transformReset },
})
);
}
}
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); return mutation.mutate;
};
}; };

View file

@ -6,31 +6,38 @@
*/ */
import React from 'react'; import React from 'react';
import { useMutation } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { addInternalBasePath } from '../../../common/constants';
import type {
import type { ScheduleNowTransformsRequestSchema } from '../../../common/api_schemas/schedule_now_transforms'; ScheduleNowTransformsRequestSchema,
import { isScheduleNowTransformsResponseSchema } from '../../../common/api_schemas/type_guards'; ScheduleNowTransformsResponseSchema,
} from '../../../common/api_schemas/schedule_now_transforms';
import { getErrorMessage } from '../../../common/utils/errors'; import { getErrorMessage } from '../../../common/utils/errors';
import { useAppDependencies, useToastNotifications } from '../app_dependencies'; import { useAppDependencies, useToastNotifications } from '../app_dependencies';
import { refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common';
import { ToastNotificationText } from '../components'; import { ToastNotificationText } from '../components';
import { useApi } from './use_api'; import { useRefreshTransformList } from './use_refresh_transform_list';
export const useScheduleNowTransforms = () => { export const useScheduleNowTransforms = () => {
const { overlays, theme } = useAppDependencies(); const { http, i18n: i18nStart, theme } = useAppDependencies();
const refreshTransformList = useRefreshTransformList();
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const api = useApi();
return async (transformsInfo: ScheduleNowTransformsRequestSchema) => { const mutation = useMutation({
const results = await api.scheduleNowTransforms(transformsInfo); mutationFn: (reqBody: ScheduleNowTransformsRequestSchema) =>
http.post<ScheduleNowTransformsResponseSchema>(
if (!isScheduleNowTransformsResponseSchema(results)) { addInternalBasePath('schedule_now_transforms'),
{
body: JSON.stringify(reqBody),
version: '1',
}
),
onError: (error) =>
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate( title: i18n.translate(
'xpack.transform.stepCreateForm.scheduleNowTransformResponseSchemaErrorMessage', 'xpack.transform.stepCreateForm.scheduleNowTransformResponseSchemaErrorMessage',
@ -39,32 +46,20 @@ export const useScheduleNowTransforms = () => {
'An error occurred calling the request to schedule the transform to process data instantly.', 'An error occurred calling the request to schedule the transform to process data instantly.',
} }
), ),
text: toMountPoint( text: toMountPoint(<ToastNotificationText text={getErrorMessage(error)} />, {
<ToastNotificationText theme,
overlays={overlays} i18n: i18nStart,
theme={theme} }),
text={getErrorMessage(results)} }),
/>, onSuccess: (results) => {
{ theme$: theme.theme$ }
),
});
return;
}
for (const transformId in results) { for (const transformId in results) {
// hasOwnProperty check to ensure only properties on object itself, and not its prototypes // hasOwnProperty check to ensure only properties on object itself, and not its prototypes
if (results.hasOwnProperty(transformId)) { if (results.hasOwnProperty(transformId)) {
const result = results[transformId]; const result = results[transformId];
if (result.success === true) { if (!result.success) {
toastNotifications.addSuccess( toastNotifications.addError(
i18n.translate('xpack.transform.transformList.scheduleNowTransformSuccessMessage', { new Error(JSON.stringify(result.error!.caused_by, null, 2)),
defaultMessage: {
'Request to schedule transform {transformId} to process data instantly acknowledged.',
values: { transformId },
})
);
} else {
toastNotifications.addError(new Error(JSON.stringify(result.error!.caused_by, null, 2)), {
title: i18n.translate( title: i18n.translate(
'xpack.transform.transformList.scheduleNowTransformErrorMessage', 'xpack.transform.transformList.scheduleNowTransformErrorMessage',
{ {
@ -74,11 +69,15 @@ export const useScheduleNowTransforms = () => {
} }
), ),
toastMessage: result.error!.reason, toastMessage: result.error!.reason,
}); }
);
} }
} }
} }
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); refreshTransformList();
}; },
});
return mutation.mutate;
}; };

View file

@ -6,31 +6,35 @@
*/ */
import React from 'react'; import React from 'react';
import { useMutation } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { addInternalBasePath } from '../../../common/constants';
import type {
import type { StartTransformsRequestSchema } from '../../../common/api_schemas/start_transforms'; StartTransformsRequestSchema,
import { isStartTransformsResponseSchema } from '../../../common/api_schemas/type_guards'; StartTransformsResponseSchema,
} from '../../../common/api_schemas/start_transforms';
import { getErrorMessage } from '../../../common/utils/errors'; import { getErrorMessage } from '../../../common/utils/errors';
import { useAppDependencies, useToastNotifications } from '../app_dependencies'; import { useAppDependencies, useToastNotifications } from '../app_dependencies';
import { refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common';
import { ToastNotificationText } from '../components'; import { ToastNotificationText } from '../components';
import { useApi } from './use_api'; import { useRefreshTransformList } from './use_refresh_transform_list';
export const useStartTransforms = () => { export const useStartTransforms = () => {
const { overlays, theme } = useAppDependencies(); const { http, i18n: i18nStart, theme } = useAppDependencies();
const refreshTransformList = useRefreshTransformList();
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const api = useApi();
return async (transformsInfo: StartTransformsRequestSchema) => { const mutation = useMutation({
const results = await api.startTransforms(transformsInfo); mutationFn: (reqBody: StartTransformsRequestSchema) =>
http.post<StartTransformsResponseSchema>(addInternalBasePath('start_transforms'), {
if (!isStartTransformsResponseSchema(results)) { body: JSON.stringify(reqBody),
version: '1',
}),
onError: (error) =>
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate( title: i18n.translate(
'xpack.transform.stepCreateForm.startTransformResponseSchemaErrorMessage', 'xpack.transform.stepCreateForm.startTransformResponseSchemaErrorMessage',
@ -38,41 +42,34 @@ export const useStartTransforms = () => {
defaultMessage: 'An error occurred calling the start transforms request.', defaultMessage: 'An error occurred calling the start transforms request.',
} }
), ),
text: toMountPoint( text: toMountPoint(<ToastNotificationText text={getErrorMessage(error)} />, {
<ToastNotificationText theme,
overlays={overlays} i18n: i18nStart,
theme={theme} }),
text={getErrorMessage(results)} }),
/>, onSuccess: (results) => {
{ theme$: theme.theme$ }
),
});
return;
}
for (const transformId in results) { for (const transformId in results) {
// hasOwnProperty check to ensure only properties on object itself, and not its prototypes // hasOwnProperty check to ensure only properties on object itself, and not its prototypes
if (results.hasOwnProperty(transformId)) { if (results.hasOwnProperty(transformId)) {
const result = results[transformId]; const result = results[transformId];
if (result.success === true) { if (!result.success) {
toastNotifications.addSuccess( toastNotifications.addError(
i18n.translate('xpack.transform.transformList.startTransformSuccessMessage', { new Error(JSON.stringify(result.error!.caused_by, null, 2)),
defaultMessage: 'Request to start transform {transformId} acknowledged.', {
values: { transformId },
})
);
} else {
toastNotifications.addError(new Error(JSON.stringify(result.error!.caused_by, null, 2)), {
title: i18n.translate('xpack.transform.transformList.startTransformErrorMessage', { title: i18n.translate('xpack.transform.transformList.startTransformErrorMessage', {
defaultMessage: 'An error occurred starting the transform {transformId}', defaultMessage: 'An error occurred starting the transform {transformId}',
values: { transformId }, values: { transformId },
}), }),
toastMessage: result.error!.reason, toastMessage: result.error!.reason,
}); }
);
} }
} }
} }
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); refreshTransformList();
}; },
});
return mutation.mutate;
}; };

View file

@ -6,31 +6,36 @@
*/ */
import React from 'react'; import React from 'react';
import { useMutation } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { toMountPoint } from '@kbn/react-kibana-mount';
import type { StopTransformsRequestSchema } from '../../../common/api_schemas/stop_transforms';
import { isStopTransformsResponseSchema } from '../../../common/api_schemas/type_guards';
import { addInternalBasePath } from '../../../common/constants';
import type {
StopTransformsRequestSchema,
StopTransformsResponseSchema,
} from '../../../common/api_schemas/stop_transforms';
import { getErrorMessage } from '../../../common/utils/errors'; import { getErrorMessage } from '../../../common/utils/errors';
import { useAppDependencies, useToastNotifications } from '../app_dependencies'; import { useAppDependencies, useToastNotifications } from '../app_dependencies';
import { refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common';
import { ToastNotificationText } from '../components'; import { ToastNotificationText } from '../components';
import { useApi } from './use_api'; import { useRefreshTransformList } from './use_refresh_transform_list';
export const useStopTransforms = () => { export const useStopTransforms = () => {
const { overlays, theme } = useAppDependencies(); const { http, i18n: i18nStart, theme } = useAppDependencies();
const refreshTransformList = useRefreshTransformList();
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const api = useApi();
return async (transformsInfo: StopTransformsRequestSchema) => { const mutation = useMutation({
const results = await api.stopTransforms(transformsInfo); mutationFn: (reqBody: StopTransformsRequestSchema) =>
http.post<StopTransformsResponseSchema>(addInternalBasePath('stop_transforms'), {
if (!isStopTransformsResponseSchema(results)) { body: JSON.stringify(reqBody),
version: '1',
}),
onError: (error) =>
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate( title: i18n.translate(
'xpack.transform.transformList.stopTransformResponseSchemaErrorMessage', 'xpack.transform.transformList.stopTransformResponseSchemaErrorMessage',
@ -38,29 +43,16 @@ export const useStopTransforms = () => {
defaultMessage: 'An error occurred called the stop transforms request.', defaultMessage: 'An error occurred called the stop transforms request.',
} }
), ),
text: toMountPoint( text: toMountPoint(<ToastNotificationText text={getErrorMessage(error)} />, {
<ToastNotificationText theme,
overlays={overlays} i18n: i18nStart,
theme={theme} }),
text={getErrorMessage(results)} }),
/>, onSuccess: (results) => {
{ theme$: theme.theme$ }
),
});
return;
}
for (const transformId in results) { for (const transformId in results) {
// hasOwnProperty check to ensure only properties on object itself, and not its prototypes // hasOwnProperty check to ensure only properties on object itself, and not its prototypes
if (results.hasOwnProperty(transformId)) { if (results.hasOwnProperty(transformId)) {
if (results[transformId].success === true) { if (!results[transformId].success) {
toastNotifications.addSuccess(
i18n.translate('xpack.transform.transformList.stopTransformSuccessMessage', {
defaultMessage: 'Request to stop data frame transform {transformId} acknowledged.',
values: { transformId },
})
);
} else {
toastNotifications.addDanger( toastNotifications.addDanger(
i18n.translate('xpack.transform.transformList.stopTransformErrorMessage', { i18n.translate('xpack.transform.transformList.stopTransformErrorMessage', {
defaultMessage: 'An error occurred stopping the data frame transform {transformId}', defaultMessage: 'An error occurred stopping the data frame transform {transformId}',
@ -71,6 +63,9 @@ export const useStopTransforms = () => {
} }
} }
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); refreshTransformList();
}; },
});
return mutation.mutate;
}; };

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
getInitialTransformCapabilities,
isTransformCapabilities,
} from '../../../common/types/capabilities';
import { useAppDependencies } from '../app_dependencies';
export const useTransformCapabilities = () => {
const { application } = useAppDependencies();
if (isTransformCapabilities(application?.capabilities?.transform)) {
return application.capabilities.transform;
}
return getInitialTransformCapabilities();
};

View file

@ -29,14 +29,13 @@ import {
} from '@kbn/ml-data-grid'; } from '@kbn/ml-data-grid';
import type { PreviewMappingsProperties } from '../../../common/api_schemas/transforms'; import type { PreviewMappingsProperties } from '../../../common/api_schemas/transforms';
import { isPostTransformsPreviewResponseSchema } from '../../../common/api_schemas/type_guards';
import { getErrorMessage } from '../../../common/utils/errors'; import { getErrorMessage } from '../../../common/utils/errors';
import { getPreviewTransformRequestBody, type TransformConfigQuery } from '../common'; import { getPreviewTransformRequestBody, type TransformConfigQuery } from '../common';
import { SearchItems } from './use_search_items'; import { SearchItems } from './use_search_items';
import { useApi } from './use_api'; import { useGetTransformsPreview } from './use_get_transforms_preview';
import { StepDefineExposedState } from '../sections/create_transform/components/step_define'; import { StepDefineExposedState } from '../sections/create_transform/components/step_define';
import { import {
isLatestPartialRequest, isLatestPartialRequest,
@ -111,7 +110,6 @@ export const useTransformConfigData = (
): UseIndexDataReturnType => { ): UseIndexDataReturnType => {
const [previewMappingsProperties, setPreviewMappingsProperties] = const [previewMappingsProperties, setPreviewMappingsProperties] =
useState<PreviewMappingsProperties>({}); useState<PreviewMappingsProperties>({});
const api = useApi();
// Filters mapping properties of type `object`, which get returned for nested field parents. // Filters mapping properties of type `object`, which get returned for nested field parents.
const columnKeys = Object.keys(previewMappingsProperties).filter( const columnKeys = Object.keys(previewMappingsProperties).filter(
@ -147,32 +145,32 @@ export const useTransformConfigData = (
tableItems, tableItems,
} = dataGrid; } = dataGrid;
const getPreviewData = async () => { const previewRequest = useMemo(
if (!validationStatus.isValid) { () =>
setTableItems([]); getPreviewTransformRequestBody(
setRowCountInfo({
rowCount: 0,
rowCountRelation: ES_CLIENT_TOTAL_HITS_RELATION.EQ,
});
setNoDataMessage(validationStatus.errorMessage!);
return;
}
setErrorMessage('');
setNoDataMessage('');
setStatus(INDEX_STATUS.LOADING);
const previewRequest = getPreviewTransformRequestBody(
dataView, dataView,
query, query,
requestPayload, requestPayload,
combinedRuntimeMappings, combinedRuntimeMappings,
timeRangeMs timeRangeMs
),
[dataView, query, requestPayload, combinedRuntimeMappings, timeRangeMs]
); );
const resp = await api.getTransformsPreview(previewRequest);
if (!isPostTransformsPreviewResponseSchema(resp)) { const {
setErrorMessage(getErrorMessage(resp)); error: previewError,
data: previewData,
isError,
isLoading,
} = useGetTransformsPreview(previewRequest, validationStatus.isValid);
useEffect(() => {
if (isLoading) {
setErrorMessage('');
setNoDataMessage('');
setStatus(INDEX_STATUS.LOADING);
} else if (isError) {
setErrorMessage(getErrorMessage(previewError));
setTableItems([]); setTableItems([]);
setRowCountInfo({ setRowCountInfo({
rowCount: 0, rowCount: 0,
@ -180,14 +178,12 @@ export const useTransformConfigData = (
}); });
setPreviewMappingsProperties({}); setPreviewMappingsProperties({});
setStatus(INDEX_STATUS.ERROR); setStatus(INDEX_STATUS.ERROR);
return; } else if (!isLoading && !isError && previewData !== undefined) {
}
// To improve UI performance with a latest configuration for indices with a large number // To improve UI performance with a latest configuration for indices with a large number
// of fields, we reduce the number of available columns to those populated with values. // of fields, we reduce the number of available columns to those populated with values.
// 1. Flatten the returned object structure object documents to match mapping properties // 1. Flatten the returned object structure object documents to match mapping properties
const docs = resp.preview.map(getFlattenedObject); const docs = previewData.preview.map(getFlattenedObject);
// 2. Get all field names for each returned doc and flatten it // 2. Get all field names for each returned doc and flatten it
// to a list of unique field names used across all docs. // to a list of unique field names used across all docs.
@ -195,7 +191,7 @@ export const useTransformConfigData = (
// 3. Filter mapping properties by populated fields // 3. Filter mapping properties by populated fields
let populatedProperties: PreviewMappingsProperties = Object.entries( let populatedProperties: PreviewMappingsProperties = Object.entries(
resp.generated_dest_index.mappings.properties previewData.generated_dest_index.mappings.properties
) )
.filter(([key]) => populatedFields.includes(key)) .filter(([key]) => populatedFields.includes(key))
.reduce( .reduce(
@ -223,8 +219,26 @@ export const useTransformConfigData = (
'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.', 'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.',
}) })
); );
} else {
setNoDataMessage('');
} }
}; }
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isError, isLoading, previewData]);
useEffect(() => {
if (!validationStatus.isValid) {
setTableItems([]);
setRowCountInfo({
rowCount: 0,
rowCountRelation: ES_CLIENT_TOTAL_HITS_RELATION.EQ,
});
setNoDataMessage(validationStatus.errorMessage!);
}
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [validationStatus.isValid]);
useEffect(() => { useEffect(() => {
resetPagination(); resetPagination();
@ -232,15 +246,6 @@ export const useTransformConfigData = (
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(query)]); }, [JSON.stringify(query)]);
useEffect(() => {
getPreviewData();
// custom comparison
/* eslint-disable react-hooks/exhaustive-deps */
}, [
dataView.getIndexPattern(),
JSON.stringify([requestPayload, query, combinedRuntimeMappings, timeRangeMs]),
]);
if (sortingColumns.length > 0) { if (sortingColumns.length > 0) {
const sortingColumnsWithTypes = sortingColumns.map((c) => { const sortingColumnsWithTypes = sortingColumns.map((c) => {
// Since items might contain undefined/null values, we want to accurate find the data type // Since items might contain undefined/null values, we want to accurate find the data type
@ -291,13 +296,7 @@ export const useTransformConfigData = (
return cellValue; return cellValue;
}; };
}, [ }, [pageData, pagination.pageIndex, pagination.pageSize, previewMappingsProperties]);
pageData,
pagination.pageIndex,
pagination.pageSize,
previewMappingsProperties,
formatHumanReadableDateTimeSeconds,
]);
return { return {
...dataGrid, ...dataGrid,

View file

@ -0,0 +1,41 @@
/*
* 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 } from '@tanstack/react-query';
import type {
PostTransformsUpdateRequestSchema,
PostTransformsUpdateResponseSchema,
} from '../../../common/api_schemas/update_transforms';
import { addInternalBasePath } from '../../../common/constants';
import type { TransformId } from '../../../common/types/transform';
import { useAppDependencies } from '../app_dependencies';
import { useRefreshTransformList } from './use_refresh_transform_list';
export const useUpdateTransform = (
transformId: TransformId,
transformConfig: PostTransformsUpdateRequestSchema
) => {
const { http } = useAppDependencies();
const refreshTransformList = useRefreshTransformList();
const mutation = useMutation({
mutationFn: () =>
http.post<PostTransformsUpdateResponseSchema>(
addInternalBasePath(`transforms/${transformId}/_update`),
{
body: JSON.stringify(transformConfig),
version: '1',
}
),
onSuccess: () => refreshTransformList(),
});
return mutation.mutate;
};

View file

@ -1,83 +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, { createContext } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { Privileges } from '../../../../../common/types/privileges';
import {
type PrivilegesAndCapabilities,
type TransformCapabilities,
INITIAL_CAPABILITIES,
} from '../../../../../common/privilege/has_privilege_factory';
import { useAppDependencies } from '../../../app_dependencies';
interface Authorization {
isLoading: boolean;
apiError: Error | null;
privileges: Privileges;
capabilities: TransformCapabilities;
}
const initialValue: Authorization = {
isLoading: true,
apiError: null,
privileges: {
hasAllPrivileges: false,
missingPrivileges: {},
},
capabilities: INITIAL_CAPABILITIES,
};
export const AuthorizationContext = createContext<Authorization>({ ...initialValue });
interface Props {
privilegesEndpoint: { path: string; version: string };
children: React.ReactNode;
}
export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) => {
const { http } = useAppDependencies();
const { path, version } = privilegesEndpoint;
const {
isLoading,
error,
data: privilegesData,
} = useQuery<PrivilegesAndCapabilities, IHttpFetchError>(
['transform-privileges-and-capabilities'],
async ({ signal }) => {
return await http.fetch<PrivilegesAndCapabilities>(path, {
version,
method: 'GET',
signal,
});
}
);
const value = {
isLoading,
privileges:
isLoading || privilegesData === undefined
? { ...initialValue.privileges }
: privilegesData.privileges,
capabilities:
isLoading || privilegesData === undefined
? { ...INITIAL_CAPABILITIES }
: privilegesData.capabilities,
apiError: error ? error : null,
};
return (
<AuthorizationContext.Provider value={{ ...value }}>{children}</AuthorizationContext.Provider>
);
};

View file

@ -1,11 +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.
*/
export { createCapabilityFailureMessage } from '../../../../../common/privilege/has_privilege_factory';
export { AuthorizationProvider, AuthorizationContext } from './authorization_provider';
export { PrivilegesWrapper } from './with_privileges';
export { NotAuthorizedSection } from './not_authorized_section';

View file

@ -1,23 +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 { EuiPageTemplate } from '@elastic/eui';
interface Props {
title: React.ReactNode;
message: React.ReactNode | string;
}
export const NotAuthorizedSection = ({ title, message }: Props) => (
<EuiPageTemplate.EmptyPrompt
color="danger"
iconType="securityApp"
title={<h2>{title}</h2>}
body={<p>{message}</p>}
/>
);

View file

@ -1,125 +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, { useContext, FC } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { MissingPrivileges } from '../../../../../common/types/privileges';
import { SectionLoading } from '../../../components';
import { AuthorizationContext } from './authorization_provider';
import { NotAuthorizedSection } from './not_authorized_section';
import {
hasPrivilegeFactory,
toArray,
Privilege,
} from '../../../../../common/privilege/has_privilege_factory';
interface Props {
/**
* Each required privilege must have the format "section.privilege".
* To indicate that *all* privileges from a section are required, we can use the asterix
* e.g. "index.*"
*/
privileges: string | string[];
children: (childrenProps: {
isLoading: boolean;
hasPrivileges: boolean;
privilegesMissing: MissingPrivileges;
}) => JSX.Element;
}
export const WithPrivileges = ({ privileges: requiredPrivileges, children }: Props) => {
const { isLoading, privileges } = useContext(AuthorizationContext);
const privilegesToArray: Privilege[] = toArray(requiredPrivileges).map((p) => {
const [section, privilege] = p.split('.');
if (!privilege) {
// Oh! we forgot to use the dot "." notation.
throw new Error('Required privilege must have the format "section.privilege"');
}
return [section, privilege];
});
const hasPrivilege = hasPrivilegeFactory(privileges);
const hasPrivileges = isLoading ? false : privilegesToArray.every(hasPrivilege);
const privilegesMissing = privilegesToArray.reduce((acc, [section, privilege]) => {
if (privilege === '*') {
acc[section] = privileges.missingPrivileges[section] || [];
} else if (
privileges.missingPrivileges[section] &&
privileges.missingPrivileges[section]!.includes(privilege)
) {
const missing: string[] = acc[section] || [];
acc[section] = [...missing, privilege];
}
return acc;
}, {} as MissingPrivileges);
return children({ isLoading, hasPrivileges, privilegesMissing });
};
interface MissingClusterPrivilegesProps {
missingPrivileges: string;
privilegesCount: number;
}
const MissingClusterPrivileges: FC<MissingClusterPrivilegesProps> = ({
missingPrivileges,
privilegesCount,
}) => (
<NotAuthorizedSection
title={
<FormattedMessage
id="xpack.transform.app.deniedPrivilegeTitle"
defaultMessage="You're missing cluster privileges"
/>
}
message={
<FormattedMessage
id="xpack.transform.app.deniedPrivilegeDescription"
defaultMessage="To use this section of Transforms, you must have {privilegesCount,
plural, one {this cluster privilege} other {these cluster privileges}}: {missingPrivileges}."
values={{
missingPrivileges,
privilegesCount,
}}
/>
}
/>
);
export const PrivilegesWrapper: FC<{ privileges: string | string[] }> = ({
children,
privileges,
}) => (
<WithPrivileges privileges={privileges}>
{({ isLoading, hasPrivileges, privilegesMissing }) => {
if (isLoading) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.transform.app.checkingPrivilegesDescription"
defaultMessage="Checking privileges…"
/>
</SectionLoading>
);
}
if (!hasPrivileges) {
return (
<MissingClusterPrivileges
missingPrivileges={privilegesMissing.cluster!.join(', ')}
privilegesCount={privilegesMissing.cluster!.length}
/>
);
}
return <>{children}</>;
}}
</WithPrivileges>
);

View file

@ -13,16 +13,15 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiCallOut, EuiPageTemplate, EuiSpacer } from '@elastic/eui'; import { EuiButtonEmpty, EuiCallOut, EuiPageTemplate, EuiSpacer } from '@elastic/eui';
import { isHttpFetchError } from '@kbn/core-http-browser';
import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants';
import { TransformConfigUnion } from '../../../../common/types/transform'; import { TransformConfigUnion } from '../../../../common/types/transform';
import { useApi } from '../../hooks/use_api'; import { useGetTransform } from '../../hooks';
import { useDocumentationLinks } from '../../hooks/use_documentation_links'; import { useDocumentationLinks } from '../../hooks/use_documentation_links';
import { useSearchItems } from '../../hooks/use_search_items'; import { useSearchItems } from '../../hooks/use_search_items';
import { BREADCRUMB_SECTION, breadcrumbService, docTitleService } from '../../services/navigation'; import { BREADCRUMB_SECTION, breadcrumbService, docTitleService } from '../../services/navigation';
import { PrivilegesWrapper } from '../../lib/authorization'; import { CapabilitiesWrapper } from '../../components/capabilities_wrapper';
import { Wizard } from '../create_transform/components/wizard'; import { Wizard } from '../create_transform/components/wizard';
import { overrideTransformForCloning } from '../../common/transform'; import { overrideTransformForCloning } from '../../common/transform';
@ -39,8 +38,6 @@ export const CloneTransformSection: FC<Props> = ({ match, location }) => {
docTitleService.setTitle('createTransform'); docTitleService.setTitle('createTransform');
}, []); }, []);
const api = useApi();
const { esTransform } = useDocumentationLinks(); const { esTransform } = useDocumentationLinks();
const transformId = match.params.transformId; const transformId = match.params.transformId;
@ -50,33 +47,41 @@ export const CloneTransformSection: FC<Props> = ({ match, location }) => {
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const { error: searchItemsError, searchItems, setSavedObjectId } = useSearchItems(undefined); const { error: searchItemsError, searchItems, setSavedObjectId } = useSearchItems(undefined);
const fetchTransformConfig = async () => { useEffect(() => {
if (searchItemsError !== undefined) {
setTransformConfig(undefined);
setErrorMessage(searchItemsError);
setIsInitialized(true);
return;
}
const transformConfigs = await api.getTransform(transformId);
if (isHttpFetchError(transformConfigs)) {
setTransformConfig(undefined);
setErrorMessage(transformConfigs.message);
setIsInitialized(true);
return;
}
try {
if (dataViewId === undefined) { if (dataViewId === undefined) {
throw new Error( setErrorMessage(
i18n.translate('xpack.transform.clone.fetchErrorPromptText', { i18n.translate('xpack.transform.clone.fetchErrorPromptText', {
defaultMessage: 'Could not fetch the Kibana data view ID.', defaultMessage: 'Could not fetch the Kibana data view ID.',
}) })
); );
} else {
setSavedObjectId(dataViewId);
}
}, [dataViewId, setSavedObjectId]);
useEffect(() => {
if (searchItemsError !== undefined) {
setTransformConfig(undefined);
setErrorMessage(searchItemsError);
setIsInitialized(true);
}
}, [searchItemsError]);
const { data: transformConfigs, error } = useGetTransform(
transformId,
searchItemsError === undefined
);
useEffect(() => {
if (error !== null && error.message !== errorMessage) {
setTransformConfig(undefined);
setErrorMessage(error.message);
setIsInitialized(true);
return;
} }
setSavedObjectId(dataViewId); if (transformConfigs !== undefined) {
try {
setTransformConfig(overrideTransformForCloning(transformConfigs.transforms[0])); setTransformConfig(overrideTransformForCloning(transformConfigs.transforms[0]));
setErrorMessage(undefined); setErrorMessage(undefined);
setIsInitialized(true); setIsInitialized(true);
@ -89,13 +94,8 @@ export const CloneTransformSection: FC<Props> = ({ match, location }) => {
} }
setIsInitialized(true); setIsInitialized(true);
} }
}; }
}, [error, errorMessage, transformConfigs]);
useEffect(() => {
fetchTransformConfig();
// The effect should only be called once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const docsLink = ( const docsLink = (
<EuiButtonEmpty <EuiButtonEmpty
@ -112,7 +112,14 @@ export const CloneTransformSection: FC<Props> = ({ match, location }) => {
); );
return ( return (
<PrivilegesWrapper privileges={APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES}> <CapabilitiesWrapper
requiredCapabilities={[
'canGetTransform',
'canPreviewTransform',
'canCreateTransform',
'canStartStopTransform',
]}
>
<EuiPageTemplate.Header <EuiPageTemplate.Header
pageTitle={ pageTitle={
<FormattedMessage <FormattedMessage
@ -147,6 +154,6 @@ export const CloneTransformSection: FC<Props> = ({ match, location }) => {
<Wizard cloneConfig={transformConfig} searchItems={searchItems} /> <Wizard cloneConfig={transformConfig} searchItems={searchItems} />
)} )}
</EuiPageTemplate.Section> </EuiPageTemplate.Section>
</PrivilegesWrapper> </CapabilitiesWrapper>
); );
}; };

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSwitch } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSwitch } from '@elastic/eui';

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { EuiSwitch } from '@elastic/eui'; import { EuiSwitch } from '@elastic/eui';

View file

@ -5,7 +5,8 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { EuiSwitch } from '@elastic/eui'; import { EuiSwitch } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { SwitchModal } from './switch_modal'; import { SwitchModal } from './switch_modal';

View file

@ -5,7 +5,8 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { EuiConfirmModal } from '@elastic/eui'; import { EuiConfirmModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';

View file

@ -5,7 +5,8 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { import {
EuiButton, EuiButton,
EuiButtonIcon, EuiButtonIcon,

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';

View file

@ -1,72 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: <AggLabelForm /> Date histogram aggregation 1`] = `
<Fragment>
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
className="transform__AggregationLabel--text"
>
<span
className="eui-textTruncate"
data-test-subj="transformAggregationEntryLabel"
>
the-group-by-agg-name
</span>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--button"
grow={false}
>
<EuiPopover
anchorPosition="downCenter"
button={
<EuiButtonIcon
aria-label="Edit aggregation"
data-test-subj="transformAggregationEntryEditButton_the-group-by-agg-name"
iconType="pencil"
onClick={[Function]}
size="s"
/>
}
closePopover={[Function]}
display="inline-block"
hasArrow={true}
id="transformFormPopover"
isOpen={false}
ownFocus={true}
panelPaddingSize="m"
>
<PopoverForm
defaultData={
Object {
"agg": "cardinality",
"aggName": "the-group-by-agg-name",
"dropDownName": "the-group-by-drop-down-name",
"field": "the-group-by-field",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--button"
grow={false}
>
<EuiButtonIcon
aria-label="Delete item"
data-test-subj="transformAggregationEntryDeleteButton"
iconType="cross"
onClick={[Function]}
size="s"
/>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
`;

View file

@ -1,28 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: <AggListForm /> Minimal initialization 1`] = `
<Fragment>
<EuiPanel
data-test-subj="transformAggregationEntry_0"
paddingSize="s"
>
<AggLabelForm
deleteHandler={[Function]}
item={
Object {
"agg": "avg",
"aggName": "the-group-by-agg-name",
"dropDownName": "the-group-by-drop-down-name",
"field": "the-field",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPanel>
<EuiSpacer
size="s"
/>
</Fragment>
`;

View file

@ -1,18 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: <AggListSummary /> Minimal initialization 1`] = `
<EuiForm>
<EuiPanel
paddingSize="s"
>
<div
className="eui-textTruncate"
>
the-agg
</div>
</EuiPanel>
<EuiSpacer
size="s"
/>
</EuiForm>
`;

View file

@ -1,48 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: Aggregation <PopoverForm /> Minimal initialization 1`] = `
<EuiForm
css={
Object {
"width": "300px",
}
}
data-test-subj="transformAggPopoverForm_the-group-by-agg-name"
>
<EuiFormRow
describedByIds={Array []}
display="row"
error={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
helpText=""
isInvalid={false}
label="Aggregation name"
labelType="label"
>
<EuiFieldText
data-test-subj="transformAggName"
isInvalid={false}
onChange={[Function]}
value="the-group-by-agg-name"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
hasChildLabel={true}
hasEmptyLabelSpace={true}
labelType="label"
>
<EuiButton
color="primary"
data-test-subj="transformApplyAggChanges"
isDisabled={false}
onClick={[Function]}
size="m"
>
Apply
</EuiButton>
</EuiFormRow>
</EuiForm>
`;

View file

@ -5,8 +5,8 @@
* 2.0. * 2.0.
*/ */
import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react';
import { AggName } from '../../../../../../common/types/aggregations'; import { AggName } from '../../../../../../common/types/aggregations';
import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs';
@ -31,8 +31,8 @@ describe('Transform: <AggLabelForm />', () => {
onChange() {}, onChange() {},
}; };
const wrapper = shallow(<AggLabelForm {...props} />); const { container } = render(<AggLabelForm {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toBe('the-group-by-agg-name');
}); });
}); });

View file

@ -5,8 +5,8 @@
* 2.0. * 2.0.
*/ */
import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react';
import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs';
@ -29,8 +29,8 @@ describe('Transform: <AggListForm />', () => {
onChange() {}, onChange() {},
}; };
const wrapper = shallow(<AggListForm {...props} />); const { container } = render(<AggListForm {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toBe('the-group-by-agg-name');
}); });
}); });

View file

@ -5,8 +5,8 @@
* 2.0. * 2.0.
*/ */
import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react';
import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs';
@ -26,8 +26,8 @@ describe('Transform: <AggListSummary />', () => {
list: { 'the-agg': item }, list: { 'the-agg': item },
}; };
const wrapper = shallow(<AggListSummary {...props} />); const { container } = render(<AggListSummary {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toBe('the-agg');
}); });
}); });

View file

@ -5,7 +5,6 @@
* 2.0. * 2.0.
*/ */
import { shallow } from 'enzyme';
import { fireEvent, render } from '@testing-library/react'; import { fireEvent, render } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { AggName } from '../../../../../../common/types/aggregations'; import { AggName } from '../../../../../../common/types/aggregations';
@ -29,7 +28,7 @@ describe('Transform: Aggregation <PopoverForm />', () => {
const otherAggNames: AggName[] = []; const otherAggNames: AggName[] = [];
const onChange = (item: PivotAggsConfig) => {}; const onChange = (item: PivotAggsConfig) => {};
const wrapper = shallow( const { getByTestId } = render(
<PopoverForm <PopoverForm
defaultData={defaultData} defaultData={defaultData}
otherAggNames={otherAggNames} otherAggNames={otherAggNames}
@ -38,7 +37,8 @@ describe('Transform: Aggregation <PopoverForm />', () => {
/> />
); );
expect(wrapper).toMatchSnapshot(); const input = getByTestId('transformAggName');
expect(input).toHaveValue('the-group-by-agg-name');
}); });
test('preserves the field for unsupported aggs', async () => { test('preserves the field for unsupported aggs', async () => {

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { EuiSwitch } from '@elastic/eui'; import { EuiSwitch } from '@elastic/eui';

View file

@ -1,234 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: <GroupByLabelForm /> Date histogram aggregation 1`] = `
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
className="transform__GroupByLabel--text"
>
<span
className="eui-textTruncate"
data-test-subj="transformGroupByEntryLabel"
>
the-group-by-agg-name
</span>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--text transform__GroupByLabel--interval"
grow={false}
>
<EuiTextColor
className="eui-textTruncate"
color="subdued"
data-test-subj="transformGroupByEntryIntervalLabel"
>
1m
</EuiTextColor>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--button"
grow={false}
>
<EuiPopover
anchorPosition="downCenter"
button={
<EuiButtonIcon
aria-label="Edit interval"
data-test-subj="transformGroupByEntryEditButton"
iconType="pencil"
onClick={[Function]}
size="s"
/>
}
closePopover={[Function]}
display="inline-block"
hasArrow={true}
id="transformIntervalFormPopover"
isOpen={false}
ownFocus={true}
panelPaddingSize="m"
>
<PopoverForm
defaultData={
Object {
"agg": "date_histogram",
"aggName": "the-group-by-agg-name",
"calendar_interval": "1m",
"dropDownName": "the-group-by-drop-down-name",
"field": "the-group-by-field",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--button"
grow={false}
>
<EuiButtonIcon
aria-label="Delete item"
data-test-subj="transformGroupByEntryDeleteButton"
iconType="cross"
onClick={[Function]}
size="s"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`Transform: <GroupByLabelForm /> Histogram aggregation 1`] = `
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
className="transform__GroupByLabel--text"
>
<span
className="eui-textTruncate"
data-test-subj="transformGroupByEntryLabel"
>
the-group-by-agg-name
</span>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--text transform__GroupByLabel--interval"
grow={false}
>
<EuiTextColor
className="eui-textTruncate"
color="subdued"
data-test-subj="transformGroupByEntryIntervalLabel"
>
100
</EuiTextColor>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--button"
grow={false}
>
<EuiPopover
anchorPosition="downCenter"
button={
<EuiButtonIcon
aria-label="Edit interval"
data-test-subj="transformGroupByEntryEditButton"
iconType="pencil"
onClick={[Function]}
size="s"
/>
}
closePopover={[Function]}
display="inline-block"
hasArrow={true}
id="transformIntervalFormPopover"
isOpen={false}
ownFocus={true}
panelPaddingSize="m"
>
<PopoverForm
defaultData={
Object {
"agg": "histogram",
"aggName": "the-group-by-agg-name",
"dropDownName": "the-group-by-drop-down-name",
"field": "the-group-by-field",
"interval": "100",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--button"
grow={false}
>
<EuiButtonIcon
aria-label="Delete item"
data-test-subj="transformGroupByEntryDeleteButton"
iconType="cross"
onClick={[Function]}
size="s"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`Transform: <GroupByLabelForm /> Terms aggregation 1`] = `
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
className="transform__GroupByLabel--text"
>
<span
className="eui-textTruncate"
data-test-subj="transformGroupByEntryLabel"
>
the-group-by-agg-name
</span>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--button"
grow={false}
>
<EuiPopover
anchorPosition="downCenter"
button={
<EuiButtonIcon
aria-label="Edit interval"
data-test-subj="transformGroupByEntryEditButton"
iconType="pencil"
onClick={[Function]}
size="s"
/>
}
closePopover={[Function]}
display="inline-block"
hasArrow={true}
id="transformIntervalFormPopover"
isOpen={false}
ownFocus={true}
panelPaddingSize="m"
>
<PopoverForm
defaultData={
Object {
"agg": "terms",
"aggName": "the-group-by-agg-name",
"dropDownName": "the-group-by-drop-down-name",
"field": "the-group-by-field",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--button"
grow={false}
>
<EuiButtonIcon
aria-label="Delete item"
data-test-subj="transformGroupByEntryDeleteButton"
iconType="cross"
onClick={[Function]}
size="s"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -1,77 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: <GroupByLabelSummary /> Date histogram aggregation 1`] = `
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
className="transform__GroupByLabel--text"
>
<span
className="eui-textTruncate"
>
the-options-data-id
</span>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--text transform__GroupByLabel--interval"
grow={false}
>
<EuiTextColor
className="eui-textTruncate"
color="subdued"
>
1m
</EuiTextColor>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`Transform: <GroupByLabelSummary /> Histogram aggregation 1`] = `
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
className="transform__GroupByLabel--text"
>
<span
className="eui-textTruncate"
>
the-options-data-id
</span>
</EuiFlexItem>
<EuiFlexItem
className="transform__GroupByLabel--text transform__GroupByLabel--interval"
grow={false}
>
<EuiTextColor
className="eui-textTruncate"
color="subdued"
>
100
</EuiTextColor>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`Transform: <GroupByLabelSummary /> Terms aggregation 1`] = `
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
className="transform__GroupByLabel--text"
>
<span
className="eui-textTruncate"
>
the-options-data-id
</span>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -1,28 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: <GroupByListForm /> Minimal initialization 1`] = `
<Fragment>
<EuiPanel
data-test-subj="transformGroupByEntry 0"
paddingSize="s"
>
<GroupByLabelForm
deleteHandler={[Function]}
item={
Object {
"agg": "terms",
"aggName": "the-group-by-agg-name",
"dropDownName": "the-group-by-drop-down-name",
"field": "the-group-by-field",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPanel>
<EuiSpacer
size="s"
/>
</Fragment>
`;

View file

@ -1,24 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: <GroupByListSummary /> Minimal initialization 1`] = `
<Fragment>
<EuiPanel
paddingSize="s"
>
<GroupByLabelSummary
item={
Object {
"agg": "terms",
"aggName": "the-group-by-agg-name",
"dropDownName": "the-group-by-drop-down-name",
"field": "the-group-by-field",
}
}
optionsDataId="the-options-data-id"
/>
</EuiPanel>
<EuiSpacer
size="s"
/>
</Fragment>
`;

View file

@ -1,18 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: Group By <PopoverForm /> Minimal initialization 1`] = `
<PopoverForm
defaultData={
Object {
"agg": "date_histogram",
"aggName": "the-agg-name",
"calendar_interval": "1m",
"dropDownName": "the-drop-down-name",
"field": "the-field",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
`;

View file

@ -5,8 +5,8 @@
* 2.0. * 2.0.
*/ */
import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react';
import { PivotGroupByConfig, PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../../../common'; import { PivotGroupByConfig, PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../../../common';
@ -29,9 +29,9 @@ describe('Transform: <GroupByLabelForm />', () => {
onChange() {}, onChange() {},
}; };
const wrapper = shallow(<GroupByLabelForm {...props} />); const { container } = render(<GroupByLabelForm {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toContain('the-group-by-agg-name');
}); });
test('Histogram aggregation', () => { test('Histogram aggregation', () => {
@ -50,9 +50,9 @@ describe('Transform: <GroupByLabelForm />', () => {
onChange() {}, onChange() {},
}; };
const wrapper = shallow(<GroupByLabelForm {...props} />); const { container } = render(<GroupByLabelForm {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toContain('the-group-by-agg-name');
}); });
test('Terms aggregation', () => { test('Terms aggregation', () => {
@ -70,8 +70,8 @@ describe('Transform: <GroupByLabelForm />', () => {
onChange() {}, onChange() {},
}; };
const wrapper = shallow(<GroupByLabelForm {...props} />); const { container } = render(<GroupByLabelForm {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toContain('the-group-by-agg-name');
}); });
}); });

View file

@ -5,8 +5,8 @@
* 2.0. * 2.0.
*/ */
import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react';
import { PivotGroupByConfig, PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../../../common'; import { PivotGroupByConfig, PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../../../common';
@ -26,9 +26,9 @@ describe('Transform: <GroupByLabelSummary />', () => {
optionsDataId: 'the-options-data-id', optionsDataId: 'the-options-data-id',
}; };
const wrapper = shallow(<GroupByLabelSummary {...props} />); const { container } = render(<GroupByLabelSummary {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toContain('the-options-data-id');
}); });
test('Histogram aggregation', () => { test('Histogram aggregation', () => {
@ -44,9 +44,9 @@ describe('Transform: <GroupByLabelSummary />', () => {
optionsDataId: 'the-options-data-id', optionsDataId: 'the-options-data-id',
}; };
const wrapper = shallow(<GroupByLabelSummary {...props} />); const { container } = render(<GroupByLabelSummary {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toContain('the-options-data-id');
}); });
test('Terms aggregation', () => { test('Terms aggregation', () => {
@ -61,8 +61,8 @@ describe('Transform: <GroupByLabelSummary />', () => {
optionsDataId: 'the-options-data-id', optionsDataId: 'the-options-data-id',
}; };
const wrapper = shallow(<GroupByLabelSummary {...props} />); const { container } = render(<GroupByLabelSummary {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toContain('the-options-data-id');
}); });
}); });

View file

@ -5,8 +5,8 @@
* 2.0. * 2.0.
*/ */
import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react';
import { PivotGroupByConfig, PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../../../common'; import { PivotGroupByConfig, PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../../../common';
@ -27,8 +27,8 @@ describe('Transform: <GroupByListForm />', () => {
onChange() {}, onChange() {},
}; };
const wrapper = shallow(<GroupByListForm {...props} />); const { container } = render(<GroupByListForm {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toContain('the-group-by-agg-name');
}); });
}); });

View file

@ -5,8 +5,8 @@
* 2.0. * 2.0.
*/ */
import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react';
import { PivotGroupByConfig, PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../../../common'; import { PivotGroupByConfig, PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../../../common';
@ -24,8 +24,8 @@ describe('Transform: <GroupByListSummary />', () => {
list: { 'the-options-data-id': item }, list: { 'the-options-data-id': item },
}; };
const wrapper = shallow(<GroupByListSummary {...props} />); const { container } = render(<GroupByListSummary {...props} />);
expect(wrapper).toMatchSnapshot(); expect(container.textContent).toContain('the-options-data-id');
}); });
}); });

View file

@ -5,8 +5,8 @@
* 2.0. * 2.0.
*/ */
import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
@ -101,7 +101,7 @@ describe('Transform: Group By <PopoverForm />', () => {
appName: 'the-test-app', appName: 'the-test-app',
}; };
const wrapper = shallow( const { getByDisplayValue } = render(
<KibanaContextProvider services={services}> <KibanaContextProvider services={services}>
<PopoverForm <PopoverForm
defaultData={defaultData} defaultData={defaultData}
@ -112,6 +112,7 @@ describe('Transform: Group By <PopoverForm />', () => {
</KibanaContextProvider> </KibanaContextProvider>
); );
expect(wrapper.find(PopoverForm)).toMatchSnapshot(); expect(getByDisplayValue('the-agg-name')).toBeInTheDocument();
expect(getByDisplayValue('1m')).toBeInTheDocument();
}); });
}); });

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { EuiCode, EuiInputPopover } from '@elastic/eui'; import { EuiCode, EuiInputPopover } from '@elastic/eui';

View file

@ -7,6 +7,7 @@
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StepCreateForm, StepCreateFormProps } from './step_create_form'; import { StepCreateForm, StepCreateFormProps } from './step_create_form';
@ -16,6 +17,7 @@ jest.mock('../../../../app_dependencies');
describe('Transform: <StepCreateForm />', () => { describe('Transform: <StepCreateForm />', () => {
test('Minimal initialization', () => { test('Minimal initialization', () => {
// Arrange // Arrange
const queryClient = new QueryClient();
const props: StepCreateFormProps = { const props: StepCreateFormProps = {
createDataView: false, createDataView: false,
transformId: 'the-transform-id', transformId: 'the-transform-id',
@ -35,7 +37,11 @@ describe('Transform: <StepCreateForm />', () => {
onChange() {}, onChange() {},
}; };
const { getByText } = render(<StepCreateForm {...props} />); const { getByText } = render(
<QueryClientProvider client={queryClient}>
<StepCreateForm {...props} />
</QueryClientProvider>
);
// Act // Act
// Assert // Assert

View file

@ -24,25 +24,19 @@ import {
} from '@elastic/eui'; } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { toMountPoint } from '@kbn/react-kibana-mount';
import { DISCOVER_APP_LOCATOR } from '@kbn/discover-plugin/common'; import { DISCOVER_APP_LOCATOR } from '@kbn/discover-plugin/common';
import { DuplicateDataViewError } from '@kbn/data-plugin/public'; import { DuplicateDataViewError } from '@kbn/data-plugin/public';
import type { RuntimeField } from '@kbn/data-views-plugin/common'; import type { RuntimeField } from '@kbn/data-views-plugin/common';
import { isPopulatedObject } from '@kbn/ml-is-populated-object'; import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import type { PutTransformsResponseSchema } from '../../../../../../common/api_schemas/transforms';
import {
isGetTransformsStatsResponseSchema,
isPutTransformsResponseSchema,
isStartTransformsResponseSchema,
} from '../../../../../../common/api_schemas/type_guards';
import { PROGRESS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants'; import { PROGRESS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants';
import { getErrorMessage } from '../../../../../../common/utils/errors'; import { getErrorMessage } from '../../../../../../common/utils/errors';
import { getTransformProgress } from '../../../../common'; import { getTransformProgress } from '../../../../common';
import { useApi } from '../../../../hooks/use_api'; import { useCreateTransform, useGetTransformStats, useStartTransforms } from '../../../../hooks';
import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies';
import { RedirectToTransformManagement } from '../../../../common/navigation'; import { RedirectToTransformManagement } from '../../../../common/navigation';
import { ToastNotificationText } from '../../../../components'; import { ToastNotificationText } from '../../../../components';
@ -92,11 +86,10 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
); );
const [discoverLink, setDiscoverLink] = useState<string>(); const [discoverLink, setDiscoverLink] = useState<string>();
const deps = useAppDependencies();
const { share } = deps;
const dataViews = deps.data.dataViews;
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const isDiscoverAvailable = deps.application.capabilities.discover?.show ?? false; const { application, data, i18n: i18nStart, share, theme } = useAppDependencies();
const dataViews = data.dataViews;
const isDiscoverAvailable = application.capabilities.discover?.show ?? false;
useEffect(() => { useEffect(() => {
let unmounted = false; let unmounted = false;
@ -128,104 +121,38 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [created, started, dataViewId]); }, [created, started, dataViewId]);
const { overlays, theme } = useAppDependencies(); const startTransforms = useStartTransforms();
const api = useApi(); const createTransform = useCreateTransform();
async function createTransform() { function createTransformHandler(startAfterCreation = false) {
setLoading(true); setLoading(true);
const resp = await api.createTransform(transformId, transformConfig); createTransform(
{ transformId, transformConfig },
if (!isPutTransformsResponseSchema(resp) || resp.errors.length > 0) { {
let respErrors: onError: () => setCreated(false),
| PutTransformsResponseSchema['errors'] onSuccess: () => {
| PutTransformsResponseSchema['errors'][number]
| undefined;
if (isPutTransformsResponseSchema(resp) && resp.errors.length > 0) {
respErrors = resp.errors.length === 1 ? resp.errors[0] : resp.errors;
}
toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepCreateForm.createTransformErrorMessage', {
defaultMessage: 'An error occurred creating the transform {transformId}:',
values: { transformId },
}),
text: toMountPoint(
<ToastNotificationText
overlays={overlays}
theme={theme}
text={getErrorMessage(isPutTransformsResponseSchema(resp) ? respErrors : resp)}
/>,
{ theme$: theme.theme$ }
),
});
setCreated(false);
setLoading(false);
return false;
}
toastNotifications.addSuccess(
i18n.translate('xpack.transform.stepCreateForm.createTransformSuccessMessage', {
defaultMessage: 'Request to create transform {transformId} acknowledged.',
values: { transformId },
})
);
setCreated(true); setCreated(true);
setLoading(false);
if (createDataView) { if (createDataView) {
createKibanaDataView(); createKibanaDataView();
} }
if (startAfterCreation) {
return true; startTransform();
}
},
onSettled: () => setLoading(false),
}
);
} }
async function startTransform() { function startTransform() {
setLoading(true); setLoading(true);
const resp = await api.startTransforms([{ id: transformId }]); startTransforms([{ id: transformId }], {
onError: () => setStarted(false),
if (isStartTransformsResponseSchema(resp) && resp[transformId]?.success === true) { onSuccess: (resp) => setStarted(resp[transformId]?.success === true),
toastNotifications.addSuccess( onSettled: () => setLoading(false),
i18n.translate('xpack.transform.stepCreateForm.startTransformSuccessMessage', {
defaultMessage: 'Request to start transform {transformId} acknowledged.',
values: { transformId },
})
);
setStarted(true);
setLoading(false);
return;
}
const errorMessage =
isStartTransformsResponseSchema(resp) && resp[transformId]?.success === false
? resp[transformId].error
: resp;
toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepCreateForm.startTransformErrorMessage', {
defaultMessage: 'An error occurred starting the transform {transformId}:',
values: { transformId },
}),
text: toMountPoint(
<ToastNotificationText
overlays={overlays}
theme={theme}
text={getErrorMessage(errorMessage)}
/>,
{ theme$: theme.theme$ }
),
}); });
setStarted(false);
setLoading(false);
}
async function createAndStartTransform() {
const acknowledged = await createTransform();
if (acknowledged) {
await startTransform();
}
} }
const createKibanaDataView = async () => { const createKibanaDataView = async () => {
@ -250,13 +177,6 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
true true
); );
toastNotifications.addSuccess(
i18n.translate('xpack.transform.stepCreateForm.createDataViewSuccessMessage', {
defaultMessage: 'Kibana data view {dataViewName} created successfully.',
values: { dataViewName },
})
);
setDataViewId(newDataView.id); setDataViewId(newDataView.id);
setLoading(false); setLoading(false);
return true; return true;
@ -275,10 +195,10 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
defaultMessage: 'An error occurred creating the Kibana data view {dataViewName}:', defaultMessage: 'An error occurred creating the Kibana data view {dataViewName}:',
values: { dataViewName }, values: { dataViewName },
}), }),
text: toMountPoint( text: toMountPoint(<ToastNotificationText text={getErrorMessage(e)} />, {
<ToastNotificationText overlays={overlays} theme={theme} text={getErrorMessage(e)} />, theme,
{ theme$: theme.theme$ } i18n: i18nStart,
), }),
}); });
setLoading(false); setLoading(false);
return false; return false;
@ -288,22 +208,37 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
const isBatchTransform = typeof transformConfig.sync === 'undefined'; const isBatchTransform = typeof transformConfig.sync === 'undefined';
useEffect(() => {
if ( if (
loading === false && loading === false &&
started === true && started === true &&
progressPercentComplete === undefined && progressPercentComplete === undefined &&
isBatchTransform isBatchTransform
) { ) {
// wrapping in function so we can keep the interval id in local scope setProgressPercentComplete(0);
function startProgressBar() { }
const interval = setInterval(async () => { }, [loading, started, progressPercentComplete, isBatchTransform]);
const stats = await api.getTransformStats(transformId);
if ( const progressBarRefetchEnabled =
isGetTransformsStatsResponseSchema(stats) && isBatchTransform &&
Array.isArray(stats.transforms) && typeof progressPercentComplete === 'number' &&
stats.transforms.length > 0 progressPercentComplete < 100;
) { const progressBarRefetchInterval = progressBarRefetchEnabled
? PROGRESS_REFRESH_INTERVAL_MS
: false;
const { data: stats } = useGetTransformStats(
transformId,
progressBarRefetchEnabled,
progressBarRefetchInterval
);
useEffect(() => {
if (stats === undefined) {
return;
}
if (stats && Array.isArray(stats.transforms) && stats.transforms.length > 0) {
const percent = const percent =
getTransformProgress({ getTransformProgress({
id: transformId, id: transformId,
@ -314,31 +249,18 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
stats: stats.transforms[0], stats: stats.transforms[0],
}) || 0; }) || 0;
setProgressPercentComplete(percent); setProgressPercentComplete(percent);
if (percent >= 100) {
clearInterval(interval);
}
} else { } else {
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepCreateForm.progressErrorMessage', { title: i18n.translate('xpack.transform.stepCreateForm.progressErrorMessage', {
defaultMessage: 'An error occurred getting the progress percentage:', defaultMessage: 'An error occurred getting the progress percentage:',
}), }),
text: toMountPoint( text: toMountPoint(<ToastNotificationText text={getErrorMessage(stats)} />, {
<ToastNotificationText theme,
overlays={overlays} i18n: i18nStart,
theme={theme} }),
text={getErrorMessage(stats)}
/>,
{ theme$: theme.theme$ }
),
}); });
clearInterval(interval);
}
}, PROGRESS_REFRESH_INTERVAL_MS);
setProgressPercentComplete(0);
}
startProgressBar();
} }
}, [i18nStart, stats, theme, toastNotifications, transformConfig, transformId]);
function getTransformConfigDevConsoleStatement() { function getTransformConfigDevConsoleStatement() {
return `PUT _transform/${transformId}\n${JSON.stringify(transformConfig, null, 2)}\n\n`; return `PUT _transform/${transformId}\n${JSON.stringify(transformConfig, null, 2)}\n\n`;
@ -362,7 +284,7 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
<EuiButton <EuiButton
fill fill
isDisabled={loading || (created && started)} isDisabled={loading || (created && started)}
onClick={createAndStartTransform} onClick={() => createTransformHandler(true)}
data-test-subj="transformWizardCreateAndStartButton" data-test-subj="transformWizardCreateAndStartButton"
> >
{i18n.translate('xpack.transform.stepCreateForm.createAndStartTransformButton', { {i18n.translate('xpack.transform.stepCreateForm.createAndStartTransformButton', {
@ -436,7 +358,7 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
<EuiFlexItem grow={false} style={FLEX_ITEM_STYLE}> <EuiFlexItem grow={false} style={FLEX_ITEM_STYLE}>
<EuiButton <EuiButton
isDisabled={loading || created} isDisabled={loading || created}
onClick={createTransform} onClick={() => createTransformHandler()}
data-test-subj="transformWizardCreateButton" data-test-subj="transformWizardCreateButton"
> >
{i18n.translate('xpack.transform.stepCreateForm.createTransformButton', { {i18n.translate('xpack.transform.stepCreateForm.createTransformButton', {

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
export const StepCreateSummary: FC = React.memo(() => { export const StepCreateSummary: FC = React.memo(() => {
return null; return null;

View file

@ -6,7 +6,7 @@
*/ */
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import React, { useCallback, useContext, useEffect, useState } from 'react'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import useUpdateEffect from 'react-use/lib/useUpdateEffect'; import useUpdateEffect from 'react-use/lib/useUpdateEffect';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
@ -18,7 +18,6 @@ import { i18n } from '@kbn/i18n';
import { isMultiBucketAggregate } from '@kbn/ml-agg-utils'; import { isMultiBucketAggregate } from '@kbn/ml-agg-utils';
import { useDataSearch } from '../../../../../../../hooks/use_data_search'; import { useDataSearch } from '../../../../../../../hooks/use_data_search';
import { isEsSearchResponseWithAggregations } from '../../../../../../../../../common/api_schemas/type_guards';
import { CreateTransformWizardContext } from '../../../../wizard/wizard'; import { CreateTransformWizardContext } from '../../../../wizard/wizard';
import { useToastNotifications } from '../../../../../../../app_dependencies'; import { useToastNotifications } from '../../../../../../../app_dependencies';
@ -33,16 +32,22 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm
selectedField, selectedField,
}) => { }) => {
const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext);
const dataSearch = useDataSearch();
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const [options, setOptions] = useState<EuiComboBoxOptionOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const debouncedOnSearchChange = useMemo(
() => debounce((d: string) => setSearchValue(d), 600),
[]
);
const onSearchChange = (newSearchValue: string) => { useEffect(() => {
setSearchValue(newSearchValue); // Simulate initial load.
}; debouncedOnSearchChange('');
// Cancel debouncing when unmounting
return () => debouncedOnSearchChange.cancel();
// Only call on mount
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, []);
const updateConfig = useCallback( const updateConfig = useCallback(
(update) => { (update) => {
@ -56,16 +61,8 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm
[config, onChange] [config, onChange]
); );
useEffect(() => { const { data, isError, isLoading } = useDataSearch(
const abortController = new AbortController(); {
const fetchOptions = debounce(async () => {
if (selectedField === undefined) return;
setIsLoading(true);
setOptions([]);
const esSearchRequest = {
index: dataView!.title, index: dataView!.title,
body: { body: {
...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}),
@ -86,50 +83,31 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm
}, },
size: 0, size: 0,
}, },
}; },
// Check whether fetching should be enabled
selectedField !== undefined
);
const response = await dataSearch(esSearchRequest, abortController.signal); useEffect(() => {
if (isError) {
setIsLoading(false);
if (
!(
isEsSearchResponseWithAggregations(response) &&
isMultiBucketAggregate<estypes.AggregationsSignificantLongTermsBucket>(
response.aggregations.field_values
)
)
) {
toastNotifications.addWarning( toastNotifications.addWarning(
i18n.translate('xpack.transform.agg.popoverForm.filerAgg.term.errorFetchSuggestions', { i18n.translate('xpack.transform.agg.popoverForm.filerAgg.term.errorFetchSuggestions', {
defaultMessage: 'Unable to fetch suggestions', defaultMessage: 'Unable to fetch suggestions',
}) })
); );
return;
} }
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [isError]);
setOptions( const options: EuiComboBoxOptionOption[] =
( isMultiBucketAggregate<estypes.AggregationsSignificantLongTermsBucket>(
response.aggregations.field_values data?.aggregations?.field_values
)
? (
data?.aggregations?.field_values
.buckets as estypes.AggregationsSignificantLongTermsBucket[] .buckets as estypes.AggregationsSignificantLongTermsBucket[]
).map((value) => ({ label: value.key + '' })) ).map((value) => ({ label: value.key + '' }))
); : [];
}, 600);
fetchOptions();
return () => {
// make sure the ongoing request is canceled
fetchOptions.cancel();
abortController.abort();
};
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [selectedField]);
useEffect(() => {
// Simulate initial load.
onSearchChange('');
}, []);
useUpdateEffect(() => { useUpdateEffect(() => {
// Reset value control on field change // Reset value control on field change
@ -168,7 +146,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm
onCreateOption={(value) => { onCreateOption={(value) => {
updateConfig({ value }); updateConfig({ value });
}} }}
onSearchChange={onSearchChange} onSearchChange={debouncedOnSearchChange}
data-test-subj="transformFilterTermValueSelector" data-test-subj="transformFilterTermValueSelector"
/> />
</EuiFormRow> </EuiFormRow>

View file

@ -5,7 +5,8 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButtonIcon, EuiCallOut, EuiComboBox, EuiCopy, EuiFormRow } from '@elastic/eui'; import { EuiButtonIcon, EuiCallOut, EuiComboBox, EuiCopy, EuiFormRow } from '@elastic/eui';

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { import {
EuiButton, EuiButton,

View file

@ -7,6 +7,7 @@
import React from 'react'; import React from 'react';
import { render, waitFor } from '@testing-library/react'; import { render, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { I18nProvider } from '@kbn/i18n-react'; import { I18nProvider } from '@kbn/i18n-react';
import { DatePickerContextProvider, type DatePickerDependencies } from '@kbn/ml-date-picker'; import { DatePickerContextProvider, type DatePickerDependencies } from '@kbn/ml-date-picker';
@ -66,6 +67,7 @@ const createMockStorage = () => ({
describe('Transform: <DefinePivotForm />', () => { describe('Transform: <DefinePivotForm />', () => {
test('Minimal initialization', async () => { test('Minimal initialization', async () => {
// Arrange // Arrange
const queryClient = new QueryClient();
const mlSharedImports = await getMlSharedImports(); const mlSharedImports = await getMlSharedImports();
const searchItems = { const searchItems = {
@ -87,6 +89,7 @@ describe('Transform: <DefinePivotForm />', () => {
const { getByText } = render( const { getByText } = render(
<I18nProvider> <I18nProvider>
<QueryClientProvider client={queryClient}>
<KibanaContextProvider services={services}> <KibanaContextProvider services={services}>
<MlSharedContext.Provider value={mlSharedImports}> <MlSharedContext.Provider value={mlSharedImports}>
<DatePickerContextProvider {...getMockedDatePickerDependencies()}> <DatePickerContextProvider {...getMockedDatePickerDependencies()}>
@ -94,6 +97,7 @@ describe('Transform: <DefinePivotForm />', () => {
</DatePickerContextProvider> </DatePickerContextProvider>
</MlSharedContext.Provider> </MlSharedContext.Provider>
</KibanaContextProvider> </KibanaContextProvider>
</QueryClientProvider>
</I18nProvider> </I18nProvider>
); );

View file

@ -78,6 +78,9 @@ const ALLOW_TIME_RANGE_ON_TRANSFORM_CONFIG = false;
const advancedEditorsSidebarWidth = '220px'; const advancedEditorsSidebarWidth = '220px';
type PopulatedFields = Set<string>;
const isPopulatedFields = (arg: unknown): arg is PopulatedFields => arg instanceof Set;
export const ConfigSectionTitle: FC<{ title: string }> = ({ title }) => ( export const ConfigSectionTitle: FC<{ title: string }> = ({ title }) => (
<> <>
<EuiSpacer size="m" /> <EuiSpacer size="m" />
@ -132,7 +135,9 @@ export const StepDefineForm: FC<StepDefineFormProps> = React.memo((props) => {
transformConfigQuery, transformConfigQuery,
runtimeMappings, runtimeMappings,
timeRangeMs, timeRangeMs,
fieldStatsContext?.populatedFields ?? null isPopulatedFields(fieldStatsContext?.populatedFields)
? [...fieldStatsContext.populatedFields]
: []
), ),
dataTestSubj: 'transformIndexPreview', dataTestSubj: 'transformIndexPreview',
toastNotifications, toastNotifications,

View file

@ -7,6 +7,7 @@
import React from 'react'; import React from 'react';
import { render, waitFor } from '@testing-library/react'; import { render, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs';
@ -30,6 +31,7 @@ describe('Transform: <DefinePivotSummary />', () => {
// Using the async/await wait()/done() pattern to avoid act() errors. // Using the async/await wait()/done() pattern to avoid act() errors.
test('Minimal initialization', async () => { test('Minimal initialization', async () => {
// Arrange // Arrange
const queryClient = new QueryClient();
const mlSharedImports = await getMlSharedImports(); const mlSharedImports = await getMlSharedImports();
const searchItems = { const searchItems = {
@ -78,9 +80,11 @@ describe('Transform: <DefinePivotSummary />', () => {
}; };
const { queryByText } = render( const { queryByText } = render(
<QueryClientProvider client={queryClient}>
<MlSharedContext.Provider value={mlSharedImports}> <MlSharedContext.Provider value={mlSharedImports}>
<StepDefineSummary formState={formState} searchItems={searchItems as SearchItems} /> <StepDefineSummary formState={formState} searchItems={searchItems as SearchItems} />
</MlSharedContext.Provider> </MlSharedContext.Provider>
</QueryClientProvider>
); );
// Act // Act

View file

@ -5,7 +5,8 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer } from '@elastic/eui'; import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer } from '@elastic/eui';
import { TRANSFORM_FUNCTION, TransformFunction } from '../../../../../../common/constants'; import { TRANSFORM_FUNCTION, TransformFunction } from '../../../../../../common/constants';

View file

@ -25,15 +25,9 @@ import {
} from '@elastic/eui'; } from '@elastic/eui';
import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { toMountPoint } from '@kbn/react-kibana-mount';
import { isHttpFetchError } from '@kbn/core-http-browser';
import { retentionPolicyMaxAgeInvalidErrorMessage } from '../../../../common/constants/validation_messages'; import { retentionPolicyMaxAgeInvalidErrorMessage } from '../../../../common/constants/validation_messages';
import {
isEsIndices,
isEsIngestPipelines,
isPostTransformsPreviewResponseSchema,
} from '../../../../../../common/api_schemas/type_guards';
import { DEFAULT_TRANSFORM_FREQUENCY } from '../../../../../../common/constants'; import { DEFAULT_TRANSFORM_FREQUENCY } from '../../../../../../common/constants';
import { TransformId } from '../../../../../../common/types/transform'; import { TransformId } from '../../../../../../common/types/transform';
import { isValidIndexName } from '../../../../../../common/utils/es_utils'; import { isValidIndexName } from '../../../../../../common/utils/es_utils';
@ -42,16 +36,22 @@ import { getErrorMessage } from '../../../../../../common/utils/errors';
import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies';
import { ToastNotificationText } from '../../../../components'; import { ToastNotificationText } from '../../../../components';
import { useDocumentationLinks } from '../../../../hooks/use_documentation_links'; import {
useDocumentationLinks,
useGetDataViewTitles,
useGetEsIndices,
useGetEsIngestPipelines,
useGetTransforms,
useGetTransformsPreview,
} from '../../../../hooks';
import { SearchItems } from '../../../../hooks/use_search_items'; import { SearchItems } from '../../../../hooks/use_search_items';
import { useApi } from '../../../../hooks/use_api';
import { StepDetailsTimeField } from './step_details_time_field'; import { StepDetailsTimeField } from './step_details_time_field';
import { import {
getTransformConfigQuery, getTransformConfigQuery,
getPreviewTransformRequestBody, getPreviewTransformRequestBody,
isTransformIdValid, isTransformIdValid,
} from '../../../../common'; } from '../../../../common';
import { EsIndexName, DataViewTitle } from './common'; import { EsIndexName } from './common';
import { import {
continuousModeDelayValidator, continuousModeDelayValidator,
integerRangeMinus1To100Validator, integerRangeMinus1To100Validator,
@ -73,8 +73,8 @@ interface StepDetailsFormProps {
export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo( export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
({ overrides = {}, onChange, searchItems, stepDefineState }) => { ({ overrides = {}, onChange, searchItems, stepDefineState }) => {
const deps = useAppDependencies(); const { application, i18n: i18nStart, theme } = useAppDependencies();
const { capabilities } = deps.application; const { capabilities } = application;
const toastNotifications = useToastNotifications(); const toastNotifications = useToastNotifications();
const { esIndicesCreateIndex } = useDocumentationLinks(); const { esIndicesCreateIndex } = useDocumentationLinks();
@ -90,19 +90,15 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
const [destinationIngestPipeline, setDestinationIngestPipeline] = useState<string>( const [destinationIngestPipeline, setDestinationIngestPipeline] = useState<string>(
defaults.destinationIngestPipeline defaults.destinationIngestPipeline
); );
const [transformIds, setTransformIds] = useState<TransformId[]>([]);
const [indexNames, setIndexNames] = useState<EsIndexName[]>([]);
const [ingestPipelineNames, setIngestPipelineNames] = useState<string[]>([]);
const canCreateDataView = useMemo( const canCreateDataView = useMemo(
() => () =>
capabilities.savedObjectsManagement.edit === true || capabilities.savedObjectsManagement?.edit === true ||
capabilities.indexPatterns.save === true, capabilities.indexPatterns?.save === true,
[capabilities] [capabilities]
); );
// Index pattern state // Index pattern state
const [dataViewTitles, setDataViewTitles] = useState<DataViewTitle[]>([]);
const [createDataView, setCreateDataView] = useState( const [createDataView, setCreateDataView] = useState(
canCreateDataView === false ? false : defaults.createDataView canCreateDataView === false ? false : defaults.createDataView
); );
@ -125,25 +121,42 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
[setDataViewTimeField, dataViewAvailableTimeFields] [setDataViewTimeField, dataViewAvailableTimeFields]
); );
const { overlays, theme } = useAppDependencies(); const {
const api = useApi(); error: transformsError,
data: { transformIds },
} = useGetTransforms();
// fetch existing transform IDs and indices once for form validation
useEffect(() => { useEffect(() => {
// use an IIFE to avoid returning a Promise to useEffect. if (transformsError !== null) {
(async function () { toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformList', {
defaultMessage: 'An error occurred getting the existing transform IDs:',
}),
text: toMountPoint(<ToastNotificationText text={getErrorMessage(transformsError)} />, {
theme,
i18n: i18nStart,
}),
});
}
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transformsError]);
const previewRequest = useMemo(() => {
const { searchQuery, previewRequest: partialPreviewRequest } = stepDefineState; const { searchQuery, previewRequest: partialPreviewRequest } = stepDefineState;
const transformConfigQuery = getTransformConfigQuery(searchQuery); const transformConfigQuery = getTransformConfigQuery(searchQuery);
const previewRequest = getPreviewTransformRequestBody( return getPreviewTransformRequestBody(
searchItems.dataView, searchItems.dataView,
transformConfigQuery, transformConfigQuery,
partialPreviewRequest, partialPreviewRequest,
stepDefineState.runtimeMappings stepDefineState.runtimeMappings
); );
}, [searchItems.dataView, stepDefineState]);
const { error: transformsPreviewError, data: transformPreview } =
useGetTransformsPreview(previewRequest);
const transformPreview = await api.getTransformsPreview(previewRequest); useEffect(() => {
if (transformPreview) {
if (isPostTransformsPreviewResponseSchema(transformPreview)) {
const properties = transformPreview.generated_dest_index.mappings.properties; const properties = transformPreview.generated_dest_index.mappings.properties;
const timeFields: string[] = Object.keys(properties).filter( const timeFields: string[] = Object.keys(properties).filter(
(col) => properties[col].type === 'date' (col) => properties[col].type === 'date'
@ -151,100 +164,79 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
setDataViewAvailableTimeFields(timeFields); setDataViewAvailableTimeFields(timeFields);
setDataViewTimeField(timeFields[0]); setDataViewTimeField(timeFields[0]);
} else { }
}, [transformPreview]);
useEffect(() => {
if (transformsPreviewError !== null) {
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformPreview', { title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformPreview', {
defaultMessage: 'An error occurred fetching the transform preview', defaultMessage: 'An error occurred fetching the transform preview',
}), }),
text: toMountPoint( text: toMountPoint(
<ToastNotificationText <ToastNotificationText text={getErrorMessage(transformsPreviewError)} />,
overlays={overlays} { theme, i18n: i18nStart }
theme={theme}
text={getErrorMessage(transformPreview)}
/>,
{ theme$: theme.theme$ }
), ),
}); });
} }
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transformsPreviewError]);
const resp = await api.getTransforms(); const { error: esIndicesError, data: esIndicesData } = useGetEsIndices();
const indexNames = esIndicesData?.map((index) => index.name) ?? [];
if (isHttpFetchError(resp)) { useEffect(() => {
toastNotifications.addDanger({ if (esIndicesError !== null) {
title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformList', {
defaultMessage: 'An error occurred getting the existing transform IDs:',
}),
text: toMountPoint(
<ToastNotificationText
overlays={overlays}
theme={theme}
text={getErrorMessage(resp)}
/>,
{ theme$: theme.theme$ }
),
});
} else {
setTransformIds(resp.transforms.map((transform) => transform.id));
}
const [indices, ingestPipelines] = await Promise.all([
api.getEsIndices(),
api.getEsIngestPipelines(),
]);
if (isEsIndices(indices)) {
setIndexNames(indices.map((index) => index.name));
} else {
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIndexNames', { title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIndexNames', {
defaultMessage: 'An error occurred getting the existing index names:', defaultMessage: 'An error occurred getting the existing index names:',
}), }),
text: toMountPoint( text: toMountPoint(<ToastNotificationText text={getErrorMessage(esIndicesError)} />, {
<ToastNotificationText theme,
overlays={overlays} i18n: i18nStart,
theme={theme} }),
text={getErrorMessage(indices)}
/>,
{ theme$: theme.theme$ }
),
}); });
} }
// custom comparison
/* eslint-disable react-hooks/exhaustive-deps */
}, [esIndicesError]);
if (isEsIngestPipelines(ingestPipelines)) { const { error: esIngestPipelinesError, data: esIngestPipelinesData } =
setIngestPipelineNames(ingestPipelines.map(({ name }) => name)); useGetEsIngestPipelines();
} else { const ingestPipelineNames = esIngestPipelinesData?.map(({ name }) => name) ?? [];
useEffect(() => {
if (esIngestPipelinesError !== null) {
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIngestPipelines', { title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIngestPipelines', {
defaultMessage: 'An error occurred getting the existing ingest pipeline names:', defaultMessage: 'An error occurred getting the existing ingest pipeline names:',
}), }),
text: toMountPoint( text: toMountPoint(
<ToastNotificationText <ToastNotificationText text={getErrorMessage(esIngestPipelinesError)} />,
overlays={overlays} { theme, i18n: i18nStart }
theme={theme}
text={getErrorMessage(ingestPipelines)}
/>,
{ theme$: theme.theme$ }
), ),
}); });
} }
// custom comparison
/* eslint-disable react-hooks/exhaustive-deps */
}, [esIngestPipelinesError]);
try { const { error: dataViewTitlesError, data: dataViewTitles } = useGetDataViewTitles();
setDataViewTitles(await deps.data.dataViews.getTitles());
} catch (e) { useEffect(() => {
if (dataViewTitlesError !== null) {
toastNotifications.addDanger({ toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingDataViewTitles', { title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingDataViewTitles', {
defaultMessage: 'An error occurred getting the existing data view titles:', defaultMessage: 'An error occurred getting the existing data view titles:',
}), }),
text: toMountPoint( text: toMountPoint(
<ToastNotificationText overlays={overlays} theme={theme} text={getErrorMessage(e)} />, <ToastNotificationText text={getErrorMessage(dataViewTitlesError)} />,
{ theme$: theme.theme$ } { theme, i18n: i18nStart }
), ),
}); });
} }
})(); }, [dataViewTitlesError]);
// run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const dateFieldNames = searchItems.dataView.fields const dateFieldNames = searchItems.dataView.fields
.filter((f) => f.type === KBN_FIELD_TYPES.DATE) .filter((f) => f.type === KBN_FIELD_TYPES.DATE)
@ -284,7 +276,6 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
); );
setRetentionPolicyMaxAge(''); setRetentionPolicyMaxAge('');
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRetentionPolicyEnabled]); }, [isRetentionPolicyEnabled]);
const transformIdExists = transformIds.some((id) => transformId === id); const transformIdExists = transformIds.some((id) => transformId === id);
@ -294,7 +285,7 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
const indexNameExists = indexNames.some((name) => destinationIndex === name); const indexNameExists = indexNames.some((name) => destinationIndex === name);
const indexNameEmpty = destinationIndex === ''; const indexNameEmpty = destinationIndex === '';
const indexNameValid = isValidIndexName(destinationIndex); const indexNameValid = isValidIndexName(destinationIndex);
const dataViewTitleExists = dataViewTitles.some((name) => destinationIndex === name); const dataViewTitleExists = dataViewTitles?.some((name) => destinationIndex === name) ?? false;
const [transformFrequency, setTransformFrequency] = useState(defaults.transformFrequency); const [transformFrequency, setTransformFrequency] = useState(defaults.transformFrequency);
const isTransformFrequencyValid = transformFrequencyValidator(transformFrequency); const isTransformFrequencyValid = transformFrequencyValidator(transformFrequency);

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';

View file

@ -5,7 +5,8 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { EuiFormRow, EuiSelect } from '@elastic/eui'; import { EuiFormRow, EuiSelect } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';

View file

@ -5,7 +5,8 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { EuiConfirmModal } from '@elastic/eui'; import { EuiConfirmModal } from '@elastic/eui';

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';

View file

@ -8,12 +8,14 @@
import React, { FC, useEffect } from 'react'; import React, { FC, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom'; import { RouteComponentProps } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButtonEmpty, EuiCallOut, EuiPageTemplate, EuiSpacer } from '@elastic/eui'; import { EuiButtonEmpty, EuiCallOut, EuiPageTemplate, EuiSpacer } from '@elastic/eui';
import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants';
import { useDocumentationLinks } from '../../hooks/use_documentation_links'; import { useDocumentationLinks } from '../../hooks/use_documentation_links';
import { useSearchItems } from '../../hooks/use_search_items'; import { useSearchItems } from '../../hooks/use_search_items';
import { BREADCRUMB_SECTION, breadcrumbService, docTitleService } from '../../services/navigation'; import { breadcrumbService, docTitleService, BREADCRUMB_SECTION } from '../../services/navigation';
import { PrivilegesWrapper } from '../../lib/authorization'; import { CapabilitiesWrapper } from '../../components/capabilities_wrapper';
import { Wizard } from './components/wizard'; import { Wizard } from './components/wizard';
type Props = RouteComponentProps<{ savedObjectId: string }>; type Props = RouteComponentProps<{ savedObjectId: string }>;
@ -43,7 +45,14 @@ export const CreateTransformSection: FC<Props> = ({ match }) => {
); );
return ( return (
<PrivilegesWrapper privileges={APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES}> <CapabilitiesWrapper
requiredCapabilities={[
'canGetTransform',
'canPreviewTransform',
'canCreateTransform',
'canStartStopTransform',
]}
>
<EuiPageTemplate.Header <EuiPageTemplate.Header
pageTitle={ pageTitle={
<FormattedMessage <FormattedMessage
@ -67,6 +76,6 @@ export const CreateTransformSection: FC<Props> = ({ match }) => {
)} )}
{searchItems !== undefined && <Wizard searchItems={searchItems} />} {searchItems !== undefined && <Wizard searchItems={searchItems} />}
</EuiPageTemplate.Section> </EuiPageTemplate.Section>
</PrivilegesWrapper> </CapabilitiesWrapper>
); );
}; };

View file

@ -1,14 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: <TransformManagementSection /> Minimal initialization 1`] = `
<PrivilegesWrapper
privileges={
Array [
"cluster.cluster:monitor/transform/get",
"cluster.cluster:monitor/transform/stats/get",
]
}
>
<TransformManagement />
</PrivilegesWrapper>
`;

View file

@ -5,11 +5,12 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { EuiToolTip } from '@elastic/eui'; import { EuiToolTip } from '@elastic/eui';
import { createCapabilityFailureMessage } from '../../../../lib/authorization'; import { createCapabilityFailureMessage } from '../../../../../../common/utils/create_capability_failure_message';
export const cloneActionNameText = i18n.translate( export const cloneActionNameText = i18n.translate(
'xpack.transform.transformList.cloneActionNameText', 'xpack.transform.transformList.cloneActionNameText',

View file

@ -5,14 +5,13 @@
* 2.0. * 2.0.
*/ */
import React, { useCallback, useContext, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { AuthorizationContext } from '../../../../lib/authorization';
import { TransformListAction, TransformListRow } from '../../../../common'; import { TransformListAction, TransformListRow } from '../../../../common';
import { SECTION_SLUG } from '../../../../common/constants'; import { SECTION_SLUG } from '../../../../common/constants';
import { useSearchItems } from '../../../../hooks/use_search_items'; import { useTransformCapabilities, useSearchItems } from '../../../../hooks';
import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies';
import { cloneActionNameText, CloneActionName } from './clone_action_name'; import { cloneActionNameText, CloneActionName } from './clone_action_name';
@ -26,7 +25,7 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) =>
const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined);
const { canCreateTransform } = useContext(AuthorizationContext).capabilities; const { canCreateTransform } = useTransformCapabilities();
const clickHandler = useCallback( const clickHandler = useCallback(
async (item: TransformListRow) => { async (item: TransformListRow) => {

View file

@ -5,11 +5,13 @@
* 2.0. * 2.0.
*/ */
import React, { FC } from 'react'; import React, { type FC } from 'react';
import { EuiToolTip } from '@elastic/eui'; import { EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { createCapabilityFailureMessage } from '../../../../lib/authorization';
import { createCapabilityFailureMessage } from '../../../../../../common/utils/create_capability_failure_message';
interface CreateAlertRuleActionProps { interface CreateAlertRuleActionProps {
disabled: boolean; disabled: boolean;

View file

@ -5,8 +5,8 @@
* 2.0. * 2.0.
*/ */
import React, { useCallback, useContext, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { AuthorizationContext } from '../../../../lib/authorization'; import { useTransformCapabilities } from '../../../../hooks';
import { TransformListAction, TransformListRow } from '../../../../common'; import { TransformListAction, TransformListRow } from '../../../../common';
import { import {
crateAlertRuleActionNameText, crateAlertRuleActionNameText,
@ -17,7 +17,7 @@ import { isContinuousTransform } from '../../../../../../common/types/transform'
export type CreateAlertRuleAction = ReturnType<typeof useCreateAlertRuleAction>; export type CreateAlertRuleAction = ReturnType<typeof useCreateAlertRuleAction>;
export const useCreateAlertRuleAction = (forceDisable: boolean) => { export const useCreateAlertRuleAction = (forceDisable: boolean) => {
const { canCreateTransformAlerts } = useContext(AuthorizationContext).capabilities; const { canCreateTransformAlerts } = useTransformCapabilities();
const { setCreateAlertRule } = useAlertRuleFlyout(); const { setCreateAlertRule } = useAlertRuleFlyout();
const clickHandler = useCallback( const clickHandler = useCallback(

Some files were not shown because too many files have changed in this diff Show more