[Form Helpers + Template UI] Refactor Template UI using Form lib + helpers (#45287) (#46231)

This commit is contained in:
Sébastien Loix 2019-09-20 18:48:20 +02:00 committed by GitHub
parent 7c5e01fb94
commit 8651c64a29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 2506 additions and 902 deletions

View file

@ -0,0 +1,53 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { FieldHook, FIELD_TYPES } from '../hook_form_lib';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
import {
TextField,
NumericField,
CheckBoxField,
ComboBoxField,
MultiSelectField,
SelectField,
ToggleField,
} from './fields';
const mapTypeToFieldComponent = {
[FIELD_TYPES.TEXT]: TextField,
[FIELD_TYPES.NUMBER]: NumericField,
[FIELD_TYPES.CHECKBOX]: CheckBoxField,
[FIELD_TYPES.COMBO_BOX]: ComboBoxField,
[FIELD_TYPES.MULTI_SELECT]: MultiSelectField,
[FIELD_TYPES.SELECT]: SelectField,
[FIELD_TYPES.TOGGLE]: ToggleField,
};
export const Field = (props: Props) => {
const FieldComponent = mapTypeToFieldComponent[props.field.type] || TextField;
return <FieldComponent {...props} />;
};

View file

@ -0,0 +1,56 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiFormRow, EuiCheckbox } from '@elastic/eui';
import uuid from 'uuid';
import { FieldHook } from '../../hook_form_lib';
import { getFieldValidityAndErrorMessage } from '../helpers';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
export const CheckBoxField = ({ field, euiFieldProps = {}, ...rest }: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
return (
<EuiFormRow
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={rest['data-test-subj']}
describedByIds={rest.idAria ? [rest.idAria] : undefined}
>
<EuiCheckbox
label={field.label}
checked={field.value as boolean}
onChange={field.onChange}
id={euiFieldProps.id || uuid()}
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};

View file

@ -0,0 +1,107 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui';
import { FieldHook, VALIDATION_TYPES, FieldValidateResponse } from '../../hook_form_lib';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
export const ComboBoxField = ({ field, euiFieldProps = {}, ...rest }: Props) => {
// Errors for the comboBox value (the "array")
const errorMessageField = field.getErrorsMessages();
// Errors for comboBox option added (the array "item")
const errorMessageArrayItem = field.getErrorsMessages({
validationType: VALIDATION_TYPES.ARRAY_ITEM,
});
const isInvalid = field.errors.length
? errorMessageField !== null || errorMessageArrayItem !== null
: false;
// Concatenate error messages.
const errorMessage =
errorMessageField && errorMessageArrayItem
? `${errorMessageField}, ${errorMessageArrayItem}`
: errorMessageField
? errorMessageField
: errorMessageArrayItem;
const onCreateComboOption = (value: string) => {
// Note: for now, all validations for a comboBox array item have to be synchronous
// If there is a need to support asynchronous validation, we'll work on it (and will need to update the <EuiComboBox /> logic).
const { isValid } = field.validate({
value,
validationType: VALIDATION_TYPES.ARRAY_ITEM,
}) as FieldValidateResponse;
if (!isValid) {
// Return false to explicitly reject the user's input.
return false;
}
const newValue = [...(field.value as string[]), value];
field.setValue(newValue);
};
const onComboChange = (options: EuiComboBoxOptionProps[]) => {
field.setValue(options.map(option => option.label));
};
const onSearchComboChange = (value: string) => {
if (value) {
field.clearErrors(VALIDATION_TYPES.ARRAY_ITEM);
}
};
return (
<EuiFormRow
label={field.label}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={rest['data-test-subj']}
describedByIds={rest.idAria ? [rest.idAria] : undefined}
>
<EuiComboBox
noSuggestions
placeholder={i18n.translate('esUi.forms.comboBoxField.placeHolderText', {
defaultMessage: 'Type and then hit "ENTER"',
})}
selectedOptions={(field.value as any[]).map(v => ({ label: v }))}
onCreateOption={onCreateComboOption}
onChange={onComboChange}
onSearchChange={onSearchComboChange}
fullWidth
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};

View file

@ -0,0 +1,26 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './text_field';
export * from './numeric_field';
export * from './checkbox_field';
export * from './combobox_field';
export * from './multi_select_field';
export * from './select_field';
export * from './toggle_field';

View file

@ -0,0 +1,65 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiFormRow, EuiSelectable, EuiPanel } from '@elastic/eui';
import { FieldHook } from '../../hook_form_lib';
import { getFieldValidityAndErrorMessage } from '../helpers';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
export const MultiSelectField = ({ field, euiFieldProps = {}, ...rest }: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
return (
<EuiFormRow
label={field.label}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={rest['data-test-subj']}
describedByIds={rest.idAria ? [rest.idAria] : undefined}
>
<EuiSelectable
allowExclusions={false}
height={300}
onChange={options => {
field.setValue(options);
}}
options={field.value as any[]}
data-test-subj="select"
{...euiFieldProps}
>
{(list, search) => (
<EuiPanel paddingSize="s" hasShadow={false}>
{search}
{list}
</EuiPanel>
)}
</EuiSelectable>
</EuiFormRow>
);
};

View file

@ -0,0 +1,57 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiFormRow, EuiFieldNumber } from '@elastic/eui';
import { FieldHook } from '../../hook_form_lib';
import { getFieldValidityAndErrorMessage } from '../helpers';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
export const NumericField = ({ field, euiFieldProps = {}, ...rest }: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
return (
<EuiFormRow
label={field.label}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={rest['data-test-subj']}
describedByIds={rest.idAria ? [rest.idAria] : undefined}
>
<EuiFieldNumber
isInvalid={isInvalid}
value={field.value as string}
onChange={field.onChange}
isLoading={field.isValidating}
fullWidth
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};

View file

@ -0,0 +1,59 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiFormRow, EuiSelect } from '@elastic/eui';
import { FieldHook } from '../../hook_form_lib';
import { getFieldValidityAndErrorMessage } from '../helpers';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
export const SelectField = ({ field, euiFieldProps = {}, ...rest }: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
return (
<EuiFormRow
label={field.label}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={rest['data-test-subj']}
describedByIds={rest.idAria ? [rest.idAria] : undefined}
>
<EuiSelect
fullWidth
value={field.value as string}
onChange={e => {
field.setValue(e.target.value);
}}
hasNoInitialSelection={true}
isInvalid={isInvalid}
data-test-subj="select"
{...(euiFieldProps as { options: any; [key: string]: any })}
/>
</EuiFormRow>
);
};

View file

@ -0,0 +1,57 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiFormRow, EuiFieldText } from '@elastic/eui';
import { FieldHook } from '../../hook_form_lib';
import { getFieldValidityAndErrorMessage } from '../helpers';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
export const TextField = ({ field, euiFieldProps = {}, ...rest }: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
return (
<EuiFormRow
label={field.label}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={rest['data-test-subj']}
describedByIds={rest.idAria ? [rest.idAria] : undefined}
>
<EuiFieldText
isInvalid={isInvalid}
value={field.value as string}
onChange={field.onChange}
isLoading={field.isValidating}
fullWidth
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};

View file

@ -0,0 +1,54 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiFormRow, EuiSwitch } from '@elastic/eui';
import { FieldHook } from '../../hook_form_lib';
import { getFieldValidityAndErrorMessage } from '../helpers';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
export const ToggleField = ({ field, euiFieldProps = {}, ...rest }: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
return (
<EuiFormRow
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={rest['data-test-subj']}
describedByIds={rest.idAria ? [rest.idAria] : undefined}
>
<EuiSwitch
label={field.label}
checked={field.value as boolean}
onChange={field.onChange}
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};

View file

@ -0,0 +1,65 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiDescribedFormGroup, EuiTitle } from '@elastic/eui';
import { FieldHook } from '../hook_form_lib';
import { Field } from './field';
interface Props {
title: string | JSX.Element;
description?: string | JSX.Element;
field?: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
titleTag?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
children?: React.ReactNode;
[key: string]: any;
}
export const FormRow = ({
title,
idAria,
description,
field,
children,
titleTag = 'h4',
...rest
}: Props) => {
let titleWrapped = title;
// If a string is provided, create a default Euititle of size "m"
const isTitleString = typeof title === 'string' || title.type.name === 'FormattedMessage';
if (isTitleString) {
// Create the correct title tag
const titleWithHTag = React.createElement(titleTag, undefined, title);
titleWrapped = <EuiTitle size="s">{titleWithHTag}</EuiTitle>;
}
if (!children && !field) {
throw new Error('You need to provide either children or a field to the FormRow');
}
return (
<EuiDescribedFormGroup title={titleWrapped} description={description} idAria={idAria} fullWidth>
{children ? children : <Field field={field!} idAria={idAria} {...rest} />}
</EuiDescribedFormGroup>
);
};

View file

@ -0,0 +1,30 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FieldHook } from '../hook_form_lib';
export const getFieldValidityAndErrorMessage = (
field: FieldHook
): { isInvalid: boolean; errorMessage: string | null } => {
const isInvalid = !field.isChangingValue && field.errors.length > 0;
const errorMessage =
!field.isChangingValue && field.errors.length ? field.errors[0].message : null;
return { isInvalid, errorMessage };
};

View file

@ -0,0 +1,22 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './field';
export * from './form_row';
export * from './fields';

View file

@ -0,0 +1,40 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Option } from '@elastic/eui/src/components/selectable/types';
import { SerializerFunc } from '../hook_form_lib';
type FuncType = (selectOptions: Option[]) => SerializerFunc;
export const multiSelectComponent: Record<string, FuncType> = {
// This deSerializer takes the previously selected options and map them
// against the default select options values.
selectedValueToOptions(selectOptions) {
return defaultFormValue => {
// If there are no default form value, it means that no previous value has been selected.
if (!defaultFormValue) {
return selectOptions;
}
return (selectOptions as Option[]).map(option => ({
...option,
checked: (defaultFormValue as string[]).includes(option.label) ? 'on' : undefined,
}));
};
},
};

View file

@ -0,0 +1,28 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* NOTE: The toInt() formatter does _not_ play well if we enter the "e" letter in a "number" input
* as it does not trigger an "onChange" event.
* I searched if it was a bug and found this thread (https://github.com/facebook/react/pull/7359#event-1017024857)
* We will need to investigate this a little further.
*
* @param value The string value to convert to number
*/
export const toInt = (value: string): number => parseFloat(value);

View file

@ -0,0 +1,45 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ValidationFunc, ValidationError } from '../../hook_form_lib';
import { containsChars } from '../../../validators/string';
import { ERROR_CODE } from './types';
export const containsCharsField = ({
message,
chars,
}: {
message: string | ((err: Partial<ValidationError>) => string);
chars: string | string[];
}) => (...args: Parameters<ValidationFunc>): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value }] = args;
if (typeof value !== 'string') {
return;
}
const { doesContain, charsFound } = containsChars(chars)(value as string);
if (doesContain) {
return {
code: 'ERR_INVALID_CHARS',
charsFound,
message: typeof message === 'function' ? message({ charsFound }) : message,
};
}
};

View file

@ -0,0 +1,37 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ValidationFunc } from '../../hook_form_lib';
import { isEmptyString } from '../../../validators/string';
import { isEmptyArray } from '../../../validators/array';
import { ERROR_CODE } from './types';
export const emptyField = (message: string) => (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value, path }] = args;
if (typeof value === 'string') {
return isEmptyString(value) ? { code: 'ERR_FIELD_MISSING', path, message } : undefined;
}
if (Array.isArray(value)) {
return isEmptyArray(value) ? { code: 'ERR_FIELD_MISSING', path, message } : undefined;
}
};

View file

@ -0,0 +1,27 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './empty_field';
export * from './min_length';
export * from './min_selectable_selection';
export * from './url';
export * from './index_name';
export * from './contains_char';
export * from './starts_with';
export * from './index_pattern_field';

View file

@ -0,0 +1,67 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Note: we can't import from "ui/indices" as the TS Type definition don't exist
// import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices';
import { ValidationFunc } from '../../hook_form_lib';
import { startsWith, containsChars } from '../../../validators/string';
import { ERROR_CODE } from './types';
const INDEX_ILLEGAL_CHARACTERS = ['\\', '/', '?', '"', '<', '>', '|', '*'];
export const indexNameField = (i18n: any) => (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value }] = args;
if (startsWith('.')(value as string)) {
return {
code: 'ERR_FIELD_FORMAT',
formatType: 'INDEX_NAME',
message: i18n.translate('esUi.forms.fieldValidation.indexNameStartsWithDotError', {
defaultMessage: 'The index name cannot start with a dot (.).',
}),
};
}
const { doesContain: doesContainSpaces } = containsChars(' ')(value as string);
if (doesContainSpaces) {
return {
code: 'ERR_FIELD_FORMAT',
formatType: 'INDEX_NAME',
message: i18n.translate('esUi.forms.fieldValidation.indexNameSpacesError', {
defaultMessage: 'The index name cannot contain spaces.',
}),
};
}
const { charsFound, doesContain } = containsChars(INDEX_ILLEGAL_CHARACTERS)(value as string);
if (doesContain) {
return {
message: i18n.translate('esUi.forms.fieldValidation.indexNameInvalidCharactersError', {
defaultMessage:
'The index name contains the invalid {characterListLength, plural, one {character} other {characters}} { characterList }.',
values: {
characterList: charsFound.join(' '),
characterListLength: charsFound.length,
},
}),
};
}
};

View file

@ -0,0 +1,64 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ILLEGAL_CHARACTERS, validateIndexPattern } from 'ui/index_patterns';
import { ValidationFunc } from '../../hook_form_lib';
import { containsChars } from '../../../validators/string';
import { ERROR_CODE } from './types';
export const indexPatternField = (i18n: any) => (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value }] = args;
if (typeof value !== 'string') {
return;
}
// Validate it does not contain spaces
const { doesContain } = containsChars(' ')(value);
if (doesContain) {
return {
code: 'ERR_FIELD_FORMAT',
formatType: 'INDEX_PATTERN',
message: i18n.translate('esUi.forms.fieldValidation.indexPatternSpacesError', {
defaultMessage: 'The index pattern cannot contain spaces.',
}),
};
}
// Validate illegal characters
const errors = validateIndexPattern(value);
if (errors[ILLEGAL_CHARACTERS]) {
return {
code: 'ERR_FIELD_FORMAT',
formatType: 'INDEX_PATTERN',
message: i18n.translate('esUi.forms.fieldValidation.indexPatternInvalidCharactersError', {
defaultMessage:
'The index pattern contains the invalid {characterListLength, plural, one {character} other {characters}} { characterList }.',
values: {
characterList: errors[ILLEGAL_CHARACTERS].join(' '),
characterListLength: errors[ILLEGAL_CHARACTERS].length,
},
}),
};
}
};

View file

@ -0,0 +1,53 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ValidationFunc, ValidationError } from '../../hook_form_lib';
import { hasMinLengthString } from '../../../validators/string';
import { hasMinLengthArray } from '../../../validators/array';
import { ERROR_CODE } from './types';
export const minLengthField = ({
length = 0,
message,
}: {
length: number;
message: string | ((err: Partial<ValidationError>) => string);
}) => (...args: Parameters<ValidationFunc>): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value }] = args;
// Validate for Arrays
if (Array.isArray(value)) {
return hasMinLengthArray(length)(value)
? undefined
: {
code: 'ERR_MIN_LENGTH',
length,
message: typeof message === 'function' ? message({ length }) : message,
};
}
// Validate for Strings
return hasMinLengthString(length)((value as string).trim())
? undefined
: {
code: 'ERR_MIN_LENGTH',
length,
message: typeof message === 'function' ? message({ length }) : message,
};
};

View file

@ -0,0 +1,52 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Option } from '@elastic/eui/src/components/selectable/types';
import { ValidationFunc, ValidationError } from '../../hook_form_lib';
import { hasMinLengthArray } from '../../../validators/array';
import { multiSelectComponent } from '../serializers';
import { ERROR_CODE } from './types';
const { optionsToSelectedValue } = multiSelectComponent;
/**
* Validator to validate that a EuiSelectable has a minimum number
* of items selected.
* @param total Minimum number of items
*/
export const minSelectableSelectionField = ({
total = 0,
message,
}: {
total: number;
message: string | ((err: Partial<ValidationError>) => string);
}) => (...args: Parameters<ValidationFunc>): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value }] = args;
// We need to convert all the options from the multi selectable component, to the
// an actual Array of selection _before_ validating the Array length.
return hasMinLengthArray(total)(optionsToSelectedValue(value as Option[]))
? undefined
: {
code: 'ERR_MIN_SELECTION',
total,
message: typeof message === 'function' ? message({ length }) : message,
};
};

View file

@ -0,0 +1,40 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ValidationFunc } from '../../hook_form_lib';
import { startsWith } from '../../../validators/string';
import { ERROR_CODE } from './types';
export const startsWithField = ({ message, char }: { message: string; char: string }) => (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value }] = args;
if (typeof value !== 'string') {
return;
}
if (startsWith(char)(value)) {
return {
code: 'ERR_FIRST_CHAR',
char,
message,
};
}
};

View file

@ -0,0 +1,28 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export type ERROR_CODE =
| 'ERR_FIELD_MISSING'
| 'ERR_FIELD_FORMAT'
| 'ERR_INVALID_CHARS'
| 'ERR_FIRST_CHAR'
| 'ERR_MIN_LENGTH'
| 'ERR_MAX_LENGTH'
| 'ERR_MIN_SELECTION'
| 'ERR_MAX_SELECTION';

View file

@ -0,0 +1,40 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ValidationFunc, ValidationError } from '../../hook_form_lib';
import { isUrl } from '../../../validators/string';
import { ERROR_CODE } from './types';
export const urlField = (message: string) => (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value }] = args;
const error: ValidationError<ERROR_CODE> = {
code: 'ERR_FIELD_FORMAT',
formatType: 'URL',
message,
};
if (typeof value !== 'string') {
return error;
}
return isUrl(value) ? undefined : error;
};

View file

@ -0,0 +1,28 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as fieldValidatorsImport from './field_validators';
import * as fieldFormattersImport from './field_formatters';
import * as serializersImport from './serializers';
import * as deserializersImport from './de_serializers';
export const fieldValidators = fieldValidatorsImport;
export const fieldFormatters = fieldFormattersImport;
export const deserializers = deserializersImport;
export const serializers = serializersImport;

View file

@ -0,0 +1,107 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { stripEmptyFields } from './serializers';
describe('Serializers', () => {
describe('stripEmptyFields()', () => {
let object: { [key: string]: any };
beforeEach(() => {
object = {
a: '',
b: ' ',
c: '0',
d: 0,
e: false,
f: null,
g: [],
h: {},
i: {
a: '',
b: {},
c: null,
d: 123,
},
};
});
test('should not remove empty string or empty object in child objects (not recursively = default)', () => {
const expected = {
c: object.c,
d: object.d,
e: object.e,
f: object.f,
g: object.g,
i: object.i, // not mutaded
};
expect(stripEmptyFields(object)).toEqual(expected);
});
test('should remove all empty string and empty object (recursively)', () => {
const expected = {
c: object.c,
d: object.d,
e: object.e,
f: object.f,
g: object.g,
i: {
c: object.i.c,
d: object.i.d,
},
};
expect(stripEmptyFields(object, { recursive: true })).toEqual(expected);
});
test('should only remove empty string (recursively)', () => {
const expected = {
a: object.a,
b: object.b,
c: object.c,
d: object.d,
e: object.e,
f: object.f,
g: object.g,
i: {
a: object.i.a,
c: object.i.c,
d: object.i.d,
},
};
expect(stripEmptyFields(object, { recursive: true, types: ['object'] })).toEqual(expected);
});
test('should only remove empty objects (recursively)', () => {
const expected = {
c: object.c,
d: object.d,
e: object.e,
f: object.f,
g: object.g,
h: object.h,
i: {
b: object.i.b,
c: object.i.c,
d: object.i.d,
},
};
expect(stripEmptyFields(object, { recursive: true, types: ['string'] })).toEqual(expected);
});
});
});

View file

@ -0,0 +1,92 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Output transforms are functions that will be called
* with the form field value whenever we access the form data object. (with `form.getFormData()`)
*
* This allows us to have a different object/array as field `value`
* from the desired outputed form data.
*
* Example:
* ```ts
* myField.value = [{ label: 'index_1', isSelected: true }, { label: 'index_2', isSelected: false }]
* const serializer = (value) => (
* value.filter(v => v.selected).map(v => v.label)
* );
*
* // When serializing the form data, the following array will be returned
* form.getFormData() -> { myField: ['index_1'] }
* ````
*/
import { Option } from '@elastic/eui/src/components/selectable/types';
import { SerializerFunc } from '../hook_form_lib';
export const multiSelectComponent: Record<string, SerializerFunc<string[]>> = {
/**
* Return an array of labels of all the options that are selected
*
* @param value The Eui Selectable options array
*/
optionsToSelectedValue(options: Option[]): string[] {
return options.filter(option => option.checked === 'on').map(option => option.label);
},
};
interface StripEmptyFieldsOptions {
types?: Array<'string' | 'object'>;
recursive?: boolean;
}
/**
* Strip empty fields from a data object.
* Empty fields can either be an empty string (one or several blank spaces) or an empty object (no keys)
*
* @param object Object to remove the empty fields from.
* @param types An array of types to strip. Types can be "string" or "object". Defaults to ["string", "object"]
* @param options An optional configuration object. By default recursive it turned on.
*/
export const stripEmptyFields = (
object: { [key: string]: any },
options?: StripEmptyFieldsOptions
): { [key: string]: any } => {
const { types = ['string', 'object'], recursive = false } = options || {};
return Object.entries(object).reduce(
(acc, [key, value]) => {
const type = typeof value;
const shouldStrip = types.includes(type as 'string');
if (shouldStrip && type === 'string' && value.trim() === '') {
return acc;
} else if (type === 'object' && !Array.isArray(value) && value !== null) {
if (Object.keys(value).length === 0 && shouldStrip) {
return acc;
} else if (recursive) {
value = stripEmptyFields({ ...value }, options);
}
}
acc[key] = value;
return acc;
},
{} as { [key: string]: any }
);
};

View file

@ -25,16 +25,12 @@ import { FormHook } from '../types';
interface Props {
form: FormHook<any>;
FormWrapper?: (props: any) => JSX.Element;
FormWrapper?: React.ComponentType;
children: ReactNode | ReactNode[];
className: string;
[key: string]: any;
}
const DefaultFormWrapper = (props: any) => {
return <EuiForm {...props} />;
};
export const Form = ({ form, FormWrapper = DefaultFormWrapper, ...rest }: Props) => (
export const Form = ({ form, FormWrapper = EuiForm, ...rest }: Props) => (
<FormProvider form={form}>
<FormWrapper {...rest} />
</FormProvider>

View file

@ -32,7 +32,7 @@ export const FormProvider = ({ children, form }: Props) => (
<FormContext.Provider value={form}>{children}</FormContext.Provider>
);
export const useFormContext = function<T = Record<string, unknown>>() {
export const useFormContext = function<T extends object = Record<string, unknown>>() {
const context = useContext(FormContext) as FormHook<T>;
if (context === undefined) {
throw new Error('useFormContext must be used within a <FormProvider />');

View file

@ -24,12 +24,16 @@ import { FormHook, FormData, FieldConfig, FieldsMap, FormConfig } from '../types
import { mapFormFields, flattenObject, unflattenObject, Subject } from '../lib';
const DEFAULT_ERROR_DISPLAY_TIMEOUT = 500;
const DEFAULT_OPTIONS = {
errorDisplayDelay: DEFAULT_ERROR_DISPLAY_TIMEOUT,
stripEmptyFields: true,
};
interface UseFormReturn<T> {
interface UseFormReturn<T extends object> {
form: FormHook<T>;
}
export function useForm<T = FormData>(
export function useForm<T extends object = FormData>(
formConfig: FormConfig<T> | undefined = {}
): UseFormReturn<T> {
const {
@ -38,8 +42,9 @@ export function useForm<T = FormData>(
defaultValue = {},
serializer = (data: any) => data,
deserializer = (data: any) => data,
options = { errorDisplayDelay: DEFAULT_ERROR_DISPLAY_TIMEOUT, stripEmptyFields: true },
options = {},
} = formConfig;
const formOptions = { ...DEFAULT_OPTIONS, ...options };
const defaultValueDeserialized =
Object.keys(defaultValue).length === 0 ? defaultValue : deserializer(defaultValue);
const [isSubmitted, setSubmitted] = useState(false);
@ -59,7 +64,7 @@ export function useForm<T = FormData>(
const fieldsToArray = () => Object.values(fieldsRefs.current);
const stripEmptyFields = (fields: FieldsMap): FieldsMap => {
if (options.stripEmptyFields) {
if (formOptions.stripEmptyFields) {
return Object.entries(fields).reduce(
(acc, [key, field]) => {
if (typeof field.value !== 'string' || field.value.trim() !== '') {
@ -191,7 +196,7 @@ export function useForm<T = FormData>(
getFields,
getFormData,
getFieldDefaultValue,
__options: options,
__options: formOptions,
__formData$: formData$,
__updateFormDataAt: updateFormDataAt,
__readFieldConfigFromSchema: readFieldConfigFromSchema,

View file

@ -17,10 +17,14 @@
* under the License.
*/
import { ChangeEvent, FormEvent, MouseEvent, MutableRefObject } from 'react';
import { ReactNode, ChangeEvent, FormEvent, MouseEvent, MutableRefObject } from 'react';
import { Subject } from './lib';
export interface FormHook<T = FormData> {
// This type will convert all optional property to required ones
// Comes from https://github.com/microsoft/TypeScript/issues/15012#issuecomment-365453623
type Required<T> = T extends object ? { [P in keyof T]-?: NonNullable<T[P]> } : T;
export interface FormHook<T extends object = FormData> {
readonly isSubmitted: boolean;
readonly isSubmitting: boolean;
readonly isValid: boolean;
@ -30,7 +34,7 @@ export interface FormHook<T = FormData> {
getFields: () => FieldsMap;
getFormData: (options?: { unflatten?: boolean }) => T;
getFieldDefaultValue: (fieldName: string) => unknown;
readonly __options: FormOptions;
readonly __options: Required<FormOptions>;
readonly __formData$: MutableRefObject<Subject<T>>;
__addField: (field: FieldHook) => void;
__removeField: (fieldNames: string | string[]) => void;
@ -39,15 +43,15 @@ export interface FormHook<T = FormData> {
__readFieldConfigFromSchema: (fieldName: string) => FieldConfig;
}
export interface FormSchema<T = FormData> {
export interface FormSchema<T extends object = FormData> {
[key: string]: FormSchemaEntry<T>;
}
type FormSchemaEntry<T> =
type FormSchemaEntry<T extends object> =
| FieldConfig<T>
| Array<FieldConfig<T>>
| { [key: string]: FieldConfig<T> | Array<FieldConfig<T>> | FormSchemaEntry<T> };
export interface FormConfig<T = FormData> {
export interface FormConfig<T extends object = FormData> {
onSubmit?: (data: T, isFormValid: boolean) => void;
schema?: FormSchema<T>;
defaultValue?: Partial<T>;
@ -57,17 +61,17 @@ export interface FormConfig<T = FormData> {
}
export interface FormOptions {
errorDisplayDelay: number;
errorDisplayDelay?: number;
/**
* Remove empty string field ("") from form data
*/
stripEmptyFields: boolean;
stripEmptyFields?: boolean;
}
export interface FieldHook {
readonly path: string;
readonly label?: string;
readonly helpText?: string;
readonly helpText?: string | ReactNode;
readonly type: string;
readonly value: unknown;
readonly errors: ValidationError[];
@ -91,10 +95,10 @@ export interface FieldHook {
__serializeOutput: (rawValue?: unknown) => unknown;
}
export interface FieldConfig<T = any> {
export interface FieldConfig<T extends object = any> {
readonly path?: string;
readonly label?: string;
readonly helpText?: string;
readonly helpText?: string | ReactNode;
readonly type?: HTMLInputElement['type'];
readonly defaultValue?: unknown;
readonly validations?: Array<ValidationConfig<T>>;
@ -111,20 +115,20 @@ export interface FieldsMap {
export type FormSubmitHandler<T> = (formData: T, isValid: boolean) => Promise<void>;
export interface ValidationError {
message: string | ((error: ValidationError) => string);
code?: string;
export interface ValidationError<T = string> {
message: string;
code?: T;
validationType?: string;
[key: string]: any;
}
export type ValidationFunc<T = any> = (data: {
export type ValidationFunc<T extends object = any, E = string> = (data: {
path: string;
value: unknown;
form: FormHook<T>;
formData: T;
errors: readonly ValidationError[];
}) => ValidationError | void | undefined | Promise<ValidationError | void | undefined>;
}) => ValidationError<E> | void | undefined | Promise<ValidationError<E> | void | undefined>;
export interface FieldValidateResponse {
isValid: boolean;
@ -133,7 +137,9 @@ export interface FieldValidateResponse {
export type SerializerFunc<T = unknown> = (value: any) => T;
export type FormData = Record<string, unknown>;
export interface FormData {
[key: string]: any;
}
type FormatterFunc = (value: any) => unknown;
@ -141,7 +147,7 @@ type FormatterFunc = (value: any) => unknown;
// string | number | boolean | string[] ...
type FieldValue = unknown;
export interface ValidationConfig<T = any> {
export interface ValidationConfig<T extends object = any> {
validator: ValidationFunc<T>;
type?: string;
exitOnFail?: boolean;

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const hasMaxLengthArray = (length = 5) => (value: any[]): boolean => value.length <= length;

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const hasMinLengthArray = (length = 1) => (value: any[]): boolean => value.length >= length;

View file

@ -0,0 +1,22 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './has_max_length';
export * from './has_min_length';
export * from './is_empty';

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const isEmptyArray = (value: any[]): boolean => value.length === 0;

View file

@ -0,0 +1,32 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const containsChars = (chars: string | string[]) => (value: string) => {
const charToArray = Array.isArray(chars) ? (chars as string[]) : ([chars] as string[]);
const charsFound = charToArray.reduce(
(acc, char) => (value.includes(char) ? [...acc, char] : acc),
[] as string[]
);
return {
charsFound,
doesContain: charsFound.length > 0,
};
};

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const endsWith = (char: string) => (value: string) => value.endsWith(char);

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const hasMaxLengthString = (length: number) => (str: string) => str.length <= length;

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const hasMinLengthString = (length: number) => (str: string) => str.length >= length;

View file

@ -0,0 +1,27 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './contains_chars';
export * from './ends_with';
export * from './has_max_length';
export * from './has_min_length';
export * from './is_empty';
export * from './is_url';
export * from './starts_with';
export * from './is_json';

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const isEmptyString = (value: string) => value.trim() === '';

View file

@ -0,0 +1,30 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const isJSON = (value: string) => {
try {
const parsedJSON = JSON.parse(value);
if (parsedJSON && typeof parsedJSON !== 'object') {
return false;
}
return true;
} catch (e) {
return false;
}
};

View file

@ -0,0 +1,47 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const protocolAndDomainRE = /^(?:\w+:)?\/\/(\S+)$/;
const localhostDomainRE = /^localhost[\:?\d]*(?:[^\:?\d]\S*)?$/;
const nonLocalhostDomainRE = /^[^\s\.]+\.\S{2,}$/;
export const isUrl = (string: string) => {
if (typeof string !== 'string') {
return false;
}
const match = string.match(protocolAndDomainRE);
if (!match) {
return false;
}
const everythingAfterProtocol = match[1];
if (!everythingAfterProtocol) {
return false;
}
if (
localhostDomainRE.test(everythingAfterProtocol) ||
nonLocalhostDomainRE.test(everythingAfterProtocol)
) {
return true;
}
return false;
};

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const startsWith = (char: string) => (value: string) => value.startsWith(char);

View file

@ -22,6 +22,10 @@ import { Direction } from '@elastic/eui/src/services/sort/sort_direction';
declare module '@elastic/eui' {
export const EuiSideNav: React.SFC<any>;
export const EuiDescribedFormGroup: React.SFC<any>;
export const EuiCodeEditor: React.SFC<any>;
export const Query: any;
export const EuiCard: any;
export interface EuiTableCriteria {
page: { index: number; size: number };

20
typings/@elastic/eui/lib/format.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const dateFormatAliases: any;

20
typings/@elastic/eui/lib/services.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const RIGHT_ALIGNMENT: any;

View file

@ -86,7 +86,7 @@ export const init = () => {
// Define default response for unhandled requests.
// We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
// and we can mock them all with a 200 instead of mocking each one individually.
server.respondWith([200, {}, 'DefaultResponse']);
server.respondWith([200, {}, 'DefaultSinonMockServerResponse']);
const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);

View file

@ -7,7 +7,7 @@
import { registerTestBed, TestBedConfig } from '../../../../../../test_utils';
import { BASE_PATH } from '../../../common/constants';
import { TemplateCreate } from '../../../public/sections/template_create';
import { formSetup } from './template_form.helpers';
import { formSetup, TestSubjects } from './template_form.helpers';
const testBedConfig: TestBedConfig = {
memoryRouter: {
@ -17,6 +17,6 @@ const testBedConfig: TestBedConfig = {
doMountAsync: true,
};
const initTestBed = registerTestBed(TemplateCreate, testBedConfig);
const initTestBed = registerTestBed<TestSubjects>(TemplateCreate, testBedConfig);
export const setup = formSetup.bind(null, initTestBed);

View file

@ -7,7 +7,7 @@
import { registerTestBed, TestBedConfig } from '../../../../../../test_utils';
import { BASE_PATH } from '../../../common/constants';
import { TemplateEdit } from '../../../public/sections/template_edit';
import { formSetup } from './template_form.helpers';
import { formSetup, TestSubjects } from './template_form.helpers';
import { TEMPLATE_NAME } from './constants';
const testBedConfig: TestBedConfig = {
@ -18,6 +18,6 @@ const testBedConfig: TestBedConfig = {
doMountAsync: true,
};
const initTestBed = registerTestBed(TemplateEdit, testBedConfig);
const initTestBed = registerTestBed<TestSubjects>(TemplateEdit, testBedConfig);
export const setup = formSetup.bind(null, initTestBed);

View file

@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { TestBed } from '../../../../../../test_utils';
import { TestBed, SetupFunc } from '../../../../../../test_utils';
import { Template } from '../../../common/types';
import { nextTick } from './index';
export interface TemplateFormTestBed extends TestBed<TemplateFormTestSubjects> {
actions: {
@ -13,14 +14,16 @@ export interface TemplateFormTestBed extends TestBed<TemplateFormTestSubjects> {
clickBackButton: () => void;
clickSubmitButton: () => void;
completeStepOne: ({ name, indexPatterns, order, version }: Partial<Template>) => void;
completeStepTwo: ({ settings }: Partial<Template>) => void;
completeStepThree: ({ mappings }: Partial<Template>) => void;
completeStepFour: ({ aliases }: Partial<Template>) => void;
completeStepTwo: (settings: string) => void;
completeStepThree: (mappings: string) => void;
completeStepFour: (aliases: string) => void;
selectSummaryTab: (tab: 'summary' | 'request') => void;
};
}
export const formSetup = async (initTestBed: any): Promise<TemplateFormTestBed> => {
export const formSetup = async (
initTestBed: SetupFunc<TestSubjects>
): Promise<TemplateFormTestBed> => {
const testBed = await initTestBed();
// User actions
@ -36,11 +39,11 @@ export const formSetup = async (initTestBed: any): Promise<TemplateFormTestBed>
testBed.find('submitButton').simulate('click');
};
const completeStepOne = ({ name, indexPatterns, order, version }: Partial<Template>) => {
const { form, find } = testBed;
const completeStepOne = async ({ name, indexPatterns, order, version }: Partial<Template>) => {
const { form, find, component } = testBed;
if (name) {
form.setInputValue('nameInput', name);
form.setInputValue('nameField.input', name);
}
if (indexPatterns) {
@ -50,53 +53,68 @@ export const formSetup = async (initTestBed: any): Promise<TemplateFormTestBed>
}));
find('mockComboBox').simulate('change', indexPatternsFormatted); // Using mocked EuiComboBox
await nextTick();
}
if (order) {
form.setInputValue('orderInput', JSON.stringify(order));
form.setInputValue('orderField.input', JSON.stringify(order));
}
if (version) {
form.setInputValue('versionInput', JSON.stringify(version));
form.setInputValue('versionField.input', JSON.stringify(version));
}
clickNextButton();
await nextTick();
component.update();
};
const completeStepTwo = ({ settings }: Partial<Template>) => {
const { find } = testBed;
const completeStepTwo = async (settings: string) => {
const { find, component } = testBed;
if (settings) {
find('mockCodeEditor').simulate('change', {
jsonString: settings,
}); // Using mocked EuiCodeEditor
await nextTick();
component.update();
}
clickNextButton();
await nextTick();
component.update();
};
const completeStepThree = ({ mappings }: Partial<Template>) => {
const { find } = testBed;
const completeStepThree = async (mappings: string) => {
const { find, component } = testBed;
if (mappings) {
find('mockCodeEditor').simulate('change', {
jsonString: mappings,
}); // Using mocked EuiCodeEditor
await nextTick(50);
component.update();
}
clickNextButton();
await nextTick(50); // hooks updates cycles are tricky, adding some latency is needed
component.update();
};
const completeStepFour = ({ aliases }: Partial<Template>) => {
const { find } = testBed;
const completeStepFour = async (aliases: string) => {
const { find, component } = testBed;
if (aliases) {
find('mockCodeEditor').simulate('change', {
jsonString: aliases,
}); // Using mocked EuiCodeEditor
await nextTick(50);
component.update();
}
clickNextButton();
await nextTick(50);
component.update();
};
const selectSummaryTab = (tab: 'summary' | 'request') => {
@ -126,17 +144,19 @@ export const formSetup = async (initTestBed: any): Promise<TemplateFormTestBed>
export type TemplateFormTestSubjects = TestSubjects;
type TestSubjects =
export type TestSubjects =
| 'backButton'
| 'codeEditorContainer'
| 'indexPatternsComboBox'
| 'indexPatternsField'
| 'indexPatternsWarning'
| 'indexPatternsWarningDescription'
| 'mockCodeEditor'
| 'mockComboBox'
| 'nameInput'
| 'nameField'
| 'nameField.input'
| 'nextButton'
| 'orderInput'
| 'orderField'
| 'orderField.input'
| 'pageTitle'
| 'requestTab'
| 'saveTemplateError'
@ -153,4 +173,5 @@ type TestSubjects =
| 'templateForm'
| 'templateFormContainer'
| 'testingEditor'
| 'versionInput';
| 'versionField'
| 'versionField.input';

View file

@ -130,14 +130,14 @@ describe.skip('<IndexManagementHome />', () => {
const template1 = fixtures.getTemplate({
name: `a${getRandomString()}`,
indexPatterns: ['template1Pattern1*', 'template1Pattern2'],
settings: JSON.stringify({
settings: {
index: {
number_of_shards: '1',
lifecycle: {
name: 'my_ilm_policy',
},
},
}),
},
});
const template2 = fixtures.getTemplate({
name: `b${getRandomString()}`,
@ -415,12 +415,12 @@ describe.skip('<IndexManagementHome />', () => {
const template = fixtures.getTemplate({
name: `a${getRandomString()}`,
indexPatterns: ['template1Pattern1*', 'template1Pattern2'],
settings: JSON.stringify({
settings: {
index: {
number_of_shards: '1',
},
}),
mappings: JSON.stringify({
},
mappings: {
_source: {
enabled: false,
},
@ -430,10 +430,10 @@ describe.skip('<IndexManagementHome />', () => {
format: 'EEE MMM dd HH:mm:ss Z yyyy',
},
},
}),
aliases: JSON.stringify({
},
aliases: {
alias1: {},
}),
},
});
const { find, actions, exists } = testBed;

View file

@ -77,10 +77,10 @@ describe.skip('<TemplateClone />', () => {
});
beforeEach(async () => {
testBed = await setup();
httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone);
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
@ -97,22 +97,31 @@ describe.skip('<TemplateClone />', () => {
describe('form payload', () => {
beforeEach(async () => {
const { actions } = testBed;
const { actions, component } = testBed;
// Complete step 1 (logistics)
// Specify index patterns, but do not change name (keep default)
actions.completeStepOne({
indexPatterns: DEFAULT_INDEX_PATTERNS,
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 1 (logistics)
// Specify index patterns, but do not change name (keep default)
await actions.completeStepOne({
indexPatterns: DEFAULT_INDEX_PATTERNS,
});
// Bypass step 2 (index settings)
actions.clickNextButton();
await nextTick();
component.update();
// Bypass step 3 (mappings)
actions.clickNextButton();
await nextTick();
component.update();
// Bypass step 4 (aliases)
actions.clickNextButton();
await nextTick();
component.update();
});
// Bypass step 2 (index settings)
actions.clickNextButton();
// Bypass step 3 (mappings)
actions.clickNextButton();
// Bypass step 4 (aliases)
actions.clickNextButton();
});
it('should send the correct payload', async () => {
@ -126,13 +135,16 @@ describe.skip('<TemplateClone />', () => {
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
...templateToClone,
name: `${templateToClone.name}-copy`,
indexPatterns: DEFAULT_INDEX_PATTERNS,
})
);
const body = JSON.parse(latestRequest.requestBody);
const expected = {
...templateToClone,
name: `${templateToClone.name}-copy`,
indexPatterns: DEFAULT_INDEX_PATTERNS,
aliases: {},
mappings: {},
settings: {},
};
expect(body).toEqual(expected);
});
});
});

View file

@ -58,7 +58,7 @@ jest.mock('@elastic/eui', () => ({
EuiCodeEditor: (props: any) => (
<input
data-test-subj="mockCodeEditor"
onChange={async (syntheticEvent: any) => {
onChange={(syntheticEvent: any) => {
props.onChange(syntheticEvent.jsonString);
}}
/>
@ -88,14 +88,19 @@ describe.skip('<TemplateCreate />', () => {
expect(find('pageTitle').text()).toEqual('Create template');
});
test('should not let the user go to the next step with invalid fields', () => {
const { find, form } = testBed;
test('should not let the user go to the next step with invalid fields', async () => {
const { find, actions, component } = testBed;
form.setInputValue('nameInput', '');
find('mockComboBox').simulate('change', [{ value: '' }]);
expect(find('nextButton').props().disabled).toEqual(false);
const nextButton = find('nextButton');
expect(nextButton.props().disabled).toEqual(true);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickNextButton();
await nextTick();
component.update();
});
expect(find('nextButton').props().disabled).toEqual(true);
});
describe('form validation', () => {
@ -104,21 +109,29 @@ describe.skip('<TemplateCreate />', () => {
});
describe('index settings (step 2)', () => {
beforeEach(() => {
beforeEach(async () => {
const { actions } = testBed;
// Complete step 1 (logistics)
actions.completeStepOne({ name: TEMPLATE_NAME, indexPatterns: ['index1'] });
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 1 (logistics)
await actions.completeStepOne({ name: TEMPLATE_NAME, indexPatterns: ['index1'] });
});
});
it('should set the correct page title', async () => {
const { exists, find } = testBed;
expect(exists('stepSettings')).toBe(true);
expect(find('stepTitle').text()).toEqual('Index settings (optional)');
});
it('should not allow invalid json', async () => {
const { form, actions, exists, find } = testBed;
const { form, actions } = testBed;
// Complete step 2 (index settings) with invalid json
expect(exists('stepSettings')).toBe(true);
expect(find('stepTitle').text()).toEqual('Index settings (optional)');
actions.completeStepTwo({
settings: '{ invalidJsonString ',
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.completeStepTwo('{ invalidJsonString ');
});
expect(form.getErrorsMessages()).toContain('Invalid JSON format.');
@ -126,26 +139,33 @@ describe.skip('<TemplateCreate />', () => {
});
describe('mappings (step 3)', () => {
beforeEach(() => {
beforeEach(async () => {
const { actions } = testBed;
// Complete step 1 (logistics)
actions.completeStepOne({ name: TEMPLATE_NAME, indexPatterns: ['index1'] });
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 1 (logistics)
await actions.completeStepOne({ name: TEMPLATE_NAME, indexPatterns: ['index1'] });
// Complete step 2 (index settings)
actions.completeStepTwo({
settings: '{}',
// Complete step 2 (index settings)
await actions.completeStepTwo('{}');
});
});
it('should not allow invalid json', async () => {
const { actions, form, exists, find } = testBed;
it('should set the correct page title', async () => {
const { exists, find } = testBed;
// Complete step 3 (mappings) with invalid json
expect(exists('stepMappings')).toBe(true);
expect(find('stepTitle').text()).toEqual('Mappings (optional)');
actions.completeStepThree({
mappings: '{ invalidJsonString ',
});
it('should not allow invalid json', async () => {
const { actions, form } = testBed;
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 3 (mappings) with invalid json
await actions.completeStepThree('{ invalidJsonString ');
});
expect(form.getErrorsMessages()).toContain('Invalid JSON format.');
@ -153,31 +173,36 @@ describe.skip('<TemplateCreate />', () => {
});
describe('aliases (step 4)', () => {
beforeEach(() => {
beforeEach(async () => {
const { actions } = testBed;
// Complete step 1 (logistics)
actions.completeStepOne({ name: TEMPLATE_NAME, indexPatterns: ['index1'] });
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 1 (logistics)
await actions.completeStepOne({ name: TEMPLATE_NAME, indexPatterns: ['index1'] });
// Complete step 2 (index settings)
actions.completeStepTwo({
settings: '{}',
});
// Complete step 2 (index settings)
await actions.completeStepTwo('{}');
// Complete step 3 (mappings)
actions.completeStepThree({
mappings: '{}',
// Complete step 3 (mappings)
await actions.completeStepThree('{}');
});
});
it('should not allow invalid json', async () => {
const { actions, form, exists, find } = testBed;
it('should set the correct page title', async () => {
const { exists, find } = testBed;
// Complete step 4 (aliases) with invalid json
expect(exists('stepAliases')).toBe(true);
expect(find('stepTitle').text()).toEqual('Aliases (optional)');
actions.completeStepFour({
aliases: '{ invalidJsonString ',
});
it('should not allow invalid json', async () => {
const { actions, form } = testBed;
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 4 (aliases) with invalid json
await actions.completeStepFour('{ invalidJsonString ');
});
expect(form.getErrorsMessages()).toContain('Invalid JSON format.');
@ -191,25 +216,22 @@ describe.skip('<TemplateCreate />', () => {
const { actions } = testBed;
// Complete step 1 (logistics)
actions.completeStepOne({
name: TEMPLATE_NAME,
indexPatterns: DEFAULT_INDEX_PATTERNS,
});
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 1 (logistics)
await actions.completeStepOne({
name: TEMPLATE_NAME,
indexPatterns: DEFAULT_INDEX_PATTERNS,
});
// Complete step 2 (index settings)
actions.completeStepTwo({
settings: JSON.stringify(SETTINGS),
});
// Complete step 2 (index settings)
await actions.completeStepTwo(JSON.stringify(SETTINGS));
// Complete step 3 (mappings)
actions.completeStepThree({
mappings: JSON.stringify(MAPPINGS),
});
// Complete step 3 (mappings)
await actions.completeStepThree(JSON.stringify(MAPPINGS));
// Complete step 4 (aliases)
actions.completeStepFour({
aliases: JSON.stringify(ALIASES),
// Complete step 4 (aliases)
await actions.completeStepFour(JSON.stringify(ALIASES));
});
});
@ -249,25 +271,22 @@ describe.skip('<TemplateCreate />', () => {
const { actions, exists, find } = testBed;
// Complete step 1 (logistics)
actions.completeStepOne({
name: TEMPLATE_NAME,
indexPatterns: ['*'], // Set wildcard index pattern
});
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 1 (logistics)
await actions.completeStepOne({
name: TEMPLATE_NAME,
indexPatterns: ['*'], // Set wildcard index pattern
});
// Complete step 2 (index settings)
actions.completeStepTwo({
settings: JSON.stringify({}),
});
// Complete step 2 (index settings)
await actions.completeStepTwo(JSON.stringify({}));
// Complete step 3 (mappings)
actions.completeStepThree({
mappings: JSON.stringify({}),
});
// Complete step 3 (mappings)
await actions.completeStepThree(JSON.stringify({}));
// Complete step 4 (aliases)
actions.completeStepFour({
aliases: JSON.stringify({}),
// Complete step 4 (aliases)
await actions.completeStepFour(JSON.stringify({}));
});
expect(exists('indexPatternsWarning')).toBe(true);
@ -283,25 +302,22 @@ describe.skip('<TemplateCreate />', () => {
const { actions } = testBed;
// Complete step 1 (logistics)
actions.completeStepOne({
name: TEMPLATE_NAME,
indexPatterns: DEFAULT_INDEX_PATTERNS,
});
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 1 (logistics)
await actions.completeStepOne({
name: TEMPLATE_NAME,
indexPatterns: DEFAULT_INDEX_PATTERNS,
});
// Complete step 2 (index settings)
actions.completeStepTwo({
settings: JSON.stringify(SETTINGS),
});
// Complete step 2 (index settings)
await actions.completeStepTwo(JSON.stringify(SETTINGS));
// Complete step 3 (mappings)
actions.completeStepThree({
mappings: JSON.stringify(MAPPINGS),
});
// Complete step 3 (mappings)
await actions.completeStepThree(JSON.stringify(MAPPINGS));
// Complete step 4 (aliases)
actions.completeStepFour({
aliases: JSON.stringify(ALIASES),
// Complete step 4 (aliases)
await actions.completeStepFour(JSON.stringify(ALIASES));
});
});
@ -316,18 +332,15 @@ describe.skip('<TemplateCreate />', () => {
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
name: TEMPLATE_NAME,
indexPatterns: DEFAULT_INDEX_PATTERNS,
version: '',
order: '',
settings: JSON.stringify(SETTINGS),
mappings: JSON.stringify(MAPPINGS),
aliases: JSON.stringify(ALIASES),
isManaged: false,
})
);
const expected = {
name: TEMPLATE_NAME,
indexPatterns: DEFAULT_INDEX_PATTERNS,
settings: SETTINGS,
mappings: MAPPINGS,
aliases: ALIASES,
isManaged: false,
};
expect(JSON.parse(latestRequest.requestBody)).toEqual(expected);
});
it('should surface the API errors from the put HTTP request', async () => {

View file

@ -55,7 +55,7 @@ jest.mock('@elastic/eui', () => ({
EuiCodeEditor: (props: any) => (
<input
data-test-subj="mockCodeEditor"
onChange={async (syntheticEvent: any) => {
onChange={(syntheticEvent: any) => {
props.onChange(syntheticEvent.jsonString);
}}
/>
@ -79,10 +79,10 @@ describe.skip('<TemplateEdit />', () => {
});
beforeEach(async () => {
testBed = await setup();
httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit);
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
@ -98,31 +98,32 @@ describe.skip('<TemplateEdit />', () => {
expect(find('pageTitle').text()).toEqual(`Edit template '${name}'`);
});
it('should set the nameField to readOnly', () => {
const { find } = testBed;
const nameInput = find('nameField.input');
expect(nameInput.props().disabled).toEqual(true);
});
describe('form payload', () => {
beforeEach(async () => {
const { actions, find } = testBed;
const { actions } = testBed;
// Step 1 (logistics)
const nameInput = find('nameInput');
expect(nameInput.props().readOnly).toEqual(true);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
// Complete step 1 (logistics)
await actions.completeStepOne({
indexPatterns: UPDATED_INDEX_PATTERN,
});
actions.completeStepOne({
indexPatterns: UPDATED_INDEX_PATTERN,
});
// Step 2 (index settings)
await actions.completeStepTwo(JSON.stringify(SETTINGS));
// Step 2 (index settings)
actions.completeStepTwo({
settings: JSON.stringify(SETTINGS),
});
// Step 3 (mappings)
await actions.completeStepThree(JSON.stringify(MAPPINGS));
// Step 3 (mappings)
actions.completeStepThree({
mappings: JSON.stringify(MAPPINGS),
});
// Step 4 (aliases)
actions.completeStepFour({
aliases: JSON.stringify(ALIASES),
// Step 4 (aliases)
await actions.completeStepFour(JSON.stringify(ALIASES));
});
});
@ -139,17 +140,17 @@ describe.skip('<TemplateEdit />', () => {
const { version, order } = templateToEdit;
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
name: TEMPLATE_NAME,
version,
order,
indexPatterns: UPDATED_INDEX_PATTERN,
settings: JSON.stringify(SETTINGS),
mappings: JSON.stringify(MAPPINGS),
aliases: JSON.stringify(ALIASES),
})
);
const expected = {
name: TEMPLATE_NAME,
version,
order,
indexPatterns: UPDATED_INDEX_PATTERN,
settings: SETTINGS,
mappings: MAPPINGS,
aliases: ALIASES,
isManaged: false,
};
expect(JSON.parse(latestRequest.requestBody)).toEqual(expected);
});
});
});

View file

@ -6,7 +6,7 @@
export { PLUGIN } from './plugin';
export { BASE_PATH } from './base_path';
export { INVALID_INDEX_PATTERN_CHARS } from './invalid_characters';
export { INVALID_INDEX_PATTERN_CHARS, INVALID_TEMPLATE_NAME_CHARS } from './invalid_characters';
export * from './index_statuses';
export {

View file

@ -5,3 +5,5 @@
*/
export const INVALID_INDEX_PATTERN_CHARS = ['\\', '/', '?', '"', '<', '>', '|'];
export const INVALID_TEMPLATE_NAME_CHARS = ['"', '*', '\\', ',', '?'];

View file

@ -5,30 +5,8 @@
*/
import { Template, TemplateEs, TemplateListItem } from '../types';
const parseJson = (jsonString: string) => {
let parsedJson;
try {
parsedJson = JSON.parse(jsonString);
// Do not send empty object
if (!hasEntries(parsedJson)) {
parsedJson = undefined;
}
} catch (e) {
// Silently swallow parsing errors since parsing validation is done on client
// so we should never reach this point
}
return parsedJson;
};
const hasEntries = (data: object = {}) => Object.entries(data).length > 0;
const stringifyJson = (json: any) => {
return JSON.stringify(json, null, 2);
};
export function deserializeTemplateList(
indexTemplatesByName: any,
managedTemplatePrefix?: string
@ -66,12 +44,12 @@ export function serializeTemplate(template: Template): TemplateEs {
const serializedTemplate: TemplateEs = {
name,
version: version ? Number(version) : undefined,
order: order ? Number(order) : undefined,
version,
order,
index_patterns: indexPatterns,
settings: settings ? parseJson(settings) : undefined,
aliases: aliases ? parseJson(aliases) : undefined,
mappings: mappings ? parseJson(mappings) : undefined,
settings,
aliases,
mappings,
};
return serializedTemplate;
@ -93,12 +71,12 @@ export function deserializeTemplate(
const deserializedTemplate: Template = {
name,
version: version || version === 0 ? version : '',
order: order || order === 0 ? order : '',
version,
order,
indexPatterns: indexPatterns.sort(),
settings: hasEntries(settings) ? stringifyJson(settings) : undefined,
aliases: hasEntries(aliases) ? stringifyJson(aliases) : undefined,
mappings: hasEntries(mappings) ? stringifyJson(mappings) : undefined,
settings,
aliases,
mappings,
ilmPolicy: settings && settings.index && settings.index.lifecycle,
isManaged: Boolean(managedTemplatePrefix && name.startsWith(managedTemplatePrefix)),
};

View file

@ -20,11 +20,11 @@ export interface TemplateListItem {
export interface Template {
name: string;
indexPatterns: string[];
version?: number | '';
order?: number | '';
settings?: string;
aliases?: string;
mappings?: string;
version?: number;
order?: number;
settings?: object;
aliases?: object;
mappings?: object;
ilmPolicy?: {
name: string;
};

View file

@ -20,14 +20,19 @@ import {
import { FormattedMessage } from '@kbn/i18n/react';
import { templatesDocumentationLink } from '../../../lib/documentation_links';
import { StepProps } from '../types';
import { useJsonStep } from './use_json_step';
export const StepAliases: React.FunctionComponent<StepProps> = ({
template,
updateTemplate,
errors,
setDataGetter,
onStepValidityChange,
}) => {
const { aliases } = template;
const { aliases: aliasesError } = errors;
const { content, setContent, error } = useJsonStep({
prop: 'aliases',
defaultValue: template.aliases,
setDataGetter,
onStepValidityChange,
});
return (
<div data-test-subj="stepAliases">
@ -95,8 +100,8 @@ export const StepAliases: React.FunctionComponent<StepProps> = ({
}}
/>
}
isInvalid={Boolean(aliasesError)}
error={aliasesError}
isInvalid={Boolean(error)}
error={error}
fullWidth
>
<EuiCodeEditor
@ -119,9 +124,9 @@ export const StepAliases: React.FunctionComponent<StepProps> = ({
defaultMessage: 'Aliases code editor',
}
)}
value={aliases}
onChange={(newAliases: string) => {
updateTemplate({ aliases: newAliases });
value={content}
onChange={(updated: string) => {
setContent(updated);
}}
data-test-subj="aliasesEditor"
/>

View file

@ -3,52 +3,100 @@
* 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, { useState } from 'react';
import {
EuiComboBox,
EuiComboBoxOptionProps,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiButtonEmpty,
EuiSpacer,
EuiDescribedFormGroup,
EuiFormRow,
EuiFieldText,
EuiFieldNumber,
} from '@elastic/eui';
import React, { useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { Template } from '../../../../common/types';
import { INVALID_INDEX_PATTERN_CHARS } from '../../../../common/constants';
import {
useForm,
Form,
UseField,
} from '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
import { FormRow } from '../../../../../../../../src/plugins/es_ui_shared/static/forms/components';
import { templatesDocumentationLink } from '../../../lib/documentation_links';
import { StepProps } from '../types';
import { schemas } from '../template_form_schemas';
const indexPatternInvalidCharacters = INVALID_INDEX_PATTERN_CHARS.join(' ');
const i18n = {
name: {
title: (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.nameTitle"
defaultMessage="Name"
/>
),
description: (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.nameDescription"
defaultMessage="A unique identifier for this template."
/>
),
},
indexPatterns: {
title: (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.indexPatternsTitle"
defaultMessage="Index patterns"
/>
),
description: (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.indexPatternsDescription"
defaultMessage="The index patterns to apply to the template."
/>
),
},
order: {
title: (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.orderTitle"
defaultMessage="Merge order"
/>
),
description: (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.orderDescription"
defaultMessage="The merge order when multiple templates match an index."
/>
),
},
version: {
title: (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.versionTitle"
defaultMessage="Version"
/>
),
description: (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.versionDescription"
defaultMessage="A number that identifies the template to external management systems."
/>
),
},
};
export const StepLogistics: React.FunctionComponent<StepProps> = ({
template,
updateTemplate,
errors,
isEditing,
setDataGetter,
onStepValidityChange,
}) => {
const { name, order, version, indexPatterns } = template;
const { name: nameError, indexPatterns: indexPatternsError } = errors;
// hooks
const [allIndexPatterns, setAllIndexPatterns] = useState<Template['indexPatterns']>([]);
const [touchedFields, setTouchedFields] = useState({
name: false,
indexPatterns: false,
const { form } = useForm({
schema: schemas.logistics,
defaultValue: template,
options: { stripEmptyFields: false },
});
const indexPatternOptions = indexPatterns
? indexPatterns.map(pattern => ({ label: pattern, value: pattern }))
: [];
useEffect(() => {
onStepValidityChange(form.isValid);
}, [form.isValid]);
const { name: isNameTouched, indexPatterns: isIndexPatternsTouched } = touchedFields;
useEffect(() => {
setDataGetter(form.submit);
}, [form]);
return (
<div data-test-subj="stepLogistics">
<Form form={form} data-test-subj="stepLogistics">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
@ -78,197 +126,54 @@ export const StepLogistics: React.FunctionComponent<StepProps> = ({
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Name */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.nameTitle"
defaultMessage="Name"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.nameDescription"
defaultMessage="A unique identifier for this template."
/>
}
idAria="stepLogisticsNameDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.fieldNameLabel"
defaultMessage="Name"
/>
}
isInvalid={isNameTouched && Boolean(nameError)}
error={nameError}
fullWidth
>
<EuiFieldText
value={name}
readOnly={isEditing}
onBlur={() => setTouchedFields(prevTouched => ({ ...prevTouched, name: true }))}
data-test-subj="nameInput"
onChange={e => {
updateTemplate({ name: e.target.value });
}}
fullWidth
/>
</EuiFormRow>
</EuiDescribedFormGroup>
<UseField
path="name"
component={FormRow}
componentProps={{
title: i18n.name.title,
titleTag: 'h3',
description: i18n.name.description,
idAria: 'stepLogisticsNameDescription',
euiFieldProps: { disabled: isEditing },
['data-test-subj']: 'nameField',
}}
/>
{/* Index patterns */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.indexPatternsTitle"
defaultMessage="Index patterns"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.indexPatternsDescription"
defaultMessage="The index patterns to apply to the template."
/>
}
idAria="stepLogisticsIndexPatternsDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.fieldIndexPatternsLabel"
defaultMessage="Index patterns"
/>
}
helpText={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.fieldIndexPatternsHelpText"
defaultMessage="Spaces and the characters {invalidCharactersList} are not allowed."
values={{
invalidCharactersList: <strong>{indexPatternInvalidCharacters}</strong>,
}}
/>
}
isInvalid={isIndexPatternsTouched && Boolean(indexPatternsError)}
error={indexPatternsError}
fullWidth
>
<EuiComboBox
noSuggestions
fullWidth
data-test-subj="indexPatternsComboBox"
selectedOptions={indexPatternOptions}
onBlur={() =>
setTouchedFields(prevTouched => ({ ...prevTouched, indexPatterns: true }))
}
onChange={(selectedPattern: EuiComboBoxOptionProps[]) => {
const newIndexPatterns = selectedPattern.map(({ value }) => value as string);
updateTemplate({ indexPatterns: newIndexPatterns });
}}
onCreateOption={(selectedPattern: string) => {
if (!selectedPattern.trim().length) {
return;
}
const newIndexPatterns = [...indexPatterns, selectedPattern];
setAllIndexPatterns([...allIndexPatterns, selectedPattern]);
updateTemplate({ indexPatterns: newIndexPatterns });
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
<UseField
path="indexPatterns"
component={FormRow}
componentProps={{
title: i18n.indexPatterns.title,
titleTag: 'h3',
description: i18n.indexPatterns.description,
idAria: 'stepLogisticsIndexPatternsDescription',
['data-test-subj']: 'indexPatternsField',
}}
/>
{/* Order */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.orderTitle"
defaultMessage="Merge order"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.orderDescription"
defaultMessage="The merge order when multiple templates match an index."
/>
}
idAria="stepLogisticsOrderDescription"
fullWidth
>
<EuiFormRow
fullWidth
label={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.fieldOrderLabel"
defaultMessage="Order (optional)"
/>
}
>
<EuiFieldNumber
fullWidth
value={order}
onChange={e => {
const value = e.target.value;
updateTemplate({ order: value === '' ? value : Number(value) });
}}
data-test-subj="orderInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>{' '}
<UseField
path="order"
component={FormRow}
componentProps={{
title: i18n.order.title,
titleTag: 'h3',
description: i18n.order.description,
idAria: 'stepLogisticsOrderDescription',
['data-test-subj']: 'orderField',
}}
/>
{/* Version */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.versionTitle"
defaultMessage="Version"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.versionDescription"
defaultMessage="A number that identifies the template to external management systems."
/>
}
idAria="stepLogisticsVersionDescription"
fullWidth
>
<EuiFormRow
fullWidth
label={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.fieldVersionLabel"
defaultMessage="Version (optional)"
/>
}
>
<EuiFieldNumber
fullWidth
value={version}
onChange={e => {
const value = e.target.value;
updateTemplate({ version: value === '' ? value : Number(value) });
}}
data-test-subj="versionInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</div>
<UseField
path="version"
component={FormRow}
componentProps={{
title: i18n.version.title,
titleTag: 'h3',
description: i18n.version.description,
idAria: 'stepLogisticsVersionDescription',
euiFieldProps: { ['data-test-subj']: 'versionField' },
}}
/>
</Form>
);
};

View file

@ -20,14 +20,19 @@ import {
} from '@elastic/eui';
import { mappingDocumentationLink } from '../../../lib/documentation_links';
import { StepProps } from '../types';
import { useJsonStep } from './use_json_step';
export const StepMappings: React.FunctionComponent<StepProps> = ({
template,
updateTemplate,
errors,
setDataGetter,
onStepValidityChange,
}) => {
const { mappings } = template;
const { mappings: mappingsError } = errors;
const { content, setContent, error } = useJsonStep({
prop: 'mappings',
defaultValue: template.mappings,
setDataGetter,
onStepValidityChange,
});
return (
<div data-test-subj="stepMappings">
@ -97,8 +102,8 @@ export const StepMappings: React.FunctionComponent<StepProps> = ({
}}
/>
}
isInvalid={Boolean(mappingsError)}
error={mappingsError}
isInvalid={Boolean(error)}
error={error}
fullWidth
>
<EuiCodeEditor
@ -121,9 +126,9 @@ export const StepMappings: React.FunctionComponent<StepProps> = ({
defaultMessage: 'Mappings editor',
}
)}
value={mappings}
onChange={(newMappings: string) => {
updateTemplate({ mappings: newMappings });
value={content}
onChange={(udpated: string) => {
setContent(udpated);
}}
data-test-subj="mappingsEditor"
/>

View file

@ -20,9 +20,14 @@ import {
EuiCodeBlock,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { serializers } from '../../../../../../../../src/plugins/es_ui_shared/static/forms/helpers';
import { serializeTemplate } from '../../../../common/lib/template_serialization';
import { Template } from '../../../../common/types';
import { StepProps } from '../types';
const { stripEmptyFields } = serializers;
const NoneDescriptionText = () => (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.noneDescriptionText"
@ -49,7 +54,7 @@ const getDescriptionText = (data: any) => {
export const StepReview: React.FunctionComponent<StepProps> = ({ template, updateCurrentStep }) => {
const { name, indexPatterns, version, order } = template;
const serializedTemplate = serializeTemplate(template);
const serializedTemplate = serializeTemplate(stripEmptyFields(template) as Template);
// Name not included in ES request body
delete serializedTemplate.name;
const {
@ -58,9 +63,9 @@ export const StepReview: React.FunctionComponent<StepProps> = ({ template, updat
aliases: serializedAliases,
} = serializedTemplate;
const numIndexPatterns = indexPatterns.length;
const numIndexPatterns = indexPatterns!.length;
const hasWildCardIndexPattern = Boolean(indexPatterns.find(pattern => pattern === '*'));
const hasWildCardIndexPattern = Boolean(indexPatterns!.find(pattern => pattern === '*'));
const SummaryTab = () => (
<div data-test-subj="summaryTab">
@ -80,7 +85,7 @@ export const StepReview: React.FunctionComponent<StepProps> = ({ template, updat
{numIndexPatterns > 1 ? (
<EuiText>
<ul>
{indexPatterns.map((indexName: string, i: number) => {
{indexPatterns!.map((indexName: string, i: number) => {
return (
<li key={`${indexName}-${i}`}>
<EuiTitle size="xs">
@ -92,7 +97,7 @@ export const StepReview: React.FunctionComponent<StepProps> = ({ template, updat
</ul>
</EuiText>
) : (
indexPatterns.toString()
indexPatterns!.toString()
)}
</EuiDescriptionListDescription>

View file

@ -20,14 +20,19 @@ import {
import { FormattedMessage } from '@kbn/i18n/react';
import { settingsDocumentationLink } from '../../../lib/documentation_links';
import { StepProps } from '../types';
import { useJsonStep } from './use_json_step';
export const StepSettings: React.FunctionComponent<StepProps> = ({
template,
updateTemplate,
errors,
setDataGetter,
onStepValidityChange,
}) => {
const { settings } = template;
const { settings: settingsError } = errors;
const { content, setContent, error } = useJsonStep({
prop: 'settings',
defaultValue: template.settings,
setDataGetter,
onStepValidityChange,
});
return (
<div data-test-subj="stepSettings">
@ -89,8 +94,8 @@ export const StepSettings: React.FunctionComponent<StepProps> = ({
}}
/>
}
isInvalid={Boolean(settingsError)}
error={settingsError}
isInvalid={Boolean(error)}
error={error}
fullWidth
>
<EuiCodeEditor
@ -113,10 +118,8 @@ export const StepSettings: React.FunctionComponent<StepProps> = ({
defaultMessage: 'Index settings editor',
}
)}
value={settings}
onChange={(newSettings: string) => {
updateTemplate({ settings: newSettings });
}}
value={content}
onChange={(updated: string) => setContent(updated)}
data-test-subj="settingsEditor"
/>
</EuiFormRow>

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { isJSON } from '../../../../../../../../src/plugins/es_ui_shared/static/validators/string';
import { StepProps } from '../types';
interface Parameters {
prop: 'settings' | 'mappings' | 'aliases';
setDataGetter: StepProps['setDataGetter'];
onStepValidityChange: StepProps['onStepValidityChange'];
defaultValue?: object;
}
const stringifyJson = (json: any) =>
Object.keys(json).length ? JSON.stringify(json, null, 2) : '{\n\n}';
export const useJsonStep = ({
prop,
defaultValue = {},
setDataGetter,
onStepValidityChange,
}: Parameters) => {
const [content, setContent] = useState<string>(stringifyJson(defaultValue));
const [error, setError] = useState<string | null>(null);
const validateContent = () => {
// We allow empty string as it will be converted to "{}""
const isValid = content.trim() === '' ? true : isJSON(content);
if (!isValid) {
setError(
i18n.translate('xpack.idxMgmt.validators.string.invalidJSONError', {
defaultMessage: 'Invalid JSON format.',
})
);
} else {
setError(null);
}
return isValid;
};
const dataGetter = () => {
const isValid = validateContent();
const value = isValid && content.trim() !== '' ? JSON.parse(content) : {};
const data = { [prop]: value };
return Promise.resolve({ isValid, data });
};
useEffect(() => {
const isValid = validateContent();
onStepValidityChange(isValid);
setDataGetter(dataGetter);
}, [content]);
return {
content,
setContent,
error,
};
};

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, { Fragment, useState, useEffect } from 'react';
import React, { Fragment, useState, useRef } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
@ -13,32 +13,30 @@ import {
EuiForm,
EuiSpacer,
} from '@elastic/eui';
import { serializers } from '../../../../../../../src/plugins/es_ui_shared/static/forms/helpers';
import { Template } from '../../../common/types';
import { TemplateSteps } from './template_steps';
import { StepAliases, StepLogistics, StepMappings, StepSettings, StepReview } from './steps';
import {
validateLogistics,
validateSettings,
validateMappings,
validateAliases,
TemplateValidation,
} from '../../services/template_validation';
import { StepProps } from './types';
import { SectionError } from '..';
import { StepProps, DataGetterFunc } from './types';
import { SectionError } from '../section_error';
const { stripEmptyFields } = serializers;
interface Props {
onSave: (template: Template) => void;
clearSaveError: () => void;
isSaving: boolean;
saveError: any;
template: Template;
defaultValue?: Template;
isEditing?: boolean;
}
const defaultValidation = {
isValid: true,
errors: {},
};
interface ValidationState {
[key: number]: { isValid: boolean };
}
const defaultValidation = { isValid: true };
const stepComponentMap: { [key: number]: React.FunctionComponent<StepProps> } = {
1: StepLogistics,
@ -48,25 +46,16 @@ const stepComponentMap: { [key: number]: React.FunctionComponent<StepProps> } =
5: StepReview,
};
const stepValidationMap: { [key: number]: any } = {
1: validateLogistics,
2: validateSettings,
3: validateMappings,
4: validateAliases,
};
export const TemplateForm: React.FunctionComponent<Props> = ({
template: initialTemplate,
defaultValue = { isManaged: false },
onSave,
isSaving,
saveError,
clearSaveError,
isEditing,
}) => {
// hooks
const [currentStep, setCurrentStep] = useState<number>(1);
const [template, setTemplate] = useState<Template>(initialTemplate);
const [validation, setValidation] = useState<{ [key: number]: TemplateValidation }>({
const [validation, setValidation] = useState<ValidationState>({
1: defaultValidation,
2: defaultValidation,
3: defaultValidation,
@ -74,42 +63,56 @@ export const TemplateForm: React.FunctionComponent<Props> = ({
5: defaultValidation,
});
const template = useRef<Partial<Template>>(defaultValue);
const stepsDataGetters = useRef<Record<number, DataGetterFunc>>({});
const lastStep = Object.keys(stepComponentMap).length;
const CurrentStepComponent = stepComponentMap[currentStep];
const validateStep = stepValidationMap[currentStep];
const stepErrors = validation[currentStep].errors;
const isStepValid = validation[currentStep].isValid;
const updateValidation = (templateToValidate: Template): void => {
const stepValidation = validateStep(templateToValidate);
const setStepDataGetter = (stepDataGetter: DataGetterFunc) => {
stepsDataGetters.current[currentStep] = stepDataGetter;
};
const newValidation = {
...validation,
...{
[currentStep]: stepValidation,
const onStepValidityChange = (isValid: boolean) => {
setValidation(prev => ({
...prev,
[currentStep]: {
isValid,
errors: {},
},
};
setValidation(newValidation);
}));
};
const updateTemplate = (updatedTemplate: Partial<Template>): void => {
const newTemplate = { ...template, ...updatedTemplate };
const validateAndGetDataFromCurrentStep = async () => {
const validateAndGetData = stepsDataGetters.current[currentStep];
updateValidation(newTemplate);
setTemplate(newTemplate);
if (!validateAndGetData) {
throw new Error(`No data getter has been set for step "${currentStep}"`);
}
const { isValid, data } = await validateAndGetData();
if (isValid) {
// Update the template object
template.current = { ...template.current, ...data };
}
return { isValid, data };
};
const updateCurrentStep = (nextStep: number) => {
const updateCurrentStep = async (nextStep: number) => {
// All steps needs validation, except for the last step
const shouldValidate = currentStep !== lastStep;
// If step is invalid do not let user proceed
if (shouldValidate && !isStepValid) {
return;
let isValid = isStepValid;
if (shouldValidate) {
isValid = isValid === false ? false : (await validateAndGetDataFromCurrentStep()).isValid;
// If step is invalid do not let user proceed
if (!isValid) {
return;
}
}
setCurrentStep(nextStep);
@ -138,12 +141,6 @@ export const TemplateForm: React.FunctionComponent<Props> = ({
/>
);
useEffect(() => {
if (!isEditing) {
updateValidation(template);
}
}, []);
return (
<Fragment>
<TemplateSteps
@ -172,10 +169,10 @@ export const TemplateForm: React.FunctionComponent<Props> = ({
<EuiForm data-test-subj="templateForm">
<CurrentStepComponent
template={template}
updateTemplate={updateTemplate}
errors={stepErrors}
template={template.current}
setDataGetter={setStepDataGetter}
updateCurrentStep={updateCurrentStep}
onStepValidityChange={onStepValidityChange}
isEditing={isEditing}
/>
<EuiSpacer size="l" />
@ -218,7 +215,7 @@ export const TemplateForm: React.FunctionComponent<Props> = ({
fill
color="secondary"
iconType="check"
onClick={onSave.bind(null, template)}
onClick={onSave.bind(null, stripEmptyFields(template.current) as Template)}
data-test-subj="submitButton"
isLoading={isSaving}
>

View file

@ -0,0 +1,132 @@
/*
* 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 React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
FormSchema,
FIELD_TYPES,
VALIDATION_TYPES,
} from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
import {
fieldFormatters,
fieldValidators,
} from '../../../../../../../src/plugins/es_ui_shared/static/forms/helpers';
import {
INVALID_INDEX_PATTERN_CHARS,
INVALID_TEMPLATE_NAME_CHARS,
} from '../../../common/constants';
const { emptyField, containsCharsField, startsWithField, indexPatternField } = fieldValidators;
const { toInt } = fieldFormatters;
const indexPatternInvalidCharacters = INVALID_INDEX_PATTERN_CHARS.join(' ');
export const schemas: Record<string, FormSchema> = {
logistics: {
name: {
type: FIELD_TYPES.TEXT,
label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.fieldNameLabel', {
defaultMessage: 'Name',
}),
validations: [
{
validator: emptyField(
i18n.translate('xpack.idxMgmt.templateValidation.templateNameRequiredError', {
defaultMessage: 'A template name is required.',
})
),
},
{
validator: containsCharsField({
chars: ' ',
message: i18n.translate('xpack.idxMgmt.templateValidation.templateNameSpacesError', {
defaultMessage: 'Spaces are not allowed in a template name.',
}),
}),
},
{
validator: startsWithField({
char: '_',
message: i18n.translate(
'xpack.idxMgmt.templateValidation.templateNameUnderscoreError',
{
defaultMessage: 'A template name must not start with an underscore.',
}
),
}),
},
{
validator: startsWithField({
char: '.',
message: i18n.translate('xpack.idxMgmt.templateValidation.templateNamePeriodError', {
defaultMessage: 'A template name must not start with a period.',
}),
}),
},
{
validator: containsCharsField({
chars: INVALID_TEMPLATE_NAME_CHARS,
message: ({ charsFound }) =>
i18n.translate(
'xpack.idxMgmt.templateValidation.templateNameInvalidaCharacterError',
{
defaultMessage: 'A template name must not contain the character "{invalidChar}"',
values: { invalidChar: charsFound[0] },
}
),
}),
},
],
},
indexPatterns: {
type: FIELD_TYPES.COMBO_BOX,
defaultValue: [],
label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.fieldIndexPatternsLabel', {
defaultMessage: 'Index patterns',
}),
helpText: (
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.fieldIndexPatternsHelpText"
defaultMessage="Spaces and the characters {invalidCharactersList} are not allowed."
values={{
invalidCharactersList: <strong>{indexPatternInvalidCharacters}</strong>,
}}
/>
),
validations: [
{
validator: emptyField(
i18n.translate('xpack.idxMgmt.templateValidation.indexPatternsRequiredError', {
defaultMessage: 'At least one index pattern is required.',
})
),
},
{
validator: indexPatternField(i18n),
type: VALIDATION_TYPES.ARRAY_ITEM,
},
],
},
order: {
type: FIELD_TYPES.NUMBER,
label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.fieldOrderLabel', {
defaultMessage: 'Order (optional)',
}),
formatters: [toInt],
},
version: {
type: FIELD_TYPES.NUMBER,
label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.fieldVersionLabel', {
defaultMessage: 'Version (optional)',
}),
formatters: [toInt],
},
},
};

View file

@ -5,12 +5,13 @@
*/
import { Template } from '../../../common/types';
import { TemplateValidation } from '../../services/template_validation';
export interface StepProps {
template: Template;
updateTemplate: (updatedTemplate: Partial<Template>) => void;
template: Partial<Template>;
setDataGetter: (dataGetter: DataGetterFunc) => void;
updateCurrentStep: (step: number) => void;
errors: TemplateValidation['errors'];
onStepValidityChange: (isValid: boolean) => void;
isEditing?: boolean;
}
export type DataGetterFunc = () => Promise<{ isValid: boolean; data: any }>;

View file

@ -16,10 +16,10 @@ interface Props {
export const TabAliases: React.FunctionComponent<Props> = ({ templateDetails }) => {
const { aliases } = templateDetails;
if (aliases) {
if (aliases && Object.keys(aliases).length) {
return (
<div data-test-subj="aliasesTab">
<EuiCodeBlock lang="json">{aliases}</EuiCodeBlock>
<EuiCodeBlock lang="json">{JSON.stringify(aliases, null, 2)}</EuiCodeBlock>
</div>
);
}

View file

@ -16,10 +16,10 @@ interface Props {
export const TabMappings: React.FunctionComponent<Props> = ({ templateDetails }) => {
const { mappings } = templateDetails;
if (mappings) {
if (mappings && Object.keys(mappings).length) {
return (
<div data-test-subj="mappingsTab">
<EuiCodeBlock lang="json">{mappings}</EuiCodeBlock>
<EuiCodeBlock lang="json">{JSON.stringify(mappings, null, 2)}</EuiCodeBlock>
</div>
);
}

View file

@ -16,10 +16,10 @@ interface Props {
export const TabSettings: React.FunctionComponent<Props> = ({ templateDetails }) => {
const { settings } = templateDetails;
if (settings) {
if (settings && Object.keys(settings).length) {
return (
<div data-test-subj="settingsTab">
<EuiCodeBlock lang="json">{settings}</EuiCodeBlock>
<EuiCodeBlock lang="json">{JSON.stringify(settings, null, 2)}</EuiCodeBlock>
</div>
);
}

View file

@ -84,12 +84,12 @@ export const TemplateClone: React.FunctionComponent<RouteComponentProps<MatchPar
} else if (templateToClone) {
const templateData = {
...templateToClone,
...{ name: `${decodedTemplateName}-copy` },
name: `${decodedTemplateName}-copy`,
} as Template;
content = (
<TemplateForm
template={templateData}
defaultValue={templateData}
onSave={onSave}
isSaving={isSaving}
saveError={saveError}

View file

@ -13,19 +13,6 @@ import { Template } from '../../../common/types';
import { saveTemplate } from '../../services/api';
import { getTemplateDetailsLink } from '../../services/routing';
const emptyObject = '{\n\n}';
const DEFAULT_TEMPLATE: Template = {
name: '',
indexPatterns: [],
version: '',
order: '',
settings: emptyObject,
mappings: emptyObject,
aliases: emptyObject,
isManaged: false,
};
export const TemplateCreate: React.FunctionComponent<RouteComponentProps> = ({ history }) => {
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<any>(null);
@ -69,7 +56,6 @@ export const TemplateCreate: React.FunctionComponent<RouteComponentProps> = ({ h
</EuiTitle>
<EuiSpacer size="l" />
<TemplateForm
template={DEFAULT_TEMPLATE}
onSave={onSave}
isSaving={isSaving}
saveError={saveError}

View file

@ -125,7 +125,7 @@ export const TemplateEdit: React.FunctionComponent<RouteComponentProps<MatchPara
</Fragment>
)}
<TemplateForm
template={template}
defaultValue={template}
onSave={onSave}
isSaving={isSaving}
saveError={saveError}

View file

@ -1,12 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { removeEmptyErrorFields, isValid, isStringEmpty } from './validation_helpers';
export { validateLogistics } from './validation_logistics';
export { validateSettings } from './validation_settings';
export { validateMappings } from './validation_mappings';
export { validateAliases } from './validation_aliases';
export { TemplateValidation } from './types';

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface TemplateValidation {
isValid: boolean;
errors: { [key: string]: React.ReactNode[] };
}

View file

@ -1,33 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Template } from '../../../common/types';
import { TemplateValidation } from './types';
import { isStringEmpty, removeEmptyErrorFields, isValid, validateJSON } from './validation_helpers';
export const validateAliases = (template: Template): TemplateValidation => {
const { aliases } = template;
const validation: TemplateValidation = {
isValid: true,
errors: {
aliases: [],
},
};
// Aliases JSON validation
if (typeof aliases === 'string' && !isStringEmpty(aliases)) {
const validationMsg = validateJSON(aliases);
if (typeof validationMsg === 'string') {
validation.errors.aliases.push(validationMsg);
}
}
validation.errors = removeEmptyErrorFields(validation.errors);
validation.isValid = isValid(validation.errors);
return validation;
};

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { TemplateValidation } from './types';
export const removeEmptyErrorFields = (errors: TemplateValidation['errors']) => {
return Object.entries(errors)
.filter(([_key, value]) => value.length > 0)
.reduce((errs: TemplateValidation['errors'], [key, value]) => {
errs[key] = value;
return errs;
}, {});
};
export const isValid = (errors: TemplateValidation['errors']) => {
return Boolean(Object.keys(errors).length === 0);
};
export const isStringEmpty = (str: string | null): boolean => {
return str ? !Boolean(str.trim()) : true;
};
export const validateJSON = (jsonString: string) => {
const invalidJsonMsg = i18n.translate('xpack.idxMgmt.templateValidation.invalidJSONError', {
defaultMessage: 'Invalid JSON format.',
});
try {
const parsedSettingsJson = JSON.parse(jsonString);
if (parsedSettingsJson && typeof parsedSettingsJson !== 'object') {
return invalidJsonMsg;
}
return;
} catch (e) {
return invalidJsonMsg;
}
};

View file

@ -1,127 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import {
ILLEGAL_CHARACTERS,
CONTAINS_SPACES,
validateIndexPattern as getIndexPatternErrors,
} from 'ui/index_patterns';
import { Template } from '../../../common/types';
import { TemplateValidation } from './types';
import { isStringEmpty, removeEmptyErrorFields, isValid } from './validation_helpers';
const validateIndexPattern = (indexPattern: string) => {
if (indexPattern) {
const errors = getIndexPatternErrors(indexPattern);
if (errors[ILLEGAL_CHARACTERS]) {
return i18n.translate('xpack.idxMgmt.templateValidation.indexPatternInvalidCharactersError', {
defaultMessage:
"'{indexPattern}' index pattern contains the invalid {characterListLength, plural, one {character} other {characters}} { characterList }.",
values: {
characterList: errors[ILLEGAL_CHARACTERS].join(' '),
characterListLength: errors[ILLEGAL_CHARACTERS].length,
indexPattern,
},
});
}
if (errors[CONTAINS_SPACES]) {
return i18n.translate('xpack.idxMgmt.templateValidation.indexPatternSpacesError', {
defaultMessage: "'{indexPattern}' index pattern contains spaces.",
values: {
indexPattern,
},
});
}
}
};
export const INVALID_NAME_CHARS = ['"', '*', '\\', ',', '?'];
const doesStringIncludeChar = (string: string, chars: string[]) => {
const invalidChar = chars.find(char => string.includes(char)) || null;
const containsChar = invalidChar !== null;
return { containsChar, invalidChar };
};
export const validateLogistics = (template: Template): TemplateValidation => {
const { name, indexPatterns } = template;
const validation: TemplateValidation = {
isValid: true,
errors: {
indexPatterns: [],
name: [],
},
};
// Name validation
if (name !== undefined && isStringEmpty(name)) {
validation.errors.name.push(
i18n.translate('xpack.idxMgmt.templateValidation.templateNameRequiredError', {
defaultMessage: 'A template name is required.',
})
);
} else {
if (name.includes(' ')) {
validation.errors.name.push(
i18n.translate('xpack.idxMgmt.templateValidation.templateNameSpacesError', {
defaultMessage: 'Spaces are not allowed in a template name.',
})
);
}
if (name.startsWith('_')) {
validation.errors.name.push(
i18n.translate('xpack.idxMgmt.templateValidation.templateNameUnderscoreError', {
defaultMessage: 'A template name must not start with an underscore.',
})
);
}
if (name.startsWith('.')) {
validation.errors.name.push(
i18n.translate('xpack.idxMgmt.templateValidation.templateNamePeriodError', {
defaultMessage: 'A template name must not start with a period.',
})
);
}
const { containsChar, invalidChar } = doesStringIncludeChar(name, INVALID_NAME_CHARS);
if (containsChar) {
validation.errors.name = [
i18n.translate('xpack.idxMgmt.templateValidation.templateNameInvalidaCharacterError', {
defaultMessage: 'A template name must not contain the character "{invalidChar}"',
values: { invalidChar },
}),
];
}
}
// Index patterns validation
if (Array.isArray(indexPatterns) && indexPatterns.length === 0) {
validation.errors.indexPatterns.push(
i18n.translate('xpack.idxMgmt.templateValidation.indexPatternsRequiredError', {
defaultMessage: 'At least one index pattern is required.',
})
);
} else if (Array.isArray(indexPatterns) && indexPatterns.length) {
indexPatterns.forEach(pattern => {
const errorMsg = validateIndexPattern(pattern);
if (errorMsg) {
validation.errors.indexPatterns.push(errorMsg);
}
});
}
validation.errors = removeEmptyErrorFields(validation.errors);
validation.isValid = isValid(validation.errors);
return validation;
};

View file

@ -1,33 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Template } from '../../../common/types';
import { TemplateValidation } from './types';
import { isStringEmpty, removeEmptyErrorFields, isValid, validateJSON } from './validation_helpers';
export const validateMappings = (template: Template): TemplateValidation => {
const { mappings } = template;
const validation: TemplateValidation = {
isValid: true,
errors: {
mappings: [],
},
};
// Mappings JSON validation
if (typeof mappings === 'string' && !isStringEmpty(mappings)) {
const validationMsg = validateJSON(mappings);
if (typeof validationMsg === 'string') {
validation.errors.mappings.push(validationMsg);
}
}
validation.errors = removeEmptyErrorFields(validation.errors);
validation.isValid = isValid(validation.errors);
return validation;
};

View file

@ -1,33 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Template } from '../../../common/types';
import { TemplateValidation } from './types';
import { isStringEmpty, removeEmptyErrorFields, isValid, validateJSON } from './validation_helpers';
export const validateSettings = (template: Template): TemplateValidation => {
const { settings } = template;
const validation: TemplateValidation = {
isValid: true,
errors: {
settings: [],
},
};
// Settings JSON validation
if (typeof settings === 'string' && !isStringEmpty(settings)) {
const validationMsg = validateJSON(settings);
if (typeof validationMsg === 'string') {
validation.errors.settings.push(validationMsg);
}
}
validation.errors = removeEmptyErrorFields(validation.errors);
validation.isValid = isValid(validation.errors);
return validation;
};

View file

@ -4916,10 +4916,7 @@
"xpack.idxMgmt.templateList.table.orderColumnTitle": "順序",
"xpack.idxMgmt.templateList.table.reloadTemplatesButtonLabel": "再読み込み",
"xpack.idxMgmt.templateList.table.settingsColumnTitle": "設定",
"xpack.idxMgmt.templateValidation.indexPatternInvalidCharactersError": "「{indexPattern}」インデックスパターンには無効な{characterListLength, plural, one {文字} other {文字}} { characterList } が含まれています。",
"xpack.idxMgmt.templateValidation.indexPatternSpacesError": "「{indexPattern}」インデックスパターンにはスペースが含まれています。",
"xpack.idxMgmt.templateValidation.indexPatternsRequiredError": "インデックスパターンが最低 1 つ必要です。",
"xpack.idxMgmt.templateValidation.invalidJSONError": "無効な JSON フォーマット。",
"xpack.idxMgmt.templateValidation.templateNameInvalidaCharacterError": "テンプレート名に「{invalidChar}」は使用できません",
"xpack.idxMgmt.templateValidation.templateNamePeriodError": "テンプレート名はピリオドで始めることはできません。",
"xpack.idxMgmt.templateValidation.templateNameRequiredError": "テンプレート名が必要です。",

View file

@ -4919,10 +4919,7 @@
"xpack.idxMgmt.templateList.table.orderColumnTitle": "顺序",
"xpack.idxMgmt.templateList.table.reloadTemplatesButtonLabel": "重新加载",
"xpack.idxMgmt.templateList.table.settingsColumnTitle": "设置",
"xpack.idxMgmt.templateValidation.indexPatternInvalidCharactersError": "“{indexPattern}”索引模式包含无效的{characterListLength, plural, one {字符} other {字符}} { characterList }。",
"xpack.idxMgmt.templateValidation.indexPatternSpacesError": "“{indexPattern}”索引模式包含空格。",
"xpack.idxMgmt.templateValidation.indexPatternsRequiredError": "至少需要一个索引模式。",
"xpack.idxMgmt.templateValidation.invalidJSONError": "JSON 格式无效。",
"xpack.idxMgmt.templateValidation.templateNameInvalidaCharacterError": "模板名称不得包含字符“{invalidChar}”",
"xpack.idxMgmt.templateValidation.templateNamePeriodError": "模板名称不得以句点开头。",
"xpack.idxMgmt.templateValidation.templateNameRequiredError": "模板名称必填。",

View file

@ -16,15 +16,15 @@ export const registerHelpers = ({ supertest }) => {
order: 1,
indexPatterns: INDEX_PATTERNS,
version: 1,
settings: JSON.stringify({
settings: {
number_of_shards: 1,
index: {
lifecycle: {
name: 'my_policy',
}
}
}),
mappings: JSON.stringify({
},
mappings: {
_source: {
enabled: false
},
@ -37,10 +37,10 @@ export const registerHelpers = ({ supertest }) => {
format: 'EEE MMM dd HH:mm:ss Z yyyy'
}
}
}),
aliases: JSON.stringify({
},
aliases: {
alias1: {}
})
}
});
const createTemplate = payload =>

View file

@ -5,4 +5,4 @@
*/
export { registerTestBed } from './testbed';
export { TestBed, TestBedConfig } from './types';
export { TestBed, TestBedConfig, SetupFunc } from './types';