[Ingest] Data source configuration validation UI (#61180)

* Initial pass at datasource configuration validation

* Show error icon and red text at input and stream levels

* Add tests, fix bugs in validation method

* Fix typings
This commit is contained in:
Jen Huang 2020-04-08 13:33:51 -07:00 committed by GitHub
parent fdb4a37a60
commit 86a2587660
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1038 additions and 92 deletions

View file

@ -6,26 +6,38 @@
import React, { useState, Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiTextColor,
EuiSpacer,
EuiButtonEmpty,
EuiTitle,
EuiIconTip,
} from '@elastic/eui';
import { DatasourceInput, RegistryVarsEntry } from '../../../../types';
import { isAdvancedVar } from '../services';
import { isAdvancedVar, DatasourceConfigValidationResults, validationHasErrors } from '../services';
import { DatasourceInputVarField } from './datasource_input_var_field';
export const DatasourceInputConfig: React.FunctionComponent<{
packageInputVars?: RegistryVarsEntry[];
datasourceInput: DatasourceInput;
updateDatasourceInput: (updatedInput: Partial<DatasourceInput>) => void;
}> = ({ packageInputVars, datasourceInput, updateDatasourceInput }) => {
inputVarsValidationResults: DatasourceConfigValidationResults;
forceShowErrors?: boolean;
}> = ({
packageInputVars,
datasourceInput,
updateDatasourceInput,
inputVarsValidationResults,
forceShowErrors,
}) => {
// Showing advanced options toggle state
const [isShowingAdvanced, setIsShowingAdvanced] = useState<boolean>(false);
// Errors state
const hasErrors = forceShowErrors && validationHasErrors(inputVarsValidationResults);
const requiredVars: RegistryVarsEntry[] = [];
const advancedVars: RegistryVarsEntry[] = [];
@ -40,15 +52,36 @@ export const DatasourceInputConfig: React.FunctionComponent<{
}
return (
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiFlexGroup alignItems="flexStart">
<EuiFlexItem grow={1}>
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.inputSettingsTitle"
defaultMessage="Settings"
/>
</h4>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<h4>
<EuiTextColor color={hasErrors ? 'danger' : 'default'}>
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.inputSettingsTitle"
defaultMessage="Settings"
/>
</EuiTextColor>
</h4>
</EuiFlexItem>
{hasErrors ? (
<EuiFlexItem grow={false}>
<EuiIconTip
content={
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.inputConfigErrorsTooltip"
defaultMessage="Fix configuration errors"
/>
}
position="right"
type="alert"
iconProps={{ color: 'danger' }}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText color="subdued" size="s">
@ -60,7 +93,7 @@ export const DatasourceInputConfig: React.FunctionComponent<{
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiFlexGroup direction="column" gutterSize="m">
{requiredVars.map(varDef => {
const { name: varName, type: varType } = varDef;
@ -81,6 +114,8 @@ export const DatasourceInputConfig: React.FunctionComponent<{
},
});
}}
errors={inputVarsValidationResults.config![varName]}
forceShowErrors={forceShowErrors}
/>
</EuiFlexItem>
);
@ -123,6 +158,8 @@ export const DatasourceInputConfig: React.FunctionComponent<{
},
});
}}
errors={inputVarsValidationResults.config![varName]}
forceShowErrors={forceShowErrors}
/>
</EuiFlexItem>
);
@ -132,6 +169,6 @@ export const DatasourceInputConfig: React.FunctionComponent<{
) : null}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGrid>
</EuiFlexGroup>
);
};

View file

@ -17,8 +17,10 @@ import {
EuiButtonIcon,
EuiHorizontalRule,
EuiSpacer,
EuiIconTip,
} from '@elastic/eui';
import { DatasourceInput, DatasourceInputStream, RegistryInput } from '../../../../types';
import { DatasourceInputValidationResults, validationHasErrors } from '../services';
import { DatasourceInputConfig } from './datasource_input_config';
import { DatasourceInputStreamConfig } from './datasource_input_stream_config';
@ -32,10 +34,21 @@ export const DatasourceInputPanel: React.FunctionComponent<{
packageInput: RegistryInput;
datasourceInput: DatasourceInput;
updateDatasourceInput: (updatedInput: Partial<DatasourceInput>) => void;
}> = ({ packageInput, datasourceInput, updateDatasourceInput }) => {
inputValidationResults: DatasourceInputValidationResults;
forceShowErrors?: boolean;
}> = ({
packageInput,
datasourceInput,
updateDatasourceInput,
inputValidationResults,
forceShowErrors,
}) => {
// Showing streams toggle state
const [isShowingStreams, setIsShowingStreams] = useState<boolean>(false);
// Errors state
const hasErrors = forceShowErrors && validationHasErrors(inputValidationResults);
return (
<EuiPanel>
{/* Header / input-level toggle */}
@ -43,9 +56,32 @@ export const DatasourceInputPanel: React.FunctionComponent<{
<EuiFlexItem grow={false}>
<EuiSwitch
label={
<EuiText>
<h4>{packageInput.title || packageInput.type}</h4>
</EuiText>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText>
<h4>
<EuiTextColor color={hasErrors ? 'danger' : 'default'}>
{packageInput.title || packageInput.type}
</EuiTextColor>
</h4>
</EuiText>
</EuiFlexItem>
{hasErrors ? (
<EuiFlexItem grow={false}>
<EuiIconTip
content={
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.inputLevelErrorsTooltip"
defaultMessage="Fix configuration errors"
/>
}
position="right"
type="alert"
iconProps={{ color: 'danger' }}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
}
checked={datasourceInput.enabled}
onChange={e => {
@ -122,6 +158,8 @@ export const DatasourceInputPanel: React.FunctionComponent<{
packageInputVars={packageInput.vars}
datasourceInput={datasourceInput}
updateDatasourceInput={updateDatasourceInput}
inputVarsValidationResults={{ config: inputValidationResults.config }}
forceShowErrors={forceShowErrors}
/>
<EuiHorizontalRule margin="m" />
</Fragment>
@ -165,6 +203,10 @@ export const DatasourceInputPanel: React.FunctionComponent<{
updateDatasourceInput(updatedInput);
}}
inputStreamValidationResults={
inputValidationResults.streams![datasourceInputStream.id]
}
forceShowErrors={forceShowErrors}
/>
<EuiSpacer size="m" />
<EuiHorizontalRule margin="none" />

View file

@ -7,26 +7,38 @@ import React, { useState, Fragment } from 'react';
import ReactMarkdown from 'react-markdown';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiSwitch,
EuiText,
EuiSpacer,
EuiButtonEmpty,
EuiTextColor,
EuiIconTip,
} from '@elastic/eui';
import { DatasourceInputStream, RegistryStream, RegistryVarsEntry } from '../../../../types';
import { isAdvancedVar } from '../services';
import { isAdvancedVar, DatasourceConfigValidationResults, validationHasErrors } from '../services';
import { DatasourceInputVarField } from './datasource_input_var_field';
export const DatasourceInputStreamConfig: React.FunctionComponent<{
packageInputStream: RegistryStream;
datasourceInputStream: DatasourceInputStream;
updateDatasourceInputStream: (updatedStream: Partial<DatasourceInputStream>) => void;
}> = ({ packageInputStream, datasourceInputStream, updateDatasourceInputStream }) => {
inputStreamValidationResults: DatasourceConfigValidationResults;
forceShowErrors?: boolean;
}> = ({
packageInputStream,
datasourceInputStream,
updateDatasourceInputStream,
inputStreamValidationResults,
forceShowErrors,
}) => {
// Showing advanced options toggle state
const [isShowingAdvanced, setIsShowingAdvanced] = useState<boolean>(false);
// Errors state
const hasErrors = forceShowErrors && validationHasErrors(inputStreamValidationResults);
const requiredVars: RegistryVarsEntry[] = [];
const advancedVars: RegistryVarsEntry[] = [];
@ -41,10 +53,33 @@ export const DatasourceInputStreamConfig: React.FunctionComponent<{
}
return (
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem grow={1}>
<EuiSwitch
label={packageInputStream.title || packageInputStream.dataset}
label={
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiTextColor color={hasErrors ? 'danger' : 'default'}>
{packageInputStream.title || packageInputStream.dataset}
</EuiTextColor>
</EuiFlexItem>
{hasErrors ? (
<EuiFlexItem grow={false}>
<EuiIconTip
content={
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.streamLevelErrorsTooltip"
defaultMessage="Fix configuration errors"
/>
}
position="right"
type="alert"
iconProps={{ color: 'danger' }}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
}
checked={datasourceInputStream.enabled}
onChange={e => {
const enabled = e.target.checked;
@ -62,7 +97,7 @@ export const DatasourceInputStreamConfig: React.FunctionComponent<{
</Fragment>
) : null}
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiFlexGroup direction="column" gutterSize="m">
{requiredVars.map(varDef => {
const { name: varName, type: varType } = varDef;
@ -83,6 +118,8 @@ export const DatasourceInputStreamConfig: React.FunctionComponent<{
},
});
}}
errors={inputStreamValidationResults.config![varName]}
forceShowErrors={forceShowErrors}
/>
</EuiFlexItem>
);
@ -125,6 +162,8 @@ export const DatasourceInputStreamConfig: React.FunctionComponent<{
},
});
}}
errors={inputStreamValidationResults.config![varName]}
forceShowErrors={forceShowErrors}
/>
</EuiFlexItem>
);
@ -134,6 +173,6 @@ export const DatasourceInputStreamConfig: React.FunctionComponent<{
) : null}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGrid>
</EuiFlexGroup>
);
};

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFormRow, EuiFieldText, EuiComboBox, EuiText, EuiCodeEditor } from '@elastic/eui';
@ -16,12 +16,20 @@ export const DatasourceInputVarField: React.FunctionComponent<{
varDef: RegistryVarsEntry;
value: any;
onChange: (newValue: any) => void;
}> = ({ varDef, value, onChange }) => {
errors?: string[] | null;
forceShowErrors?: boolean;
}> = ({ varDef, value, onChange, errors: varErrors, forceShowErrors }) => {
const [isDirty, setIsDirty] = useState<boolean>(false);
const { multi, required, type, title, name, description } = varDef;
const isInvalid = (isDirty || forceShowErrors) && !!varErrors;
const errors = isInvalid ? varErrors : null;
const renderField = () => {
if (varDef.multi) {
if (multi) {
return (
<EuiComboBox
noSuggestions
isInvalid={isInvalid}
selectedOptions={value.map((val: string) => ({ label: val }))}
onCreateOption={(newVal: any) => {
onChange([...value, newVal]);
@ -29,10 +37,11 @@ export const DatasourceInputVarField: React.FunctionComponent<{
onChange={(newVals: any[]) => {
onChange(newVals.map(val => val.label));
}}
onBlur={() => setIsDirty(true)}
/>
);
}
if (varDef.type === 'yaml') {
if (type === 'yaml') {
return (
<EuiCodeEditor
width="100%"
@ -46,22 +55,27 @@ export const DatasourceInputVarField: React.FunctionComponent<{
}}
value={value}
onChange={newVal => onChange(newVal)}
onBlur={() => setIsDirty(true)}
/>
);
}
return (
<EuiFieldText
isInvalid={isInvalid}
value={value === undefined ? '' : value}
onChange={e => onChange(e.target.value)}
onBlur={() => setIsDirty(true)}
/>
);
};
return (
<EuiFormRow
label={varDef.title || varDef.name}
isInvalid={isInvalid}
error={errors}
label={title || name}
labelAppend={
!varDef.required ? (
!required ? (
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.inputVarFieldOptionalLabel"
@ -70,7 +84,7 @@ export const DatasourceInputVarField: React.FunctionComponent<{
</EuiText>
) : null
}
helpText={<ReactMarkdown source={varDef.description} />}
helpText={<ReactMarkdown source={description} />}
>
{renderField()}
</EuiFormRow>

View file

@ -5,3 +5,4 @@
*/
export { CreateDatasourcePageLayout } from './layout';
export { DatasourceInputPanel } from './datasource_input_panel';
export { DatasourceInputVarField } from './datasource_input_var_field';

View file

@ -21,6 +21,7 @@ import { useLinks as useEPMLinks } from '../../epm/hooks';
import { CreateDatasourcePageLayout } from './components';
import { CreateDatasourceFrom, CreateDatasourceStep } from './types';
import { CREATE_DATASOURCE_STEP_PATHS } from './constants';
import { DatasourceValidationResults, validateDatasource } from './services';
import { StepSelectPackage } from './step_select_package';
import { StepSelectConfig } from './step_select_config';
import { StepConfigureDatasource } from './step_configure_datasource';
@ -51,6 +52,9 @@ export const CreateDatasourcePage: React.FunctionComponent = () => {
inputs: [],
});
// Datasource validation state
const [validationResults, setValidationResults] = useState<DatasourceValidationResults>();
// Update package info method
const updatePackageInfo = (updatedPackageInfo: PackageInfo | undefined) => {
if (updatedPackageInfo) {
@ -84,9 +88,18 @@ export const CreateDatasourcePage: React.FunctionComponent = () => {
...updatedFields,
};
setDatasource(newDatasource);
// eslint-disable-next-line no-console
console.debug('Datasource updated', newDatasource);
updateDatasourceValidation(newDatasource);
};
const updateDatasourceValidation = (newDatasource?: NewDatasource) => {
if (packageInfo) {
const newValidationResult = validateDatasource(newDatasource || datasource, packageInfo);
setValidationResults(newValidationResult);
// eslint-disable-next-line no-console
console.debug('Datasource validation results', newValidationResult);
}
};
// Cancel url
@ -202,6 +215,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => {
packageInfo={packageInfo}
datasource={datasource}
updateDatasource={updateDatasource}
validationResults={validationResults!}
backLink={
<EuiButtonEmpty href={firstStepUrl} iconType="arrowLeft" iconSide="left">
{from === 'config' ? (

View file

@ -4,3 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { isAdvancedVar } from './is_advanced_var';
export {
DatasourceValidationResults,
DatasourceConfigValidationResults,
DatasourceInputValidationResults,
validateDatasource,
validationHasErrors,
} from './validate_datasource';

View file

@ -0,0 +1,504 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
PackageInfo,
InstallationStatus,
NewDatasource,
RegistryDatasource,
} from '../../../../types';
import { validateDatasource, validationHasErrors } from './validate_datasource';
describe('Ingest Manager - validateDatasource()', () => {
const mockPackage = ({
name: 'mock-package',
title: 'Mock package',
version: '0.0.0',
description: 'description',
type: 'mock',
categories: [],
requirement: { kibana: { versions: '' }, elasticsearch: { versions: '' } },
format_version: '',
download: '',
path: '',
assets: {
kibana: {
dashboard: [],
visualization: [],
search: [],
'index-pattern': [],
},
},
status: InstallationStatus.notInstalled,
datasources: [
{
name: 'datasource1',
title: 'Datasource 1',
description: 'test datasource',
inputs: [
{
type: 'foo',
title: 'Foo',
vars: [
{ default: 'foo-input-var-value', name: 'foo-input-var-name', type: 'text' },
{
default: 'foo-input2-var-value',
name: 'foo-input2-var-name',
required: true,
type: 'text',
},
{ name: 'foo-input3-var-name', type: 'text', required: true, multi: true },
],
streams: [
{
dataset: 'foo',
input: 'foo',
title: 'Foo',
vars: [{ name: 'var-name', type: 'yaml' }],
},
],
},
{
type: 'bar',
title: 'Bar',
vars: [
{
default: ['value1', 'value2'],
name: 'bar-input-var-name',
type: 'text',
multi: true,
},
{ name: 'bar-input2-var-name', required: true, type: 'text' },
],
streams: [
{
dataset: 'bar',
input: 'bar',
title: 'Bar',
vars: [{ name: 'var-name', type: 'yaml', required: true }],
},
{
dataset: 'bar2',
input: 'bar2',
title: 'Bar 2',
vars: [{ default: 'bar2-var-value', name: 'var-name', type: 'text' }],
},
],
},
{
type: 'with-no-config-or-streams',
title: 'With no config or streams',
streams: [],
},
{
type: 'with-disabled-streams',
title: 'With disabled streams',
streams: [
{
dataset: 'disabled',
input: 'disabled',
title: 'Disabled',
enabled: false,
vars: [{ multi: true, required: true, name: 'var-name', type: 'text' }],
},
{ dataset: 'disabled2', input: 'disabled2', title: 'Disabled 2', enabled: false },
],
},
],
},
],
} as unknown) as PackageInfo;
const validDatasource: NewDatasource = {
name: 'datasource1-1',
config_id: 'test-config',
enabled: true,
output_id: 'test-output',
inputs: [
{
type: 'foo',
enabled: true,
config: {
'foo-input-var-name': { value: 'foo-input-var-value', type: 'text' },
'foo-input2-var-name': { value: 'foo-input2-var-value', type: 'text' },
'foo-input3-var-name': { value: ['test'], type: 'text' },
},
streams: [
{
id: 'foo-foo',
dataset: 'foo',
enabled: true,
config: { 'var-name': { value: 'test_yaml: value', type: 'yaml' } },
},
],
},
{
type: 'bar',
enabled: true,
config: {
'bar-input-var-name': { value: ['value1', 'value2'], type: 'text' },
'bar-input2-var-name': { value: 'test', type: 'text' },
},
streams: [
{
id: 'bar-bar',
dataset: 'bar',
enabled: true,
config: { 'var-name': { value: 'test_yaml: value', type: 'yaml' } },
},
{
id: 'bar-bar2',
dataset: 'bar2',
enabled: true,
config: { 'var-name': { value: undefined, type: 'text' } },
},
],
},
{
type: 'with-no-config-or-streams',
enabled: true,
streams: [],
},
{
type: 'with-disabled-streams',
enabled: true,
streams: [
{
id: 'with-disabled-streams-disabled',
dataset: 'disabled',
enabled: false,
config: { 'var-name': { value: undefined, type: 'text' } },
},
{
id: 'with-disabled-streams-disabled2',
dataset: 'disabled2',
enabled: false,
},
],
},
],
};
const invalidDatasource: NewDatasource = {
...validDatasource,
name: '',
inputs: [
{
type: 'foo',
enabled: true,
config: {
'foo-input-var-name': { value: undefined, type: 'text' },
'foo-input2-var-name': { value: '', type: 'text' },
'foo-input3-var-name': { value: [], type: 'text' },
},
streams: [
{
id: 'foo-foo',
dataset: 'foo',
enabled: true,
config: { 'var-name': { value: 'invalidyaml: test\n foo bar:', type: 'yaml' } },
},
],
},
{
type: 'bar',
enabled: true,
config: {
'bar-input-var-name': { value: 'invalid value for multi', type: 'text' },
'bar-input2-var-name': { value: undefined, type: 'text' },
},
streams: [
{
id: 'bar-bar',
dataset: 'bar',
enabled: true,
config: { 'var-name': { value: ' \n\n', type: 'yaml' } },
},
{
id: 'bar-bar2',
dataset: 'bar2',
enabled: true,
config: { 'var-name': { value: undefined, type: 'text' } },
},
],
},
{
type: 'with-no-config-or-streams',
enabled: true,
streams: [],
},
{
type: 'with-disabled-streams',
enabled: true,
streams: [
{
id: 'with-disabled-streams-disabled',
dataset: 'disabled',
enabled: false,
config: {
'var-name': {
value: 'invalid value but not checked due to not enabled',
type: 'text',
},
},
},
{
id: 'with-disabled-streams-disabled2',
dataset: 'disabled2',
enabled: false,
},
],
},
],
};
const noErrorsValidationResults = {
name: null,
description: null,
inputs: {
foo: {
config: {
'foo-input-var-name': null,
'foo-input2-var-name': null,
'foo-input3-var-name': null,
},
streams: { 'foo-foo': { config: { 'var-name': null } } },
},
bar: {
config: { 'bar-input-var-name': null, 'bar-input2-var-name': null },
streams: {
'bar-bar': { config: { 'var-name': null } },
'bar-bar2': { config: { 'var-name': null } },
},
},
'with-disabled-streams': {
streams: { 'with-disabled-streams-disabled': { config: { 'var-name': null } } },
},
},
};
it('returns no errors for valid datasource configuration', () => {
expect(validateDatasource(validDatasource, mockPackage)).toEqual(noErrorsValidationResults);
});
it('returns errors for invalid datasource configuration', () => {
expect(validateDatasource(invalidDatasource, mockPackage)).toEqual({
name: ['Name is required'],
description: null,
inputs: {
foo: {
config: {
'foo-input-var-name': null,
'foo-input2-var-name': ['foo-input2-var-name is required'],
'foo-input3-var-name': ['foo-input3-var-name is required'],
},
streams: { 'foo-foo': { config: { 'var-name': ['Invalid YAML format'] } } },
},
bar: {
config: {
'bar-input-var-name': ['Invalid format'],
'bar-input2-var-name': ['bar-input2-var-name is required'],
},
streams: {
'bar-bar': { config: { 'var-name': ['var-name is required'] } },
'bar-bar2': { config: { 'var-name': null } },
},
},
'with-disabled-streams': {
streams: { 'with-disabled-streams-disabled': { config: { 'var-name': null } } },
},
},
});
});
it('returns no errors for disabled inputs', () => {
const disabledInputs = invalidDatasource.inputs.map(input => ({ ...input, enabled: false }));
expect(validateDatasource({ ...validDatasource, inputs: disabledInputs }, mockPackage)).toEqual(
noErrorsValidationResults
);
});
it('returns only datasource and input-level errors for disabled streams', () => {
const inputsWithDisabledStreams = invalidDatasource.inputs.map(input =>
input.streams
? {
...input,
streams: input.streams.map(stream => ({ ...stream, enabled: false })),
}
: input
);
expect(
validateDatasource({ ...invalidDatasource, inputs: inputsWithDisabledStreams }, mockPackage)
).toEqual({
name: ['Name is required'],
description: null,
inputs: {
foo: {
config: {
'foo-input-var-name': null,
'foo-input2-var-name': ['foo-input2-var-name is required'],
'foo-input3-var-name': ['foo-input3-var-name is required'],
},
streams: { 'foo-foo': { config: { 'var-name': null } } },
},
bar: {
config: {
'bar-input-var-name': ['Invalid format'],
'bar-input2-var-name': ['bar-input2-var-name is required'],
},
streams: {
'bar-bar': { config: { 'var-name': null } },
'bar-bar2': { config: { 'var-name': null } },
},
},
'with-disabled-streams': {
streams: { 'with-disabled-streams-disabled': { config: { 'var-name': null } } },
},
},
});
});
it('returns no errors for packages with no datasources', () => {
expect(
validateDatasource(validDatasource, {
...mockPackage,
datasources: undefined,
})
).toEqual({
name: null,
description: null,
inputs: null,
});
expect(
validateDatasource(validDatasource, {
...mockPackage,
datasources: [],
})
).toEqual({
name: null,
description: null,
inputs: null,
});
});
it('returns no errors for packages with no inputs', () => {
expect(
validateDatasource(validDatasource, {
...mockPackage,
datasources: [{} as RegistryDatasource],
})
).toEqual({
name: null,
description: null,
inputs: null,
});
expect(
validateDatasource(validDatasource, {
...mockPackage,
datasources: [({ inputs: [] } as unknown) as RegistryDatasource],
})
).toEqual({
name: null,
description: null,
inputs: null,
});
});
});
describe('Ingest Manager - validationHasErrors()', () => {
it('returns true for stream validation results with errors', () => {
expect(
validationHasErrors({
config: { foo: ['foo error'], bar: null },
})
).toBe(true);
});
it('returns false for stream validation results with no errors', () => {
expect(
validationHasErrors({
config: { foo: null, bar: null },
})
).toBe(false);
});
it('returns true for input validation results with errors', () => {
expect(
validationHasErrors({
config: { foo: ['foo error'], bar: null },
streams: { stream1: { config: { foo: null, bar: null } } },
})
).toBe(true);
expect(
validationHasErrors({
config: { foo: null, bar: null },
streams: { stream1: { config: { foo: ['foo error'], bar: null } } },
})
).toBe(true);
});
it('returns false for input validation results with no errors', () => {
expect(
validationHasErrors({
config: { foo: null, bar: null },
streams: { stream1: { config: { foo: null, bar: null } } },
})
).toBe(false);
});
it('returns true for datasource validation results with errors', () => {
expect(
validationHasErrors({
name: ['name error'],
description: null,
inputs: {
input1: {
config: { foo: null, bar: null },
streams: { stream1: { config: { foo: null, bar: null } } },
},
},
})
).toBe(true);
expect(
validationHasErrors({
name: null,
description: null,
inputs: {
input1: {
config: { foo: ['foo error'], bar: null },
streams: { stream1: { config: { foo: null, bar: null } } },
},
},
})
).toBe(true);
expect(
validationHasErrors({
name: null,
description: null,
inputs: {
input1: {
config: { foo: null, bar: null },
streams: { stream1: { config: { foo: ['foo error'], bar: null } } },
},
},
})
).toBe(true);
});
it('returns false for datasource validation results with no errors', () => {
expect(
validationHasErrors({
name: null,
description: null,
inputs: {
input1: {
config: { foo: null, bar: null },
streams: { stream1: { config: { foo: null, bar: null } } },
},
},
})
).toBe(false);
});
});

View file

@ -0,0 +1,232 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { safeLoad } from 'js-yaml';
import { getFlattenedObject } from '../../../../services';
import {
NewDatasource,
DatasourceInput,
DatasourceInputStream,
DatasourceConfigRecordEntry,
PackageInfo,
RegistryInput,
RegistryVarsEntry,
} from '../../../../types';
type Errors = string[] | null;
type ValidationEntry = Record<string, Errors>;
export interface DatasourceConfigValidationResults {
config?: ValidationEntry;
}
export type DatasourceInputValidationResults = DatasourceConfigValidationResults & {
streams?: Record<DatasourceInputStream['id'], DatasourceConfigValidationResults>;
};
export interface DatasourceValidationResults {
name: Errors;
description: Errors;
inputs: Record<DatasourceInput['type'], DatasourceInputValidationResults> | null;
}
/*
* Returns validation information for a given datasource configuration and package info
* Note: this method assumes that `datasource` is correctly structured for the given package
*/
export const validateDatasource = (
datasource: NewDatasource,
packageInfo: PackageInfo
): DatasourceValidationResults => {
const validationResults: DatasourceValidationResults = {
name: null,
description: null,
inputs: {},
};
if (!datasource.name.trim()) {
validationResults.name = [
i18n.translate('xpack.ingestManager.datasourceValidation.nameRequiredErrorMessage', {
defaultMessage: 'Name is required',
}),
];
}
if (
!packageInfo.datasources ||
packageInfo.datasources.length === 0 ||
!packageInfo.datasources[0] ||
!packageInfo.datasources[0].inputs ||
packageInfo.datasources[0].inputs.length === 0
) {
validationResults.inputs = null;
return validationResults;
}
const registryInputsByType: Record<
string,
RegistryInput
> = packageInfo.datasources[0].inputs.reduce((inputs, registryInput) => {
inputs[registryInput.type] = registryInput;
return inputs;
}, {} as Record<string, RegistryInput>);
// Validate each datasource input with either its own config fields or streams
datasource.inputs.forEach(input => {
if (!input.config && !input.streams) {
return;
}
const inputValidationResults: DatasourceInputValidationResults = {
config: undefined,
streams: {},
};
const inputVarsByName = (registryInputsByType[input.type].vars || []).reduce(
(vars, registryVar) => {
vars[registryVar.name] = registryVar;
return vars;
},
{} as Record<string, RegistryVarsEntry>
);
// Validate input-level config fields
const inputConfigs = Object.entries(input.config || {});
if (inputConfigs.length) {
inputValidationResults.config = inputConfigs.reduce((results, [name, configEntry]) => {
results[name] = input.enabled
? validateDatasourceConfig(configEntry, inputVarsByName[name])
: null;
return results;
}, {} as ValidationEntry);
} else {
delete inputValidationResults.config;
}
// Validate each input stream with config fields
if (input.streams.length) {
input.streams.forEach(stream => {
if (!stream.config) {
return;
}
const streamValidationResults: DatasourceConfigValidationResults = {
config: undefined,
};
const streamVarsByName = (
(
registryInputsByType[input.type].streams.find(
registryStream => registryStream.dataset === stream.dataset
) || {}
).vars || []
).reduce((vars, registryVar) => {
vars[registryVar.name] = registryVar;
return vars;
}, {} as Record<string, RegistryVarsEntry>);
// Validate stream-level config fields
streamValidationResults.config = Object.entries(stream.config).reduce(
(results, [name, configEntry]) => {
results[name] =
input.enabled && stream.enabled
? validateDatasourceConfig(configEntry, streamVarsByName[name])
: null;
return results;
},
{} as ValidationEntry
);
inputValidationResults.streams![stream.id] = streamValidationResults;
});
} else {
delete inputValidationResults.streams;
}
if (inputValidationResults.config || inputValidationResults.streams) {
validationResults.inputs![input.type] = inputValidationResults;
}
});
if (Object.entries(validationResults.inputs!).length === 0) {
validationResults.inputs = null;
}
return validationResults;
};
const validateDatasourceConfig = (
configEntry: DatasourceConfigRecordEntry,
varDef: RegistryVarsEntry
): string[] | null => {
const errors = [];
const { value } = configEntry;
let parsedValue: any = value;
if (typeof value === 'string') {
parsedValue = value.trim();
}
if (varDef.required) {
if (parsedValue === undefined || (typeof parsedValue === 'string' && !parsedValue)) {
errors.push(
i18n.translate('xpack.ingestManager.datasourceValidation.requiredErrorMessage', {
defaultMessage: '{fieldName} is required',
values: {
fieldName: varDef.title || varDef.name,
},
})
);
}
}
if (varDef.type === 'yaml') {
try {
parsedValue = safeLoad(value);
} catch (e) {
errors.push(
i18n.translate('xpack.ingestManager.datasourceValidation.invalidYamlFormatErrorMessage', {
defaultMessage: 'Invalid YAML format',
})
);
}
}
if (varDef.multi) {
if (parsedValue && !Array.isArray(parsedValue)) {
errors.push(
i18n.translate('xpack.ingestManager.datasourceValidation.invalidArrayErrorMessage', {
defaultMessage: 'Invalid format',
})
);
}
if (
varDef.required &&
(!parsedValue || (Array.isArray(parsedValue) && parsedValue.length === 0))
) {
errors.push(
i18n.translate('xpack.ingestManager.datasourceValidation.requiredErrorMessage', {
defaultMessage: '{fieldName} is required',
values: {
fieldName: varDef.title || varDef.name,
},
})
);
}
}
return errors.length ? errors : null;
};
export const validationHasErrors = (
validationResults:
| DatasourceValidationResults
| DatasourceInputValidationResults
| DatasourceConfigValidationResults
) => {
const flattenedValidation = getFlattenedObject(validationResults);
return !!Object.entries(flattenedValidation).find(([, value]) => !!value);
};

View file

@ -9,17 +9,16 @@ import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiSteps,
EuiPanel,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiFieldText,
EuiButtonEmpty,
EuiSpacer,
EuiEmptyPrompt,
EuiText,
EuiButton,
EuiComboBox,
EuiCallOut,
} from '@elastic/eui';
import {
AgentConfig,
@ -28,21 +27,37 @@ import {
NewDatasource,
DatasourceInput,
} from '../../../types';
import { Loading } from '../../../components';
import { packageToConfigDatasourceInputs } from '../../../services';
import { DatasourceInputPanel } from './components';
import { DatasourceValidationResults, validationHasErrors } from './services';
import { DatasourceInputPanel, DatasourceInputVarField } from './components';
export const StepConfigureDatasource: React.FunctionComponent<{
agentConfig: AgentConfig;
packageInfo: PackageInfo;
datasource: NewDatasource;
updateDatasource: (fields: Partial<NewDatasource>) => void;
validationResults: DatasourceValidationResults;
backLink: JSX.Element;
cancelUrl: string;
onNext: () => void;
}> = ({ agentConfig, packageInfo, datasource, updateDatasource, backLink, cancelUrl, onNext }) => {
}> = ({
agentConfig,
packageInfo,
datasource,
updateDatasource,
validationResults,
backLink,
cancelUrl,
onNext,
}) => {
// Form show/hide states
const [isShowingAdvancedDefine, setIsShowingAdvancedDefine] = useState<boolean>(false);
// Form submit state
const [submitAttempted, setSubmitAttempted] = useState<boolean>(false);
const hasErrors = validationResults ? validationHasErrors(validationResults) : false;
// Update datasource's package and config info
useEffect(() => {
const dsPackage = datasource.package;
@ -81,56 +96,56 @@ export const StepConfigureDatasource: React.FunctionComponent<{
}, [datasource.package, datasource.config_id, agentConfig, packageInfo, updateDatasource]);
// Step A, define datasource
const DefineDatasource = (
const renderDefineDatasource = () => (
<EuiPanel>
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.datasourceNameInputLabel"
defaultMessage="Data source name"
/>
}
>
<EuiFieldText
value={datasource.name}
onChange={e =>
updateDatasource({
name: e.target.value,
})
}
/>
</EuiFormRow>
<EuiFlexGroup>
<EuiFlexItem grow={1}>
<DatasourceInputVarField
varDef={{
name: 'name',
title: i18n.translate(
'xpack.ingestManager.createDatasource.stepConfigure.datasourceNameInputLabel',
{
defaultMessage: 'Data source name',
}
),
type: 'text',
required: true,
}}
value={datasource.name}
onChange={(newValue: any) => {
updateDatasource({
name: newValue,
});
}}
errors={validationResults!.name}
forceShowErrors={submitAttempted}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.datasourceDescriptionInputLabel"
defaultMessage="Description"
/>
}
labelAppend={
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.inputVarFieldOptionalLabel"
defaultMessage="Optional"
/>
</EuiText>
}
>
<EuiFieldText
value={datasource.description}
onChange={e =>
updateDatasource({
description: e.target.value,
})
}
/>
</EuiFormRow>
<EuiFlexItem grow={1}>
<DatasourceInputVarField
varDef={{
name: 'description',
title: i18n.translate(
'xpack.ingestManager.createDatasource.stepConfigure.datasourceDescriptionInputLabel',
{
defaultMessage: 'Description',
}
),
type: 'text',
required: false,
}}
value={datasource.description}
onChange={(newValue: any) => {
updateDatasource({
description: newValue,
});
}}
errors={validationResults!.description}
forceShowErrors={submitAttempted}
/>
</EuiFlexItem>
</EuiFlexGrid>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiButtonEmpty
flush="left"
@ -147,8 +162,8 @@ export const StepConfigureDatasource: React.FunctionComponent<{
{isShowingAdvancedDefine ? (
<Fragment>
<EuiSpacer size="m" />
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem grow={1}>
<EuiFormRow
label={
<FormattedMessage
@ -174,7 +189,8 @@ export const StepConfigureDatasource: React.FunctionComponent<{
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGrid>
<EuiFlexItem grow={1} />
</EuiFlexGroup>
</Fragment>
) : null}
</EuiPanel>
@ -182,7 +198,7 @@ export const StepConfigureDatasource: React.FunctionComponent<{
// Step B, configure inputs (and their streams)
// Assume packages only export one datasource for now
const ConfigureInputs =
const renderConfigureInputs = () =>
packageInfo.datasources &&
packageInfo.datasources[0] &&
packageInfo.datasources[0].inputs &&
@ -208,6 +224,8 @@ export const StepConfigureDatasource: React.FunctionComponent<{
inputs: newInputs,
});
}}
inputValidationResults={validationResults!.inputs![datasourceInput.type]}
forceShowErrors={submitAttempted}
/>
</EuiFlexItem>
) : null;
@ -232,7 +250,7 @@ export const StepConfigureDatasource: React.FunctionComponent<{
</EuiPanel>
);
return (
return validationResults ? (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none">
@ -251,7 +269,7 @@ export const StepConfigureDatasource: React.FunctionComponent<{
defaultMessage: 'Define your datasource',
}
),
children: DefineDatasource,
children: renderDefineDatasource(),
},
{
title: i18n.translate(
@ -260,13 +278,34 @@ export const StepConfigureDatasource: React.FunctionComponent<{
defaultMessage: 'Choose the data you want to collect',
}
),
children: ConfigureInputs,
children: renderConfigureInputs(),
},
]}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{hasErrors && submitAttempted ? (
<EuiFlexItem>
<EuiCallOut
title={i18n.translate(
'xpack.ingestManager.createDatasource.stepConfigure.validationErrorTitle',
{
defaultMessage: 'Your data source configuration has errors',
}
)}
color="danger"
>
<p>
<FormattedMessage
id="xpack.ingestManager.createDatasource.stepConfigure.validationErrorText"
defaultMessage="Please fix the above errors before continuing"
/>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</EuiFlexItem>
) : null}
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
@ -278,7 +317,17 @@ export const StepConfigureDatasource: React.FunctionComponent<{
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill iconType="arrowRight" iconSide="right" onClick={() => onNext()}>
<EuiButton
fill
iconType="arrowRight"
iconSide="right"
onClick={() => {
setSubmitAttempted(true);
if (!hasErrors) {
onNext();
}
}}
>
<FormattedMessage
id="xpack.ingestManager.createDatasource.continueButtonText"
defaultMessage="Continue"
@ -288,5 +337,7 @@ export const StepConfigureDatasource: React.FunctionComponent<{
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<Loading />
);
};

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { getFlattenedObject } from '../../../../../../../src/core/utils';
export {
agentConfigRouteService,
datasourceRouteService,

View file

@ -16,6 +16,7 @@ export {
NewDatasource,
DatasourceInput,
DatasourceInputStream,
DatasourceConfigRecordEntry,
// API schemas - Agent Config
GetAgentConfigsResponse,
GetAgentConfigsResponseItem,
@ -56,6 +57,7 @@ export {
RegistryVarsEntry,
RegistryInput,
RegistryStream,
RegistryDatasource,
PackageList,
PackageListItem,
PackagesGroupedByStatus,
@ -70,4 +72,5 @@ export {
DeletePackageResponse,
DetailViewPanelName,
InstallStatus,
InstallationStatus,
} from '../../../../common';