[ML] Transforms: Extend editing and creation options. (#77370) (#78023)

Extends the available options in the edit transform flyout and the wizard details step.
This commit is contained in:
Walter Rafelsberger 2020-09-21 18:52:20 +02:00 committed by GitHub
parent 8d0f2603d1
commit f4b88bba5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 933 additions and 321 deletions

View file

@ -8,12 +8,18 @@ import { schema, TypeOf } from '@kbn/config-schema';
import { TransformPivotConfig } from '../types/transform';
import { destSchema, settingsSchema, sourceSchema, syncSchema } from './transforms';
import { settingsSchema, sourceSchema, syncSchema } from './transforms';
// POST _transform/{transform_id}/_update
export const postTransformsUpdateRequestSchema = schema.object({
description: schema.maybe(schema.string()),
dest: schema.maybe(destSchema),
// we cannot reuse `destSchema` because `index` is optional for the update request
dest: schema.maybe(
schema.object({
index: schema.string(),
pipeline: schema.maybe(schema.string()),
})
),
frequency: schema.maybe(schema.string()),
settings: schema.maybe(settingsSchema),
source: schema.maybe(sourceSchema),

View file

@ -17,3 +17,21 @@ export const getNestedProperty = (
return value;
};
export const setNestedProperty = (obj: Record<string, any>, accessor: string, value: any) => {
let ref = obj;
const accessors = accessor.split('.');
const len = accessors.length;
for (let i = 0; i < len - 1; i++) {
const attribute = accessors[i];
if (ref[attribute] === undefined) {
ref[attribute] = {};
}
ref = ref[attribute];
}
ref[accessors[len - 1]] = value;
return obj;
};

View file

@ -157,6 +157,8 @@ describe('Transform: Common', () => {
isContinuousModeEnabled: false,
transformId: 'the-transform-id',
transformDescription: 'the-transform-description',
transformFrequency: '1m',
transformSettingsMaxPageSearchSize: 100,
destinationIndex: 'the-destination-index',
touched: true,
valid: true,
@ -171,10 +173,14 @@ describe('Transform: Common', () => {
expect(request).toEqual({
description: 'the-transform-description',
dest: { index: 'the-destination-index' },
frequency: '1m',
pivot: {
aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } },
group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } },
},
settings: {
max_page_search_size: 100,
},
source: {
index: ['the-index-pattern-title'],
query: { query_string: { default_operator: 'AND', query: 'the-search-query' } },

View file

@ -145,6 +145,10 @@ export const getCreateTransformRequestBody = (
...(transformDetailsState.transformDescription !== ''
? { description: transformDetailsState.transformDescription }
: {}),
// conditionally add optional frequency
...(transformDetailsState.transformFrequency !== ''
? { frequency: transformDetailsState.transformFrequency }
: {}),
dest: {
index: transformDetailsState.destinationIndex,
},
@ -159,6 +163,14 @@ export const getCreateTransformRequestBody = (
},
}
: {}),
// conditionally add additional settings
...(transformDetailsState.transformSettingsMaxPageSearchSize
? {
settings: {
max_page_search_size: transformDetailsState.transformSettingsMaxPageSearchSize,
},
}
: {}),
});
export function isHttpFetchError(error: any): error is HttpFetchError {

View file

@ -4,26 +4,63 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { delayValidator } from './validators';
import { continuousModeDelayValidator, transformFrequencyValidator } from './validators';
describe('delayValidator', () => {
test('it should allow 0 input without unit', () => {
expect(delayValidator('0')).toBe(true);
describe('continuousModeDelayValidator', () => {
it('should allow 0 input without unit', () => {
expect(continuousModeDelayValidator('0')).toBe(true);
});
test('it should allow 0 input with unit provided', () => {
expect(delayValidator('0s')).toBe(true);
it('should allow 0 input with unit provided', () => {
expect(continuousModeDelayValidator('0s')).toBe(true);
});
test('it should allow integer input with unit provided', () => {
expect(delayValidator('234nanos')).toBe(true);
it('should allow integer input with unit provided', () => {
expect(continuousModeDelayValidator('234nanos')).toBe(true);
});
test('it should not allow integer input without unit provided', () => {
expect(delayValidator('90000')).toBe(false);
it('should not allow integer input without unit provided', () => {
expect(continuousModeDelayValidator('90000')).toBe(false);
});
test('it should not allow float input', () => {
expect(delayValidator('122.5d')).toBe(false);
it('should not allow float input', () => {
expect(continuousModeDelayValidator('122.5d')).toBe(false);
});
});
describe('transformFrequencyValidator', () => {
it('should fail when the input is not an integer and valid time unit.', () => {
expect(transformFrequencyValidator('0')).toBe(false);
expect(transformFrequencyValidator('0.1s')).toBe(false);
expect(transformFrequencyValidator('1.1m')).toBe(false);
expect(transformFrequencyValidator('10.1asdf')).toBe(false);
});
it('should only allow s/m/h as time unit.', () => {
expect(transformFrequencyValidator('1ms')).toBe(false);
expect(transformFrequencyValidator('1s')).toBe(true);
expect(transformFrequencyValidator('1m')).toBe(true);
expect(transformFrequencyValidator('1h')).toBe(true);
expect(transformFrequencyValidator('1d')).toBe(false);
});
it('should only allow values above 0 and up to 1 hour.', () => {
expect(transformFrequencyValidator('0s')).toBe(false);
expect(transformFrequencyValidator('1s')).toBe(true);
expect(transformFrequencyValidator('3599s')).toBe(true);
expect(transformFrequencyValidator('3600s')).toBe(true);
expect(transformFrequencyValidator('3601s')).toBe(false);
expect(transformFrequencyValidator('10000s')).toBe(false);
expect(transformFrequencyValidator('0m')).toBe(false);
expect(transformFrequencyValidator('1m')).toBe(true);
expect(transformFrequencyValidator('59m')).toBe(true);
expect(transformFrequencyValidator('60m')).toBe(true);
expect(transformFrequencyValidator('61m')).toBe(false);
expect(transformFrequencyValidator('100m')).toBe(false);
expect(transformFrequencyValidator('0h')).toBe(false);
expect(transformFrequencyValidator('1h')).toBe(true);
expect(transformFrequencyValidator('2h')).toBe(false);
});
});

View file

@ -5,10 +5,54 @@
*/
/**
* Validates time delay input.
* Validates continuous mode time delay input.
* Doesn't allow floating intervals.
* @param value User input value.
*/
export function delayValidator(value: string): boolean {
export function continuousModeDelayValidator(value: string): boolean {
return value.match(/^(0|\d*(nanos|micros|ms|s|m|h|d))$/) !== null;
}
/**
* Validates transform frequency input.
* Allows time units of s/m/h only.
* Must be above 0 and only up to 1h.
* @param value User input value.
*/
export const transformFrequencyValidator = (value: string): boolean => {
if (typeof value !== 'string' || value === null) {
return false;
}
// split string by groups of numbers and letters
const regexStr = value.match(/[a-z]+|[^a-z]+/gi);
// only valid if one group of numbers and one group of letters
if (regexStr === null || (Array.isArray(regexStr) && regexStr.length !== 2)) {
return false;
}
const valueNumber = +regexStr[0];
const valueTimeUnit = regexStr[1];
// only valid if number is an integer above 0
if (isNaN(valueNumber) || !Number.isInteger(valueNumber) || valueNumber === 0) {
return false;
}
// only valid if value is up to 1 hour
return (
(valueTimeUnit === 's' && valueNumber <= 3600) ||
(valueTimeUnit === 'm' && valueNumber <= 60) ||
(valueTimeUnit === 'h' && valueNumber === 1)
);
};
/**
* Validates transform max_page_search_size input.
* Must be a number between 10 and 10000.
* @param value User input value.
*/
export function transformSettingsMaxPageSearchSizeValidator(value: number): boolean {
return value >= 10 && value <= 10000;
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { isEqual } from 'lodash';
import isEqual from 'lodash/isEqual';
import React, { memo, FC } from 'react';
import { EuiCodeEditor, EuiFormRow } from '@elastic/eui';

View file

@ -18,7 +18,7 @@ import {
EuiSelectOption,
} from '@elastic/eui';
import { cloneDeep } from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
import { useUpdateEffect } from 'react-use';
import { AggName } from '../../../../../../common/types/aggregations';
import { dictionaryToArray } from '../../../../../../common/types/common';

View file

@ -7,7 +7,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { StepCreateForm } from './step_create_form';
import { StepCreateForm, StepCreateFormProps } from './step_create_form';
jest.mock('../../../../../shared_imports');
jest.mock('../../../../../app/app_dependencies');
@ -15,10 +15,21 @@ jest.mock('../../../../../app/app_dependencies');
describe('Transform: <StepCreateForm />', () => {
test('Minimal initialization', () => {
// Arrange
const props = {
const props: StepCreateFormProps = {
createIndexPattern: false,
transformId: 'the-transform-id',
transformConfig: {},
transformConfig: {
dest: {
index: 'the-destination-index',
},
pivot: {
group_by: {},
aggregations: {},
},
source: {
index: 'the-source-index',
},
},
overrides: { created: false, started: false, indexPatternId: undefined },
onChange() {},
};

View file

@ -9,13 +9,8 @@ import { i18n } from '@kbn/i18n';
import {
EuiButton,
// Module '"@elastic/eui"' has no exported member 'EuiCard'.
// @ts-ignore
EuiCard,
EuiCopy,
// Module '"@elastic/eui"' has no exported member 'EuiDescribedFormGroup'.
// @ts-ignore
EuiDescribedFormGroup,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
@ -30,7 +25,10 @@ import {
import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public';
import type { PutTransformsResponseSchema } from '../../../../../../common/api_schemas/transforms';
import type {
PutTransformsRequestSchema,
PutTransformsResponseSchema,
} from '../../../../../../common/api_schemas/transforms';
import {
isGetTransformsStatsResponseSchema,
isPutTransformsResponseSchema,
@ -60,16 +58,16 @@ export function getDefaultStepCreateState(): StepDetailsExposedState {
};
}
interface Props {
export interface StepCreateFormProps {
createIndexPattern: boolean;
transformId: string;
transformConfig: any;
transformConfig: PutTransformsRequestSchema;
overrides: StepDetailsExposedState;
timeFieldName?: string | undefined;
onChange(s: StepDetailsExposedState): void;
}
export const StepCreateForm: FC<Props> = React.memo(
export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
({ createIndexPattern, transformConfig, transformId, onChange, overrides, timeFieldName }) => {
const defaults = { ...getDefaultStepCreateState(), ...overrides };
@ -267,8 +265,11 @@ export const StepCreateForm: FC<Props> = React.memo(
) {
const percent =
getTransformProgress({
id: transformConfig.id,
config: transformConfig,
id: transformId,
config: {
...transformConfig,
id: transformId,
},
stats: stats.transforms[0],
}) || 0;
setProgressPercentComplete(percent);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { isEqual } from 'lodash';
import isEqual from 'lodash/isEqual';
import { Dictionary } from '../../../../../../../common/types/common';
import { PivotSupportedAggs } from '../../../../../../../common/types/pivot_aggs';

View file

@ -7,7 +7,7 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { EuiComboBox, EuiFormRow } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { debounce } from 'lodash';
import debounce from 'lodash/debounce';
import { useUpdateEffect } from 'react-use';
import { i18n } from '@kbn/i18n';
import { isEsSearchResponse } from '../../../../../../../../../common/api_schemas/type_guards';

View file

@ -4,13 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
import { EuiComboBoxOptionOption } from '@elastic/eui';
import {
IndexPattern,
KBN_FIELD_TYPES,
} from '../../../../../../../../../../src/plugins/data/public';
import { getNestedProperty } from '../../../../../../../common/utils/object_utils';
import {
DropDownLabel,
DropDownOption,
@ -42,7 +43,7 @@ export function getPivotDropdownOptions(indexPattern: IndexPattern) {
fields.forEach((field) => {
// Group by
const availableGroupByAggs: [] = get(pivotGroupByFieldSupport, field.type);
const availableGroupByAggs: [] = getNestedProperty(pivotGroupByFieldSupport, field.type);
if (availableGroupByAggs !== undefined) {
availableGroupByAggs.forEach((groupByAgg) => {
@ -63,7 +64,7 @@ export function getPivotDropdownOptions(indexPattern: IndexPattern) {
// Aggregations
const aggOption: DropDownOption = { label: field.name, options: [] };
const availableAggs: [] = get(pivotAggsFieldSupport, field.type);
const availableAggs: [] = getNestedProperty(pivotAggsFieldSupport, field.type);
if (availableAggs !== undefined) {
availableAggs.forEach((agg) => {

View file

@ -8,7 +8,16 @@ import React, { Fragment, FC, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiLink, EuiSwitch, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui';
import {
EuiAccordion,
EuiLink,
EuiSwitch,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiSelect,
EuiSpacer,
} from '@elastic/eui';
import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/common';
import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public';
@ -35,7 +44,11 @@ import {
isTransformIdValid,
} from '../../../../common';
import { EsIndexName, IndexPatternTitle } from './common';
import { delayValidator } from '../../../../common/validators';
import {
continuousModeDelayValidator,
transformFrequencyValidator,
transformSettingsMaxPageSearchSizeValidator,
} from '../../../../common/validators';
import { StepDefineExposedState } from '../step_define/common';
import { dictionaryToArray } from '../../../../../../common/types/common';
@ -48,11 +61,15 @@ export interface StepDetailsExposedState {
touched: boolean;
transformId: TransformId;
transformDescription: string;
transformFrequency: string;
transformSettingsMaxPageSearchSize: number;
valid: boolean;
indexPatternDateField?: string | undefined;
indexPatternTimeField?: string | undefined;
}
const defaultContinuousModeDelay = '60s';
const defaultTransformFrequency = '1m';
const defaultTransformSettingsMaxPageSearchSize = 500;
export function getDefaultStepDetailsState(): StepDetailsExposedState {
return {
@ -62,10 +79,12 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState {
isContinuousModeEnabled: false,
transformId: '',
transformDescription: '',
transformFrequency: defaultTransformFrequency,
transformSettingsMaxPageSearchSize: defaultTransformSettingsMaxPageSearchSize,
destinationIndex: '',
touched: false,
valid: false,
indexPatternDateField: undefined,
indexPatternTimeField: undefined,
};
}
@ -113,8 +132,10 @@ export const StepDetailsForm: FC<Props> = React.memo(
// Index pattern state
const [indexPatternTitles, setIndexPatternTitles] = useState<IndexPatternTitle[]>([]);
const [createIndexPattern, setCreateIndexPattern] = useState(defaults.createIndexPattern);
const [previewDateColumns, setPreviewDateColumns] = useState<string[]>([]);
const [indexPatternDateField, setIndexPatternDateField] = useState<string | undefined>();
const [indexPatternAvailableTimeFields, setIndexPatternAvailableTimeFields] = useState<
string[]
>([]);
const [indexPatternTimeField, setIndexPatternTimeField] = useState<string | undefined>();
const onTimeFieldChanged = React.useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
@ -125,11 +146,11 @@ export const StepDetailsForm: FC<Props> = React.memo(
}
// Find the time field based on the selected value
// this is to account for undefined when user chooses not to use a date field
const timeField = previewDateColumns.find((col) => col === value);
const timeField = indexPatternAvailableTimeFields.find((col) => col === value);
setIndexPatternDateField(timeField);
setIndexPatternTimeField(timeField);
},
[setIndexPatternDateField, previewDateColumns]
[setIndexPatternTimeField, indexPatternAvailableTimeFields]
);
// Continuous mode state
@ -158,12 +179,12 @@ export const StepDetailsForm: FC<Props> = React.memo(
if (isPostTransformsPreviewResponseSchema(transformPreview)) {
const properties = transformPreview.generated_dest_index.mappings.properties;
const datetimeColumns: string[] = Object.keys(properties).filter(
const timeFields: string[] = Object.keys(properties).filter(
(col) => properties[col].type === 'date'
);
setPreviewDateColumns(datetimeColumns);
setIndexPatternDateField(datetimeColumns[0]);
setIndexPatternAvailableTimeFields(timeFields);
setIndexPatternTimeField(timeFields[0]);
} else {
toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformPreview', {
@ -237,7 +258,7 @@ export const StepDetailsForm: FC<Props> = React.memo(
isContinuousModeAvailable ? dateFieldNames[0] : ''
);
const [continuousModeDelay, setContinuousModeDelay] = useState(defaults.continuousModeDelay);
const isContinuousModeDelayValid = delayValidator(continuousModeDelay);
const isContinuousModeDelayValid = continuousModeDelayValidator(continuousModeDelay);
const transformIdExists = transformIds.some((id) => transformId === id);
const transformIdEmpty = transformId === '';
@ -248,10 +269,22 @@ export const StepDetailsForm: FC<Props> = React.memo(
const indexNameValid = isValidIndexName(destinationIndex);
const indexPatternTitleExists = indexPatternTitles.some((name) => destinationIndex === name);
const [transformFrequency, setTransformFrequency] = useState(defaults.transformFrequency);
const isTransformFrequencyValid = transformFrequencyValidator(transformFrequency);
const [transformSettingsMaxPageSearchSize, setTransformSettingsMaxPageSearchSize] = useState(
defaults.transformSettingsMaxPageSearchSize
);
const isTransformSettingsMaxPageSearchSizeValid = transformSettingsMaxPageSearchSizeValidator(
transformSettingsMaxPageSearchSize
);
const valid =
!transformIdEmpty &&
transformIdValid &&
!transformIdExists &&
isTransformFrequencyValid &&
isTransformSettingsMaxPageSearchSizeValid &&
!indexNameEmpty &&
indexNameValid &&
(!indexPatternTitleExists || !createIndexPattern) &&
@ -266,10 +299,12 @@ export const StepDetailsForm: FC<Props> = React.memo(
isContinuousModeEnabled,
transformId,
transformDescription,
transformFrequency,
transformSettingsMaxPageSearchSize,
destinationIndex,
touched: true,
valid,
indexPatternDateField,
indexPatternTimeField,
});
// custom comparison
/* eslint-disable react-hooks/exhaustive-deps */
@ -280,9 +315,11 @@ export const StepDetailsForm: FC<Props> = React.memo(
isContinuousModeEnabled,
transformId,
transformDescription,
transformFrequency,
transformSettingsMaxPageSearchSize,
destinationIndex,
valid,
indexPatternDateField,
indexPatternTimeField,
/* eslint-enable react-hooks/exhaustive-deps */
]);
@ -413,13 +450,15 @@ export const StepDetailsForm: FC<Props> = React.memo(
data-test-subj="transformCreateIndexPatternSwitch"
/>
</EuiFormRow>
{createIndexPattern && !indexPatternTitleExists && previewDateColumns.length > 0 && (
<StepDetailsTimeField
previewDateColumns={previewDateColumns}
indexPatternDateField={indexPatternDateField}
onTimeFieldChanged={onTimeFieldChanged}
/>
)}
{createIndexPattern &&
!indexPatternTitleExists &&
indexPatternAvailableTimeFields.length > 0 && (
<StepDetailsTimeField
indexPatternAvailableTimeFields={indexPatternAvailableTimeFields}
indexPatternTimeField={indexPatternTimeField}
onTimeFieldChanged={onTimeFieldChanged}
/>
)}
<EuiFormRow
helpText={
isContinuousModeAvailable === false
@ -500,6 +539,99 @@ export const StepDetailsForm: FC<Props> = React.memo(
</EuiFormRow>
</Fragment>
)}
<EuiSpacer size="l" />
<EuiAccordion
data-test-subj="transformWizardAccordionAdvancedSettings"
id="transformWizardAccordionAdvancedSettings"
buttonContent={i18n.translate(
'xpack.transform.stepDetailsForm.advancedSettingsAccordionButtonContent',
{
defaultMessage: 'Advanced settings',
}
)}
paddingSize="s"
>
<EuiFormRow
label={i18n.translate('xpack.transform.stepDetailsForm.frequencyLabel', {
defaultMessage: 'Frequency',
})}
isInvalid={!isTransformFrequencyValid}
error={
!isTransformFrequencyValid && [
i18n.translate('xpack.transform.stepDetailsForm.frequencyError', {
defaultMessage: 'Invalid frequency format',
}),
]
}
helpText={i18n.translate('xpack.transform.stepDetailsForm.frequencyHelpText', {
defaultMessage:
'The interval between checks for changes in the source indices when the transform is running continuously. Also determines the retry interval in the event of transient failures while the transform is searching or indexing. The minimum value is 1s and the maximum is 1h.',
})}
>
<EuiFieldText
placeholder={i18n.translate(
'xpack.transform.stepDetailsForm.editFlyoutFormFrequencyPlaceholderText',
{
defaultMessage: 'Default: {defaultValue}',
values: { defaultValue: '1m' },
}
)}
value={transformFrequency}
onChange={(e) => setTransformFrequency(e.target.value)}
aria-label={i18n.translate('xpack.transform.stepDetailsForm.frequencyAriaLabel', {
defaultMessage: 'Choose a frequency.',
})}
isInvalid={!isTransformFrequencyValid}
data-test-subj="transformFrequencyInput"
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.transform.stepDetailsForm.maxPageSearchSizeLabel', {
defaultMessage: 'Maximum page search size',
})}
isInvalid={!isTransformSettingsMaxPageSearchSizeValid}
error={
!isTransformSettingsMaxPageSearchSizeValid && [
i18n.translate('xpack.transform.stepDetailsForm.maxPageSearchSizeError', {
defaultMessage:
'max_page_search_size needs to be a number between 10 and 10000.',
}),
]
}
helpText={i18n.translate(
'xpack.transform.stepDetailsForm.maxPageSearchSizeHelpText',
{
defaultMessage:
'Defines the initial page size to use for the composite aggregation for each checkpoint.',
}
)}
>
<EuiFieldText
placeholder={i18n.translate(
'xpack.transform.stepDetailsForm.editFlyoutFormMaxPageSearchSizePlaceholderText',
{
defaultMessage: 'Default: {defaultValue}',
values: { defaultValue: 500 },
}
)}
value={transformSettingsMaxPageSearchSize.toString()}
onChange={(e) =>
setTransformSettingsMaxPageSearchSize(parseInt(e.target.value, 10))
}
aria-label={i18n.translate(
'xpack.transform.stepDetailsForm.maxPageSearchSizeAriaLabel',
{
defaultMessage: 'Choose a maximum page search size.',
}
)}
isInvalid={!isTransformFrequencyValid}
data-test-subj="transformMaxPageSearchSizeInput"
/>
</EuiFormRow>
</EuiAccordion>
</EuiForm>
</div>
);

View file

@ -8,83 +8,110 @@ import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFieldText, EuiFormRow, EuiSelect } from '@elastic/eui';
import { EuiAccordion, EuiFormRow, EuiSpacer } from '@elastic/eui';
import { StepDetailsExposedState } from './step_details_form';
export const StepDetailsSummary: FC<StepDetailsExposedState> = React.memo(
({
export const StepDetailsSummary: FC<StepDetailsExposedState> = React.memo((props) => {
const {
continuousModeDateField,
createIndexPattern,
isContinuousModeEnabled,
transformId,
transformDescription,
transformFrequency,
transformSettingsMaxPageSearchSize,
destinationIndex,
touched,
indexPatternDateField,
}) => {
if (touched === false) {
return null;
}
indexPatternTimeField,
} = props;
const destinationIndexHelpText = createIndexPattern
? i18n.translate('xpack.transform.stepDetailsSummary.createIndexPatternMessage', {
defaultMessage: 'A Kibana index pattern will be created for this transform.',
})
: '';
if (touched === false) {
return null;
}
return (
<div data-test-subj="transformStepDetailsSummary">
<EuiFormRow
label={i18n.translate('xpack.transform.stepDetailsSummary.transformIdLabel', {
defaultMessage: 'Transform ID',
})}
>
<EuiFieldText defaultValue={transformId} disabled={true} />
</EuiFormRow>
const destinationIndexHelpText = createIndexPattern
? i18n.translate('xpack.transform.stepDetailsSummary.createIndexPatternMessage', {
defaultMessage: 'A Kibana index pattern will be created for this transform.',
})
: '';
return (
<div data-test-subj="transformStepDetailsSummary">
<EuiFormRow
label={i18n.translate('xpack.transform.stepDetailsSummary.transformIdLabel', {
defaultMessage: 'Transform ID',
})}
>
<span>{transformId}</span>
</EuiFormRow>
{transformDescription !== '' && (
<EuiFormRow
label={i18n.translate('xpack.transform.stepDetailsSummary.transformDescriptionLabel', {
defaultMessage: 'Transform description',
})}
>
<EuiFieldText defaultValue={transformDescription} disabled={true} />
<span>{transformDescription}</span>
</EuiFormRow>
)}
<EuiFormRow
helpText={destinationIndexHelpText}
label={i18n.translate('xpack.transform.stepDetailsSummary.destinationIndexLabel', {
defaultMessage: 'Destination index',
})}
>
<span>{destinationIndex}</span>
</EuiFormRow>
{createIndexPattern && indexPatternTimeField !== undefined && indexPatternTimeField !== '' && (
<EuiFormRow
helpText={destinationIndexHelpText}
label={i18n.translate('xpack.transform.stepDetailsSummary.destinationIndexLabel', {
defaultMessage: 'Destination index',
label={i18n.translate('xpack.transform.stepDetailsSummary.indexPatternTimeFieldLabel', {
defaultMessage: 'Kibana index pattern time field',
})}
>
<EuiFieldText defaultValue={destinationIndex} disabled={true} />
<span>{indexPatternTimeField}</span>
</EuiFormRow>
)}
{isContinuousModeEnabled && (
<EuiFormRow
label={i18n.translate('xpack.transform.stepDetailsSummary.continuousModeDateFieldLabel', {
defaultMessage: 'Continuous mode date field',
})}
>
<span>{continuousModeDateField}</span>
</EuiFormRow>
)}
<EuiSpacer size="l" />
<EuiAccordion
data-test-subj="transformWizardAccordionAdvancedSettingsSummary"
id="transformWizardAccordionAdvancedSettingsSummary"
buttonContent={i18n.translate(
'xpack.transform.stepDetailsSummary.advancedSettingsAccordionButtonContent',
{
defaultMessage: 'Advanced settings',
}
)}
paddingSize="s"
>
<EuiFormRow
label={i18n.translate('xpack.transform.stepDetailsSummary.frequencyLabel', {
defaultMessage: 'Frequency',
})}
>
<span>{transformFrequency}</span>
</EuiFormRow>
<EuiFormRow
helpText={i18n.translate(
'xpack.transform.stepDetailsSummary.indexPatternTimeFilterLabel',
{
defaultMessage: 'Time filter',
}
)}
label={i18n.translate('xpack.transform.stepDetailsSummary.maxPageSearchSizeLabel', {
defaultMessage: 'Maximum page search size',
})}
>
<EuiSelect
options={[{ text: indexPatternDateField }]}
value={indexPatternDateField}
disabled={true}
/>
<span>{transformSettingsMaxPageSearchSize}</span>
</EuiFormRow>
{isContinuousModeEnabled && (
<EuiFormRow
label={i18n.translate(
'xpack.transform.stepDetailsSummary.continuousModeDateFieldLabel',
{
defaultMessage: 'Continuous mode date field',
}
)}
>
<EuiFieldText defaultValue={continuousModeDateField} disabled={true} />
</EuiFormRow>
)}
</div>
);
}
);
</EuiAccordion>
</div>
);
});

View file

@ -10,20 +10,20 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
interface Props {
previewDateColumns: string[];
indexPatternDateField: string | undefined;
indexPatternAvailableTimeFields: string[];
indexPatternTimeField: string | undefined;
onTimeFieldChanged: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}
export const StepDetailsTimeField: FC<Props> = ({
previewDateColumns,
indexPatternDateField,
indexPatternAvailableTimeFields,
indexPatternTimeField,
onTimeFieldChanged,
}) => {
const noTimeFieldLabel = i18n.translate(
'xpack.transform.stepDetailsForm.noTimeFieldOptionLabel',
{
defaultMessage: "I don't want to use the Time Filter",
defaultMessage: "I don't want to use the time filter",
}
);
@ -42,26 +42,26 @@ export const StepDetailsTimeField: FC<Props> = ({
<EuiFormRow
label={
<FormattedMessage
id="xpack.transform.stepDetailsForm.indexPatternTimeFilterLabel"
defaultMessage="Time Filter field name"
id="xpack.transform.stepDetailsForm.indexPatternTimeFieldLabel"
defaultMessage="Time field"
/>
}
helpText={
<FormattedMessage
id="xpack.transform.stepDetailsForm.indexPatternTimeFilterHelpText"
defaultMessage="The Time Filter will use this field to filter your data by time. You can choose not to have a time field, but you will not be able to narrow down your data by a time range."
id="xpack.transform.stepDetailsForm.indexPatternTimeFieldHelpText"
defaultMessage="Select a primary time field for use with the global time filter."
/>
}
>
<EuiSelect
options={[
...previewDateColumns.map((text) => ({ text })),
...indexPatternAvailableTimeFields.map((text) => ({ text })),
disabledDividerOption,
noTimeFieldOption,
]}
value={indexPatternDateField}
value={indexPatternTimeField}
onChange={onTimeFieldChanged}
data-test-subj="transformIndexPatternDateFieldSelect"
data-test-subj="transformIndexPatternTimeFieldSelect"
/>
</EuiFormRow>
);

View file

@ -165,7 +165,7 @@ export const Wizard: FC<WizardProps> = React.memo(({ cloneConfig, searchItems })
transformConfig={transformConfig}
onChange={setStepCreateState}
overrides={stepCreateState}
timeFieldName={stepDetailsState.indexPatternDateField}
timeFieldName={stepDetailsState.indexPatternTimeField}
/>
) : (
<StepCreateSummary />

View file

@ -6,7 +6,7 @@
import React, { FC } from 'react';
import { EuiForm } from '@elastic/eui';
import { EuiForm, EuiAccordion, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -33,26 +33,11 @@ export const EditTransformFlyoutForm: FC<EditTransformFlyoutFormProps> = ({
onChange={(value) => dispatch({ field: 'description', value })}
value={formFields.description.value}
/>
<EditTransformFlyoutFormTextInput
dataTestSubj="transformEditFlyoutDocsPerSecondInput"
errorMessages={formFields.docsPerSecond.errorMessages}
helpText={i18n.translate(
'xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext',
{
defaultMessage:
'To enable throttling, set a limit of documents per second of input documents.',
}
)}
label={i18n.translate('xpack.transform.transformList.editFlyoutFormdocsPerSecondLabel', {
defaultMessage: 'Documents per second',
})}
onChange={(value) => dispatch({ field: 'docsPerSecond', value })}
value={formFields.docsPerSecond.value}
/>
<EditTransformFlyoutFormTextInput
dataTestSubj="transformEditFlyoutFrequencyInput"
errorMessages={formFields.frequency.errorMessages}
helpText={i18n.translate('xpack.transform.transformList.editFlyoutFormFrequencyHelptext', {
helpText={i18n.translate('xpack.transform.transformList.editFlyoutFormFrequencyHelpText', {
defaultMessage:
'The interval between checks for changes in the source indices when the transform is running continuously. Also determines the retry interval in the event of transient failures while the transform is searching or indexing. The minimum value is 1s and the maximum is 1h.',
})}
@ -60,9 +45,118 @@ export const EditTransformFlyoutForm: FC<EditTransformFlyoutFormProps> = ({
defaultMessage: 'Frequency',
})}
onChange={(value) => dispatch({ field: 'frequency', value })}
placeholder="1m"
placeholder={i18n.translate(
'xpack.transform.transformList.editFlyoutFormFrequencyPlaceholderText',
{
defaultMessage: 'Default: {defaultValue}',
values: { defaultValue: formFields.frequency.defaultValue },
}
)}
value={formFields.frequency.value}
/>
<EuiSpacer size="l" />
<EuiAccordion
data-test-subj="transformEditAccordionDestination"
id="transformEditAccordionDestination"
buttonContent={i18n.translate(
'xpack.transform.transformList.editFlyoutFormDestinationButtonContent',
{
defaultMessage: 'Destination configuration',
}
)}
paddingSize="s"
>
<EditTransformFlyoutFormTextInput
dataTestSubj="transformEditFlyoutDestinationIndexInput"
errorMessages={formFields.destinationIndex.errorMessages}
label={i18n.translate(
'xpack.transform.transformList.editFlyoutFormDestinationIndexLabel',
{
defaultMessage: 'Destination index',
}
)}
onChange={(value) => dispatch({ field: 'destinationIndex', value })}
value={formFields.destinationIndex.value}
/>
<EditTransformFlyoutFormTextInput
dataTestSubj="transformEditFlyoutDestinationPipelineInput"
errorMessages={formFields.destinationPipeline.errorMessages}
label={i18n.translate(
'xpack.transform.transformList.editFlyoutFormDestinationPipelineLabel',
{
defaultMessage: 'Pipeline',
}
)}
onChange={(value) => dispatch({ field: 'destinationPipeline', value })}
value={formFields.destinationPipeline.value}
/>
</EuiAccordion>
<EuiSpacer size="l" />
<EuiAccordion
data-test-subj="transformEditAccordionAdvancedSettings"
id="transformEditAccordionAdvancedSettings"
buttonContent={i18n.translate(
'xpack.transform.transformList.editFlyoutFormAdvancedSettingsButtonContent',
{
defaultMessage: 'Advanced settings',
}
)}
paddingSize="s"
>
<div data-test-subj="transformEditAccordionAdvancedSettingsContent">
<EditTransformFlyoutFormTextInput
dataTestSubj="transformEditFlyoutDocsPerSecondInput"
errorMessages={formFields.docsPerSecond.errorMessages}
helpText={i18n.translate(
'xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext',
{
defaultMessage:
'To enable throttling, set a limit of documents to input per second.',
}
)}
label={i18n.translate(
'xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel',
{
defaultMessage: 'Documents per second',
}
)}
onChange={(value) => dispatch({ field: 'docsPerSecond', value })}
value={formFields.docsPerSecond.value}
/>
<EditTransformFlyoutFormTextInput
dataTestSubj="transformEditFlyoutMaxPageSearchSizeInput"
errorMessages={formFields.maxPageSearchSize.errorMessages}
helpText={i18n.translate(
'xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelptext',
{
defaultMessage:
'Defines the initial page size to use for the composite aggregation for each checkpoint.',
}
)}
label={i18n.translate(
'xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeLabel',
{
defaultMessage: 'Maximum page search size',
}
)}
onChange={(value) => dispatch({ field: 'maxPageSearchSize', value })}
value={formFields.maxPageSearchSize.value}
placeholder={i18n.translate(
'xpack.transform.transformList.editFlyoutFormMaxPageSearchSizePlaceholderText',
{
defaultMessage: 'Default: {defaultValue}',
values: { defaultValue: formFields.maxPageSearchSize.defaultValue },
}
)}
/>
</div>
</EuiAccordion>
</EuiForm>
);
};

View file

@ -11,8 +11,8 @@ import {
formReducerFactory,
frequencyValidator,
getDefaultState,
numberAboveZeroValidator,
FormField,
integerAboveZeroValidator,
stringValidator,
} from './use_edit_transform_flyout';
const getTransformConfigMock = (): TransformPivotConfig => ({
@ -45,79 +45,149 @@ const getTransformConfigMock = (): TransformPivotConfig => ({
description: 'the-description',
});
const getDescriptionFieldMock = (value = ''): FormField => ({
isOptional: true,
value,
errorMessages: [],
validator: 'string',
});
const getDocsPerSecondFieldMock = (value = ''): FormField => ({
isOptional: true,
value,
errorMessages: [],
validator: 'numberAboveZero',
});
const getFrequencyFieldMock = (value = ''): FormField => ({
isOptional: true,
value,
errorMessages: [],
validator: 'frequency',
});
describe('Transform: applyFormFieldsToTransformConfig()', () => {
test('should exclude unchanged form fields', () => {
it('should exclude unchanged form fields', () => {
const transformConfigMock = getTransformConfigMock();
const updateConfig = applyFormFieldsToTransformConfig(transformConfigMock, {
description: getDescriptionFieldMock(transformConfigMock.description),
docsPerSecond: getDocsPerSecondFieldMock(),
frequency: getFrequencyFieldMock(),
});
const formState = getDefaultState(transformConfigMock);
const updateConfig = applyFormFieldsToTransformConfig(
transformConfigMock,
formState.formFields
);
// This case will return an empty object. In the actual UI, this case should not happen
// because the Update-Button will be disabled when no form field was changed.
expect(Object.keys(updateConfig)).toHaveLength(0);
expect(updateConfig.description).toBe(undefined);
// Destination index `index` attribute is nested under `dest` so we're just checking against that.
expect(updateConfig.dest).toBe(undefined);
// `docs_per_second` is nested under `settings` so we're just checking against that.
expect(updateConfig.settings).toBe(undefined);
expect(updateConfig.frequency).toBe(undefined);
});
test('should include previously nonexisting attributes', () => {
it('should include previously nonexisting attributes', () => {
const { description, frequency, ...transformConfigMock } = getTransformConfigMock();
const updateConfig = applyFormFieldsToTransformConfig(transformConfigMock, {
description: getDescriptionFieldMock('the-new-description'),
docsPerSecond: getDocsPerSecondFieldMock('10'),
frequency: getFrequencyFieldMock('1m'),
const formState = getDefaultState({
...transformConfigMock,
description: 'the-new-description',
dest: {
index: 'the-new-destination-index',
},
frequency: '10m',
settings: {
docs_per_second: 10,
},
});
expect(Object.keys(updateConfig)).toHaveLength(3);
const updateConfig = applyFormFieldsToTransformConfig(
transformConfigMock,
formState.formFields
);
expect(Object.keys(updateConfig)).toHaveLength(4);
expect(updateConfig.description).toBe('the-new-description');
expect(updateConfig.dest?.index).toBe('the-new-destination-index');
expect(updateConfig.settings?.docs_per_second).toBe(10);
expect(updateConfig.frequency).toBe('1m');
expect(updateConfig.frequency).toBe('10m');
});
test('should only include changed form fields', () => {
it('should only include changed form fields', () => {
const transformConfigMock = getTransformConfigMock();
const updateConfig = applyFormFieldsToTransformConfig(transformConfigMock, {
description: getDescriptionFieldMock('the-updated-description'),
docsPerSecond: getDocsPerSecondFieldMock(),
frequency: getFrequencyFieldMock(),
const formState = getDefaultState({
...transformConfigMock,
description: 'the-updated-description',
dest: {
index: 'the-updated-destination-index',
pipeline: 'the-updated-destination-index',
},
});
expect(Object.keys(updateConfig)).toHaveLength(1);
const updateConfig = applyFormFieldsToTransformConfig(
transformConfigMock,
formState.formFields
);
expect(Object.keys(updateConfig)).toHaveLength(2);
expect(updateConfig.description).toBe('the-updated-description');
expect(updateConfig.dest?.index).toBe('the-updated-destination-index');
// `docs_per_second` is nested under `settings` so we're just checking against that.
expect(updateConfig.settings).toBe(undefined);
expect(updateConfig.frequency).toBe(undefined);
});
it('should include dependent form fields', () => {
const transformConfigMock = getTransformConfigMock();
const formState = getDefaultState({
...transformConfigMock,
dest: {
...transformConfigMock.dest,
pipeline: 'the-updated-destination-index',
},
});
const updateConfig = applyFormFieldsToTransformConfig(
transformConfigMock,
formState.formFields
);
expect(Object.keys(updateConfig)).toHaveLength(1);
// It should include the dependent unchanged destination index
expect(updateConfig.dest?.index).toBe(transformConfigMock.dest.index);
expect(updateConfig.dest?.pipeline).toBe('the-updated-destination-index');
});
it('should include the destination index when pipeline is unset', () => {
const transformConfigMock = {
...getTransformConfigMock(),
dest: {
index: 'the-untouched-destination-index',
pipeline: 'the-original-pipeline',
},
};
const formState = getDefaultState({
...transformConfigMock,
dest: {
...transformConfigMock.dest,
pipeline: '',
},
});
const updateConfig = applyFormFieldsToTransformConfig(
transformConfigMock,
formState.formFields
);
expect(Object.keys(updateConfig)).toHaveLength(1);
// It should include the dependent unchanged destination index
expect(updateConfig.dest?.index).toBe(transformConfigMock.dest.index);
expect(typeof updateConfig.dest?.pipeline).toBe('undefined');
});
it('should exclude unrelated dependent form fields', () => {
const transformConfigMock = getTransformConfigMock();
const formState = getDefaultState({
...transformConfigMock,
description: 'the-updated-description',
});
const updateConfig = applyFormFieldsToTransformConfig(
transformConfigMock,
formState.formFields
);
expect(Object.keys(updateConfig)).toHaveLength(1);
// It should exclude the dependent unchanged destination section
expect(typeof updateConfig.dest).toBe('undefined');
expect(updateConfig.description).toBe('the-updated-description');
});
});
describe('Transform: formReducerFactory()', () => {
test('field updates should trigger form validation', () => {
it('field updates should trigger form validation', () => {
const transformConfigMock = getTransformConfigMock();
const reducer = formReducerFactory(transformConfigMock);
@ -150,52 +220,73 @@ describe('Transform: formReducerFactory()', () => {
});
});
describe('Transfom: stringValidator()', () => {
it('should allow an empty string for optional fields', () => {
expect(stringValidator('')).toHaveLength(0);
});
it('should not allow an empty string for required fields', () => {
expect(stringValidator('', false)).toHaveLength(1);
});
});
describe('Transform: frequencyValidator()', () => {
test('it should only allow values between 1s and 1h', () => {
// frequencyValidator() returns an array of error messages so
// an array with a length of 0 means a successful validation.
const transformFrequencyValidator = (arg: string) => frequencyValidator(arg).length === 0;
// invalid
expect(frequencyValidator(0)).toHaveLength(1);
expect(frequencyValidator('0')).toHaveLength(1);
expect(frequencyValidator('0s')).toHaveLength(1);
expect(frequencyValidator(1)).toHaveLength(1);
expect(frequencyValidator('1')).toHaveLength(1);
expect(frequencyValidator('1ms')).toHaveLength(1);
expect(frequencyValidator('1d')).toHaveLength(1);
expect(frequencyValidator('60s')).toHaveLength(1);
expect(frequencyValidator('60m')).toHaveLength(1);
expect(frequencyValidator('60h')).toHaveLength(1);
expect(frequencyValidator('2h')).toHaveLength(1);
expect(frequencyValidator('h2')).toHaveLength(1);
expect(frequencyValidator('2h2')).toHaveLength(1);
expect(frequencyValidator('h2h')).toHaveLength(1);
it('should fail when the input is not an integer and valid time unit.', () => {
expect(transformFrequencyValidator('0')).toBe(false);
expect(transformFrequencyValidator('0.1s')).toBe(false);
expect(transformFrequencyValidator('1.1m')).toBe(false);
expect(transformFrequencyValidator('10.1asdf')).toBe(false);
});
// valid
expect(frequencyValidator('1s')).toHaveLength(0);
expect(frequencyValidator('1m')).toHaveLength(0);
expect(frequencyValidator('1h')).toHaveLength(0);
expect(frequencyValidator('10s')).toHaveLength(0);
expect(frequencyValidator('10m')).toHaveLength(0);
expect(frequencyValidator('59s')).toHaveLength(0);
expect(frequencyValidator('59m')).toHaveLength(0);
it('should only allow s/m/h as time unit.', () => {
expect(transformFrequencyValidator('1ms')).toBe(false);
expect(transformFrequencyValidator('1s')).toBe(true);
expect(transformFrequencyValidator('1m')).toBe(true);
expect(transformFrequencyValidator('1h')).toBe(true);
expect(transformFrequencyValidator('1d')).toBe(false);
});
it('should only allow values above 0 and up to 1 hour.', () => {
expect(transformFrequencyValidator('0s')).toBe(false);
expect(transformFrequencyValidator('1s')).toBe(true);
expect(transformFrequencyValidator('3599s')).toBe(true);
expect(transformFrequencyValidator('3600s')).toBe(true);
expect(transformFrequencyValidator('3601s')).toBe(false);
expect(transformFrequencyValidator('10000s')).toBe(false);
expect(transformFrequencyValidator('0m')).toBe(false);
expect(transformFrequencyValidator('1m')).toBe(true);
expect(transformFrequencyValidator('59m')).toBe(true);
expect(transformFrequencyValidator('60m')).toBe(true);
expect(transformFrequencyValidator('61m')).toBe(false);
expect(transformFrequencyValidator('100m')).toBe(false);
expect(transformFrequencyValidator('0h')).toBe(false);
expect(transformFrequencyValidator('1h')).toBe(true);
expect(transformFrequencyValidator('2h')).toBe(false);
});
});
describe('Transform: numberValidator()', () => {
test('it should only allow numbers', () => {
// numberValidator() returns an array of error messages so
describe('Transform: integerAboveZeroValidator()', () => {
it('should only allow integers above zero', () => {
// integerAboveZeroValidator() returns an array of error messages so
// an array with a length of 0 means a successful validation.
// invalid
expect(numberAboveZeroValidator('a-string')).toHaveLength(1);
expect(numberAboveZeroValidator('0s')).toHaveLength(1);
expect(numberAboveZeroValidator('1m')).toHaveLength(1);
expect(numberAboveZeroValidator(-1)).toHaveLength(1);
expect(numberAboveZeroValidator(0)).toHaveLength(1);
expect(integerAboveZeroValidator('a-string')).toHaveLength(1);
expect(integerAboveZeroValidator('0s')).toHaveLength(1);
expect(integerAboveZeroValidator('1m')).toHaveLength(1);
expect(integerAboveZeroValidator('1.')).toHaveLength(1);
expect(integerAboveZeroValidator('1..')).toHaveLength(1);
expect(integerAboveZeroValidator('1.0')).toHaveLength(1);
expect(integerAboveZeroValidator(-1)).toHaveLength(1);
expect(integerAboveZeroValidator(0)).toHaveLength(1);
expect(integerAboveZeroValidator(0.1)).toHaveLength(1);
// valid
expect(numberAboveZeroValidator(1)).toHaveLength(0);
expect(numberAboveZeroValidator('1')).toHaveLength(0);
expect(integerAboveZeroValidator(1)).toHaveLength(0);
expect(integerAboveZeroValidator('1')).toHaveLength(0);
});
});

View file

@ -4,17 +4,70 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { isEqual } from 'lodash';
import isEqual from 'lodash/isEqual';
import merge from 'lodash/merge';
import { useReducer } from 'react';
import { i18n } from '@kbn/i18n';
import { PostTransformsUpdateRequestSchema } from '../../../../../../common/api_schemas/update_transforms';
import { TransformPivotConfig } from '../../../../../../common/types/transform';
import { getNestedProperty, setNestedProperty } from '../../../../../../common/utils/object_utils';
// This custom hook uses nested reducers to provide a generic framework to manage form state
// and apply it to a final possibly nested configuration object suitable for passing on
// directly to an API call. For now this is only used for the transform edit form.
// Once we apply the functionality to other places, e.g. the transform creation wizard,
// the generic framework code in this file should be moved to a dedicated location.
// The outer most level reducer defines a flat structure of names for form fields.
// This is a flat structure regardless of whether the final request object will be nested.
// For example, `destinationIndex` and `destinationPipeline` will later be nested under `dest`.
interface EditTransformFlyoutFieldsState {
[key: string]: FormField;
description: FormField;
destinationIndex: FormField;
destinationPipeline: FormField;
frequency: FormField;
docsPerSecond: FormField;
}
// The inner reducers apply validation based on supplied attributes of each field.
export interface FormField {
formFieldName: string;
configFieldName: string;
defaultValue: string;
dependsOn: string[];
errorMessages: string[];
isNullable: boolean;
isOptional: boolean;
validator: keyof typeof validate;
value: string;
valueParser: (value: string) => any;
}
// The reducers and utility functions in this file provide the following features:
// - getDefaultState()
// Sets up the initial form state. It supports overrides to apply a pre-existing configuration.
// The implementation of this function is the only one that's specifically required to define
// the features of the transform edit form. All other functions are generic and could be reused
// in the future for other forms.
//
// - formReducerFactory() / formFieldReducer()
// These nested reducers take care of updating and validating the form state.
//
// - applyFormFieldsToTransformConfig() (iterates over getUpdateValue())
// Once a user hits the update button, these functions take care of extracting the information
// necessary to create the update request. They take into account whether a field needs to
// be included at all in the request (for example, if it hadn't been changed).
// The code is also able to identify relationships/dependencies between form fields.
// For example, if the `pipeline` field was changed, it's necessary to make the `index`
// field part of the request, otherwise the update would fail.
// A Validator function takes in a value to check and returns an array of error messages.
// If no messages (empty array) get returned, the value is valid.
type Validator = (arg: any) => string[];
type Validator = (value: any, isOptional?: boolean) => string[];
// Note on the form validation and input components used:
// All inputs use `EuiFieldText` which means all form values will be treated as strings.
@ -25,22 +78,48 @@ type Validator = (arg: any) => string[];
const numberAboveZeroNotValidErrorMessage = i18n.translate(
'xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage',
{
defaultMessage: 'Value needs to be a number above zero.',
defaultMessage: 'Value needs to be an integer above zero.',
}
);
export const numberAboveZeroValidator: Validator = (arg) =>
!isNaN(arg) && parseInt(arg, 10) > 0 ? [] : [numberAboveZeroNotValidErrorMessage];
export const integerAboveZeroValidator: Validator = (value) =>
!isNaN(value) && Number.isInteger(+value) && +value > 0 && !(value + '').includes('.')
? []
: [numberAboveZeroNotValidErrorMessage];
// The way the current form is set up, this validator is just a sanity check,
// it should never trigger an error, because `EuiFieldText` always returns a string.
const numberRange10To10000NotValidErrorMessage = i18n.translate(
'xpack.transform.transformList.editFlyoutFormNumberRange10To10000NotValidErrorMessage',
{
defaultMessage: 'Value needs to be an integer between 10 and 10000.',
}
);
export const integerRange10To10000Validator: Validator = (value) =>
integerAboveZeroValidator(value).length === 0 && +value >= 10 && +value <= 10000
? []
: [numberRange10To10000NotValidErrorMessage];
const requiredErrorMessage = i18n.translate(
'xpack.transform.transformList.editFlyoutFormRequiredErrorMessage',
{
defaultMessage: 'Required field.',
}
);
const stringNotValidErrorMessage = i18n.translate(
'xpack.transform.transformList.editFlyoutFormStringNotValidErrorMessage',
{
defaultMessage: 'Value needs to be of type string.',
}
);
const stringValidator: Validator = (arg) =>
typeof arg === 'string' ? [] : [stringNotValidErrorMessage];
export const stringValidator: Validator = (value, isOptional = true) => {
if (typeof value !== 'string') {
return [stringNotValidErrorMessage];
}
if (value.length === 0 && !isOptional) {
return [requiredErrorMessage];
}
return [];
};
// Only allow frequencies in the form of 1s/1h etc.
const frequencyNotValidErrorMessage = i18n.translate(
@ -57,55 +136,59 @@ export const frequencyValidator: Validator = (arg) => {
// split string by groups of numbers and letters
const regexStr = arg.match(/[a-z]+|[^a-z]+/gi);
return (
// only valid if one group of numbers and one group of letters
regexStr !== null &&
regexStr.length === 2 &&
// only valid if time unit is one of s/m/h
['s', 'm', 'h'].includes(regexStr[1]) &&
// only valid if number is between 1 and 59
parseInt(regexStr[0], 10) > 0 &&
parseInt(regexStr[0], 10) < 60 &&
// if time unit is 'h' then number must not be higher than 1
!(parseInt(regexStr[0], 10) > 1 && regexStr[1] === 'h')
? []
: [frequencyNotValidErrorMessage]
);
// only valid if one group of numbers and one group of letters
if (regexStr === null || (Array.isArray(regexStr) && regexStr.length !== 2)) {
return [frequencyNotValidErrorMessage];
}
const valueNumber = +regexStr[0];
const valueTimeUnit = regexStr[1];
// only valid if number is an integer above 0
if (isNaN(valueNumber) || !Number.isInteger(valueNumber) || valueNumber === 0) {
return [frequencyNotValidErrorMessage];
}
// only valid if value is up to 1 hour
return (valueTimeUnit === 's' && valueNumber <= 3600) ||
(valueTimeUnit === 'm' && valueNumber <= 60) ||
(valueTimeUnit === 'h' && valueNumber === 1)
? []
: [frequencyNotValidErrorMessage];
};
type Validators = 'string' | 'frequency' | 'numberAboveZero';
type Validate = {
[key in Validators]: Validator;
};
const validate: Validate = {
const validate = {
string: stringValidator,
frequency: frequencyValidator,
numberAboveZero: numberAboveZeroValidator,
integerAboveZero: integerAboveZeroValidator,
integerRange10To10000: integerRange10To10000Validator,
} as const;
export const initializeField = (
formFieldName: string,
configFieldName: string,
config: TransformPivotConfig,
overloads?: Partial<FormField>
): FormField => {
const defaultValue = overloads?.defaultValue !== undefined ? overloads.defaultValue : '';
const rawValue = getNestedProperty(config, configFieldName, undefined);
const value = rawValue !== null && rawValue !== undefined ? rawValue.toString() : '';
return {
formFieldName,
configFieldName,
defaultValue,
dependsOn: [],
errorMessages: [],
isNullable: false,
isOptional: true,
validator: 'string',
value,
valueParser: (v) => v,
...(overloads !== undefined ? { ...overloads } : {}),
};
};
export interface FormField {
errorMessages: string[];
isOptional: boolean;
validator: keyof Validate;
value: string;
}
const defaultField: FormField = {
errorMessages: [],
isOptional: true,
validator: 'string',
value: '',
};
interface EditTransformFlyoutFieldsState {
[key: string]: FormField;
description: FormField;
frequency: FormField;
docsPerSecond: FormField;
}
export interface EditTransformFlyoutState {
formFields: EditTransformFlyoutFieldsState;
isFormTouched: boolean;
@ -119,48 +202,95 @@ interface Action {
value: string;
}
// Takes a value from form state and applies it to the structure
// of the expected final configuration request object.
// Considers options like if a value is nullable or optional.
const getUpdateValue = (
attribute: keyof EditTransformFlyoutFieldsState,
config: TransformPivotConfig,
formState: EditTransformFlyoutFieldsState,
enforceFormValue = false
) => {
const formStateAttribute = formState[attribute];
const fallbackValue = formStateAttribute.isNullable ? null : formStateAttribute.defaultValue;
const formValue =
formStateAttribute.value !== ''
? formStateAttribute.valueParser(formStateAttribute.value)
: fallbackValue;
const configValue = getNestedProperty(config, formStateAttribute.configFieldName, fallbackValue);
// only get depending values if we're not already in a call to get depending values.
const dependsOnConfig: PostTransformsUpdateRequestSchema =
enforceFormValue === false
? formStateAttribute.dependsOn.reduce((_dependsOnConfig, dependsOnField) => {
return merge(
{ ..._dependsOnConfig },
getUpdateValue(dependsOnField, config, formState, true)
);
}, {})
: {};
if (formValue === formStateAttribute.defaultValue && formStateAttribute.isOptional) {
return formValue !== configValue ? dependsOnConfig : {};
}
return formValue !== configValue || enforceFormValue
? setNestedProperty(dependsOnConfig, formStateAttribute.configFieldName, formValue)
: {};
};
// Takes in the form configuration and returns a
// request object suitable to be sent to the
// transform update API endpoint.
export const applyFormFieldsToTransformConfig = (
config: TransformPivotConfig,
{ description, docsPerSecond, frequency }: EditTransformFlyoutFieldsState
): PostTransformsUpdateRequestSchema => {
// if the input field was left empty,
// fall back to the default value of `null`
// which will disable throttling.
const docsPerSecondFormValue =
docsPerSecond.value !== '' ? parseInt(docsPerSecond.value, 10) : null;
const docsPerSecondConfigValue = config.settings?.docs_per_second ?? null;
return {
// set the values only if they changed from the default
// and actually differ from the previous value.
...(!(config.frequency === undefined && frequency.value === '') &&
config.frequency !== frequency.value
? { frequency: frequency.value }
: {}),
...(!(config.description === undefined && description.value === '') &&
config.description !== description.value
? { description: description.value }
: {}),
...(docsPerSecondFormValue !== docsPerSecondConfigValue
? { settings: { docs_per_second: docsPerSecondFormValue } }
: {}),
};
};
formState: EditTransformFlyoutFieldsState
): PostTransformsUpdateRequestSchema =>
// Iterates over all form fields and only if necessary applies them to
// the request object used for updating the transform.
Object.keys(formState).reduce(
(updateConfig, field) => merge({ ...updateConfig }, getUpdateValue(field, config, formState)),
{}
);
// Takes in a transform configuration and returns
// the default state to populate the form.
export const getDefaultState = (config: TransformPivotConfig): EditTransformFlyoutState => ({
formFields: {
description: { ...defaultField, value: config?.description ?? '' },
frequency: { ...defaultField, value: config?.frequency ?? '', validator: 'frequency' },
docsPerSecond: {
...defaultField,
value: config?.settings?.docs_per_second?.toString() ?? '',
validator: 'numberAboveZero',
},
// top level attributes
description: initializeField('description', 'description', config),
frequency: initializeField('frequency', 'frequency', config, {
defaultValue: '1m',
validator: 'frequency',
}),
// dest.*
destinationIndex: initializeField('destinationIndex', 'dest.index', config, {
dependsOn: ['destinationPipeline'],
isOptional: false,
}),
destinationPipeline: initializeField('destinationPipeline', 'dest.pipeline', config, {
dependsOn: ['destinationIndex'],
}),
// settings.*
docsPerSecond: initializeField('docsPerSecond', 'settings.docs_per_second', config, {
isNullable: true,
validator: 'integerAboveZero',
valueParser: (v) => (v === '' ? null : +v),
}),
maxPageSearchSize: initializeField(
'maxPageSearchSize',
'settings.max_page_search_size',
config,
{
defaultValue: '500',
validator: 'integerRange10To10000',
valueParser: (v) => +v,
}
),
},
isFormTouched: false,
isFormValid: true,
@ -180,7 +310,7 @@ const formFieldReducer = (state: FormField, value: string): FormField => {
errorMessages:
state.isOptional && typeof value === 'string' && value.length === 0
? []
: validate[state.validator](value),
: validate[state.validator](value, state.isOptional),
value,
};
};
@ -191,6 +321,8 @@ const formFieldReducer = (state: FormField, value: string): FormField => {
// - sets `isFormValid` to have a flag if any of the form fields contains an error.
export const formReducerFactory = (config: TransformPivotConfig) => {
const defaultState = getDefaultState(config);
const defaultFieldValues = Object.values(defaultState.formFields).map((f) => f.value);
return (state: EditTransformFlyoutState, { field, value }: Action): EditTransformFlyoutState => {
const formFields = {
...state.formFields,
@ -200,7 +332,10 @@ export const formReducerFactory = (config: TransformPivotConfig) => {
return {
...state,
formFields,
isFormTouched: !isEqual(defaultState.formFields, formFields),
isFormTouched: !isEqual(
defaultFieldValues,
Object.values(formFields).map((f) => f.value)
),
isFormValid: isFormValid(formFields),
};
};

View file

@ -17777,8 +17777,6 @@
"xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました:",
"xpack.transform.stepDetailsForm.errorGettingTransformList": "既存の変換 ID の取得中にエラーが発生しました:",
"xpack.transform.stepDetailsForm.errorGettingTransformPreview": "変換プレビューの取得中にエラーが発生しました。",
"xpack.transform.stepDetailsForm.indexPatternTimeFilterHelpText": "時間フィルターはこのフィールドを使って時間でフィールドを絞ります。時間フィールドを使わないこともできますが、その場合データを時間範囲で絞ることができません。",
"xpack.transform.stepDetailsForm.indexPatternTimeFilterLabel": "時間フィルターのフィールド名",
"xpack.transform.stepDetailsForm.indexPatternTitleError": "このタイトルのインデックスパターンが既に存在します。",
"xpack.transform.stepDetailsForm.noTimeFieldOptionLabel": "時間フィルターを使用しない",
"xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel": "オプションの変換の説明を選択してください。",
@ -17791,7 +17789,6 @@
"xpack.transform.stepDetailsSummary.continuousModeDateFieldLabel": "連続モード日付フィールド",
"xpack.transform.stepDetailsSummary.createIndexPatternMessage": "このジョブの Kibana インデックスパターンが作成されます。",
"xpack.transform.stepDetailsSummary.destinationIndexLabel": "デスティネーションインデックス",
"xpack.transform.stepDetailsSummary.indexPatternTimeFilterLabel": "時間フィルター",
"xpack.transform.stepDetailsSummary.transformDescriptionLabel": "変換の説明",
"xpack.transform.stepDetailsSummary.transformIdLabel": "ジョブ ID",
"xpack.transform.tableActionLabel": "アクション",
@ -17820,8 +17817,6 @@
"xpack.transform.transformList.editFlyoutCancelButtonText": "キャンセル",
"xpack.transform.transformList.editFlyoutFormDescriptionLabel": "説明",
"xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "スロットリングを有効にするには、インプットドキュメントの毎秒あたりのドキュメントの上限を設定します。",
"xpack.transform.transformList.editFlyoutFormdocsPerSecondLabel": "毎秒あたりのドキュメント",
"xpack.transform.transformList.editFlyoutFormFrequencyHelptext": "変換が連続実行されているときにソースインデックスで変更を確認する間の間隔。また、変換が検索またはインデックス中に一時障害が発生した場合に、再試行する間隔も決定します。最小値は1秒で、最大値は1時間です。",
"xpack.transform.transformList.editFlyoutFormFrequencyLabel": "頻度",
"xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage": "頻度値が無効です。",
"xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage": "値はゼロより大きい数値でなければなりません。",

View file

@ -17787,8 +17787,6 @@
"xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:",
"xpack.transform.stepDetailsForm.errorGettingTransformList": "获取现有转换 ID 时发生错误:",
"xpack.transform.stepDetailsForm.errorGettingTransformPreview": "获取转换预览时发生错误",
"xpack.transform.stepDetailsForm.indexPatternTimeFilterHelpText": "时间筛选将使用此字段按时间筛选您的数据。您可以选择不使用时间字段,但将无法通过时间范围缩小您的数据范围。",
"xpack.transform.stepDetailsForm.indexPatternTimeFilterLabel": "时间筛选字段名称",
"xpack.transform.stepDetailsForm.indexPatternTitleError": "具有此名称的索引模式已存在。",
"xpack.transform.stepDetailsForm.noTimeFieldOptionLabel": "我不想使用时间筛选",
"xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel": "选择可选的转换描述。",
@ -17801,7 +17799,6 @@
"xpack.transform.stepDetailsSummary.continuousModeDateFieldLabel": "连续模式日期字段",
"xpack.transform.stepDetailsSummary.createIndexPatternMessage": "将为此作业创建 Kibana 索引模式。",
"xpack.transform.stepDetailsSummary.destinationIndexLabel": "目标 IP",
"xpack.transform.stepDetailsSummary.indexPatternTimeFilterLabel": "时间筛选",
"xpack.transform.stepDetailsSummary.transformDescriptionLabel": "转换描述",
"xpack.transform.stepDetailsSummary.transformIdLabel": "作业 ID",
"xpack.transform.tableActionLabel": "操作",
@ -17830,8 +17827,6 @@
"xpack.transform.transformList.editFlyoutCancelButtonText": "取消",
"xpack.transform.transformList.editFlyoutFormDescriptionLabel": "描述",
"xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "要启用节流,请设置输入文档限制(每秒文档数)。",
"xpack.transform.transformList.editFlyoutFormdocsPerSecondLabel": "每秒文档数",
"xpack.transform.transformList.editFlyoutFormFrequencyHelptext": "转换不间断运行时检查源索引更改的时间间隔。还决定转换在搜索或索引时发生临时失败时的重试时间间隔。最小值为 1s最大值为 1h。",
"xpack.transform.transformList.editFlyoutFormFrequencyLabel": "频率",
"xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage": "频率值无效。",
"xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage": "值需要是大于零的数字。",

View file

@ -99,6 +99,7 @@ export default function ({ getService }: FtrProviderContext) {
await transform.testExecution.logTestStep(
'should update the transform documents per second'
);
await transform.editFlyout.openTransformEditAccordionAdvancedSettings();
await transform.editFlyout.assertTransformEditFlyoutInputExists('DocsPerSecond');
await transform.editFlyout.assertTransformEditFlyoutInputValue('DocsPerSecond', '');
await transform.editFlyout.setTransformEditFlyoutInputValue(

View file

@ -35,6 +35,12 @@ export function TransformEditFlyoutProvider({ getService }: FtrProviderContext)
);
},
// for now we expect this to be used only for opening the accordion
async openTransformEditAccordionAdvancedSettings() {
await testSubjects.click('transformEditAccordionAdvancedSettings');
await testSubjects.existOrFail('transformEditAccordionAdvancedSettingsContent');
},
async setTransformEditFlyoutInputValue(input: string, value: string) {
await testSubjects.setValue(`transformEditFlyout${input}Input`, value, {
clearWithKeyboard: true,