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

This commit is contained in:
Sébastien Loix 2019-09-20 15:35:30 +02:00 committed by GitHub
parent d624c974b6
commit bae7f4727c
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 { interface Props {
form: FormHook<any>; form: FormHook<any>;
FormWrapper?: (props: any) => JSX.Element; FormWrapper?: React.ComponentType;
children: ReactNode | ReactNode[]; children: ReactNode | ReactNode[];
className: string; [key: string]: any;
} }
const DefaultFormWrapper = (props: any) => { export const Form = ({ form, FormWrapper = EuiForm, ...rest }: Props) => (
return <EuiForm {...props} />;
};
export const Form = ({ form, FormWrapper = DefaultFormWrapper, ...rest }: Props) => (
<FormProvider form={form}> <FormProvider form={form}>
<FormWrapper {...rest} /> <FormWrapper {...rest} />
</FormProvider> </FormProvider>

View file

@ -32,7 +32,7 @@ export const FormProvider = ({ children, form }: Props) => (
<FormContext.Provider value={form}>{children}</FormContext.Provider> <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>; const context = useContext(FormContext) as FormHook<T>;
if (context === undefined) { if (context === undefined) {
throw new Error('useFormContext must be used within a <FormProvider />'); 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'; import { mapFormFields, flattenObject, unflattenObject, Subject } from '../lib';
const DEFAULT_ERROR_DISPLAY_TIMEOUT = 500; 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>; form: FormHook<T>;
} }
export function useForm<T = FormData>( export function useForm<T extends object = FormData>(
formConfig: FormConfig<T> | undefined = {} formConfig: FormConfig<T> | undefined = {}
): UseFormReturn<T> { ): UseFormReturn<T> {
const { const {
@ -38,8 +42,9 @@ export function useForm<T = FormData>(
defaultValue = {}, defaultValue = {},
serializer = (data: any) => data, serializer = (data: any) => data,
deserializer = (data: any) => data, deserializer = (data: any) => data,
options = { errorDisplayDelay: DEFAULT_ERROR_DISPLAY_TIMEOUT, stripEmptyFields: true }, options = {},
} = formConfig; } = formConfig;
const formOptions = { ...DEFAULT_OPTIONS, ...options };
const defaultValueDeserialized = const defaultValueDeserialized =
Object.keys(defaultValue).length === 0 ? defaultValue : deserializer(defaultValue); Object.keys(defaultValue).length === 0 ? defaultValue : deserializer(defaultValue);
const [isSubmitted, setSubmitted] = useState(false); const [isSubmitted, setSubmitted] = useState(false);
@ -59,7 +64,7 @@ export function useForm<T = FormData>(
const fieldsToArray = () => Object.values(fieldsRefs.current); const fieldsToArray = () => Object.values(fieldsRefs.current);
const stripEmptyFields = (fields: FieldsMap): FieldsMap => { const stripEmptyFields = (fields: FieldsMap): FieldsMap => {
if (options.stripEmptyFields) { if (formOptions.stripEmptyFields) {
return Object.entries(fields).reduce( return Object.entries(fields).reduce(
(acc, [key, field]) => { (acc, [key, field]) => {
if (typeof field.value !== 'string' || field.value.trim() !== '') { if (typeof field.value !== 'string' || field.value.trim() !== '') {
@ -191,7 +196,7 @@ export function useForm<T = FormData>(
getFields, getFields,
getFormData, getFormData,
getFieldDefaultValue, getFieldDefaultValue,
__options: options, __options: formOptions,
__formData$: formData$, __formData$: formData$,
__updateFormDataAt: updateFormDataAt, __updateFormDataAt: updateFormDataAt,
__readFieldConfigFromSchema: readFieldConfigFromSchema, __readFieldConfigFromSchema: readFieldConfigFromSchema,

View file

@ -17,10 +17,14 @@
* under the License. * under the License.
*/ */
import { ChangeEvent, FormEvent, MouseEvent, MutableRefObject } from 'react'; import { ReactNode, ChangeEvent, FormEvent, MouseEvent, MutableRefObject } from 'react';
import { Subject } from './lib'; 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 isSubmitted: boolean;
readonly isSubmitting: boolean; readonly isSubmitting: boolean;
readonly isValid: boolean; readonly isValid: boolean;
@ -30,7 +34,7 @@ export interface FormHook<T = FormData> {
getFields: () => FieldsMap; getFields: () => FieldsMap;
getFormData: (options?: { unflatten?: boolean }) => T; getFormData: (options?: { unflatten?: boolean }) => T;
getFieldDefaultValue: (fieldName: string) => unknown; getFieldDefaultValue: (fieldName: string) => unknown;
readonly __options: FormOptions; readonly __options: Required<FormOptions>;
readonly __formData$: MutableRefObject<Subject<T>>; readonly __formData$: MutableRefObject<Subject<T>>;
__addField: (field: FieldHook) => void; __addField: (field: FieldHook) => void;
__removeField: (fieldNames: string | string[]) => void; __removeField: (fieldNames: string | string[]) => void;
@ -39,15 +43,15 @@ export interface FormHook<T = FormData> {
__readFieldConfigFromSchema: (fieldName: string) => FieldConfig; __readFieldConfigFromSchema: (fieldName: string) => FieldConfig;
} }
export interface FormSchema<T = FormData> { export interface FormSchema<T extends object = FormData> {
[key: string]: FormSchemaEntry<T>; [key: string]: FormSchemaEntry<T>;
} }
type FormSchemaEntry<T> = type FormSchemaEntry<T extends object> =
| FieldConfig<T> | FieldConfig<T>
| Array<FieldConfig<T>> | Array<FieldConfig<T>>
| { [key: string]: FieldConfig<T> | Array<FieldConfig<T>> | FormSchemaEntry<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; onSubmit?: (data: T, isFormValid: boolean) => void;
schema?: FormSchema<T>; schema?: FormSchema<T>;
defaultValue?: Partial<T>; defaultValue?: Partial<T>;
@ -57,17 +61,17 @@ export interface FormConfig<T = FormData> {
} }
export interface FormOptions { export interface FormOptions {
errorDisplayDelay: number; errorDisplayDelay?: number;
/** /**
* Remove empty string field ("") from form data * Remove empty string field ("") from form data
*/ */
stripEmptyFields: boolean; stripEmptyFields?: boolean;
} }
export interface FieldHook { export interface FieldHook {
readonly path: string; readonly path: string;
readonly label?: string; readonly label?: string;
readonly helpText?: string; readonly helpText?: string | ReactNode;
readonly type: string; readonly type: string;
readonly value: unknown; readonly value: unknown;
readonly errors: ValidationError[]; readonly errors: ValidationError[];
@ -91,10 +95,10 @@ export interface FieldHook {
__serializeOutput: (rawValue?: unknown) => unknown; __serializeOutput: (rawValue?: unknown) => unknown;
} }
export interface FieldConfig<T = any> { export interface FieldConfig<T extends object = any> {
readonly path?: string; readonly path?: string;
readonly label?: string; readonly label?: string;
readonly helpText?: string; readonly helpText?: string | ReactNode;
readonly type?: HTMLInputElement['type']; readonly type?: HTMLInputElement['type'];
readonly defaultValue?: unknown; readonly defaultValue?: unknown;
readonly validations?: Array<ValidationConfig<T>>; readonly validations?: Array<ValidationConfig<T>>;
@ -111,20 +115,20 @@ export interface FieldsMap {
export type FormSubmitHandler<T> = (formData: T, isValid: boolean) => Promise<void>; export type FormSubmitHandler<T> = (formData: T, isValid: boolean) => Promise<void>;
export interface ValidationError { export interface ValidationError<T = string> {
message: string | ((error: ValidationError) => string); message: string;
code?: string; code?: T;
validationType?: string; validationType?: string;
[key: string]: any; [key: string]: any;
} }
export type ValidationFunc<T = any> = (data: { export type ValidationFunc<T extends object = any, E = string> = (data: {
path: string; path: string;
value: unknown; value: unknown;
form: FormHook<T>; form: FormHook<T>;
formData: T; formData: T;
errors: readonly ValidationError[]; errors: readonly ValidationError[];
}) => ValidationError | void | undefined | Promise<ValidationError | void | undefined>; }) => ValidationError<E> | void | undefined | Promise<ValidationError<E> | void | undefined>;
export interface FieldValidateResponse { export interface FieldValidateResponse {
isValid: boolean; isValid: boolean;
@ -133,7 +137,9 @@ export interface FieldValidateResponse {
export type SerializerFunc<T = unknown> = (value: any) => T; 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; type FormatterFunc = (value: any) => unknown;
@ -141,7 +147,7 @@ type FormatterFunc = (value: any) => unknown;
// string | number | boolean | string[] ... // string | number | boolean | string[] ...
type FieldValue = unknown; type FieldValue = unknown;
export interface ValidationConfig<T = any> { export interface ValidationConfig<T extends object = any> {
validator: ValidationFunc<T>; validator: ValidationFunc<T>;
type?: string; type?: string;
exitOnFail?: boolean; 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' { declare module '@elastic/eui' {
export const EuiSideNav: React.SFC<any>; 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 { export interface EuiTableCriteria {
page: { index: number; size: number }; 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. // Define default response for unhandled requests.
// We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, // 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. // 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); const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);

View file

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

View file

@ -7,7 +7,7 @@
import { registerTestBed, TestBedConfig } from '../../../../../../test_utils'; import { registerTestBed, TestBedConfig } from '../../../../../../test_utils';
import { BASE_PATH } from '../../../common/constants'; import { BASE_PATH } from '../../../common/constants';
import { TemplateEdit } from '../../../public/sections/template_edit'; 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'; import { TEMPLATE_NAME } from './constants';
const testBedConfig: TestBedConfig = { const testBedConfig: TestBedConfig = {
@ -18,6 +18,6 @@ const testBedConfig: TestBedConfig = {
doMountAsync: true, doMountAsync: true,
}; };
const initTestBed = registerTestBed(TemplateEdit, testBedConfig); const initTestBed = registerTestBed<TestSubjects>(TemplateEdit, testBedConfig);
export const setup = formSetup.bind(null, initTestBed); 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. * 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 { Template } from '../../../common/types';
import { nextTick } from './index';
export interface TemplateFormTestBed extends TestBed<TemplateFormTestSubjects> { export interface TemplateFormTestBed extends TestBed<TemplateFormTestSubjects> {
actions: { actions: {
@ -13,14 +14,16 @@ export interface TemplateFormTestBed extends TestBed<TemplateFormTestSubjects> {
clickBackButton: () => void; clickBackButton: () => void;
clickSubmitButton: () => void; clickSubmitButton: () => void;
completeStepOne: ({ name, indexPatterns, order, version }: Partial<Template>) => void; completeStepOne: ({ name, indexPatterns, order, version }: Partial<Template>) => void;
completeStepTwo: ({ settings }: Partial<Template>) => void; completeStepTwo: (settings: string) => void;
completeStepThree: ({ mappings }: Partial<Template>) => void; completeStepThree: (mappings: string) => void;
completeStepFour: ({ aliases }: Partial<Template>) => void; completeStepFour: (aliases: string) => void;
selectSummaryTab: (tab: 'summary' | 'request') => 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(); const testBed = await initTestBed();
// User actions // User actions
@ -36,11 +39,11 @@ export const formSetup = async (initTestBed: any): Promise<TemplateFormTestBed>
testBed.find('submitButton').simulate('click'); testBed.find('submitButton').simulate('click');
}; };
const completeStepOne = ({ name, indexPatterns, order, version }: Partial<Template>) => { const completeStepOne = async ({ name, indexPatterns, order, version }: Partial<Template>) => {
const { form, find } = testBed; const { form, find, component } = testBed;
if (name) { if (name) {
form.setInputValue('nameInput', name); form.setInputValue('nameField.input', name);
} }
if (indexPatterns) { if (indexPatterns) {
@ -50,53 +53,68 @@ export const formSetup = async (initTestBed: any): Promise<TemplateFormTestBed>
})); }));
find('mockComboBox').simulate('change', indexPatternsFormatted); // Using mocked EuiComboBox find('mockComboBox').simulate('change', indexPatternsFormatted); // Using mocked EuiComboBox
await nextTick();
} }
if (order) { if (order) {
form.setInputValue('orderInput', JSON.stringify(order)); form.setInputValue('orderField.input', JSON.stringify(order));
} }
if (version) { if (version) {
form.setInputValue('versionInput', JSON.stringify(version)); form.setInputValue('versionField.input', JSON.stringify(version));
} }
clickNextButton(); clickNextButton();
await nextTick();
component.update();
}; };
const completeStepTwo = ({ settings }: Partial<Template>) => { const completeStepTwo = async (settings: string) => {
const { find } = testBed; const { find, component } = testBed;
if (settings) { if (settings) {
find('mockCodeEditor').simulate('change', { find('mockCodeEditor').simulate('change', {
jsonString: settings, jsonString: settings,
}); // Using mocked EuiCodeEditor }); // Using mocked EuiCodeEditor
await nextTick();
component.update();
} }
clickNextButton(); clickNextButton();
await nextTick();
component.update();
}; };
const completeStepThree = ({ mappings }: Partial<Template>) => { const completeStepThree = async (mappings: string) => {
const { find } = testBed; const { find, component } = testBed;
if (mappings) { if (mappings) {
find('mockCodeEditor').simulate('change', { find('mockCodeEditor').simulate('change', {
jsonString: mappings, jsonString: mappings,
}); // Using mocked EuiCodeEditor }); // Using mocked EuiCodeEditor
await nextTick(50);
component.update();
} }
clickNextButton(); clickNextButton();
await nextTick(50); // hooks updates cycles are tricky, adding some latency is needed
component.update();
}; };
const completeStepFour = ({ aliases }: Partial<Template>) => { const completeStepFour = async (aliases: string) => {
const { find } = testBed; const { find, component } = testBed;
if (aliases) { if (aliases) {
find('mockCodeEditor').simulate('change', { find('mockCodeEditor').simulate('change', {
jsonString: aliases, jsonString: aliases,
}); // Using mocked EuiCodeEditor }); // Using mocked EuiCodeEditor
await nextTick(50);
component.update();
} }
clickNextButton(); clickNextButton();
await nextTick(50);
component.update();
}; };
const selectSummaryTab = (tab: 'summary' | 'request') => { const selectSummaryTab = (tab: 'summary' | 'request') => {
@ -126,17 +144,19 @@ export const formSetup = async (initTestBed: any): Promise<TemplateFormTestBed>
export type TemplateFormTestSubjects = TestSubjects; export type TemplateFormTestSubjects = TestSubjects;
type TestSubjects = export type TestSubjects =
| 'backButton' | 'backButton'
| 'codeEditorContainer' | 'codeEditorContainer'
| 'indexPatternsComboBox' | 'indexPatternsField'
| 'indexPatternsWarning' | 'indexPatternsWarning'
| 'indexPatternsWarningDescription' | 'indexPatternsWarningDescription'
| 'mockCodeEditor' | 'mockCodeEditor'
| 'mockComboBox' | 'mockComboBox'
| 'nameInput' | 'nameField'
| 'nameField.input'
| 'nextButton' | 'nextButton'
| 'orderInput' | 'orderField'
| 'orderField.input'
| 'pageTitle' | 'pageTitle'
| 'requestTab' | 'requestTab'
| 'saveTemplateError' | 'saveTemplateError'
@ -153,4 +173,5 @@ type TestSubjects =
| 'templateForm' | 'templateForm'
| 'templateFormContainer' | 'templateFormContainer'
| 'testingEditor' | 'testingEditor'
| 'versionInput'; | 'versionField'
| 'versionField.input';

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
export { PLUGIN } from './plugin'; export { PLUGIN } from './plugin';
export { BASE_PATH } from './base_path'; 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 * from './index_statuses';
export { export {

View file

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

View file

@ -5,30 +5,8 @@
*/ */
import { Template, TemplateEs, TemplateListItem } from '../types'; 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 hasEntries = (data: object = {}) => Object.entries(data).length > 0;
const stringifyJson = (json: any) => {
return JSON.stringify(json, null, 2);
};
export function deserializeTemplateList( export function deserializeTemplateList(
indexTemplatesByName: any, indexTemplatesByName: any,
managedTemplatePrefix?: string managedTemplatePrefix?: string
@ -66,12 +44,12 @@ export function serializeTemplate(template: Template): TemplateEs {
const serializedTemplate: TemplateEs = { const serializedTemplate: TemplateEs = {
name, name,
version: version ? Number(version) : undefined, version,
order: order ? Number(order) : undefined, order,
index_patterns: indexPatterns, index_patterns: indexPatterns,
settings: settings ? parseJson(settings) : undefined, settings,
aliases: aliases ? parseJson(aliases) : undefined, aliases,
mappings: mappings ? parseJson(mappings) : undefined, mappings,
}; };
return serializedTemplate; return serializedTemplate;
@ -93,12 +71,12 @@ export function deserializeTemplate(
const deserializedTemplate: Template = { const deserializedTemplate: Template = {
name, name,
version: version || version === 0 ? version : '', version,
order: order || order === 0 ? order : '', order,
indexPatterns: indexPatterns.sort(), indexPatterns: indexPatterns.sort(),
settings: hasEntries(settings) ? stringifyJson(settings) : undefined, settings,
aliases: hasEntries(aliases) ? stringifyJson(aliases) : undefined, aliases,
mappings: hasEntries(mappings) ? stringifyJson(mappings) : undefined, mappings,
ilmPolicy: settings && settings.index && settings.index.lifecycle, ilmPolicy: settings && settings.index && settings.index.lifecycle,
isManaged: Boolean(managedTemplatePrefix && name.startsWith(managedTemplatePrefix)), isManaged: Boolean(managedTemplatePrefix && name.startsWith(managedTemplatePrefix)),
}; };

View file

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

View file

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

View file

@ -3,52 +3,100 @@
* or more contributor license agreements. Licensed under the Elastic License; * or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { useState } from 'react'; import React, { useEffect } from 'react';
import { import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
EuiComboBox,
EuiComboBoxOptionProps,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiButtonEmpty,
EuiSpacer,
EuiDescribedFormGroup,
EuiFormRow,
EuiFieldText,
EuiFieldNumber,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
import { Template } from '../../../../common/types'; import {
import { INVALID_INDEX_PATTERN_CHARS } from '../../../../common/constants'; 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 { templatesDocumentationLink } from '../../../lib/documentation_links';
import { StepProps } from '../types'; 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> = ({ export const StepLogistics: React.FunctionComponent<StepProps> = ({
template, template,
updateTemplate,
errors,
isEditing, isEditing,
setDataGetter,
onStepValidityChange,
}) => { }) => {
const { name, order, version, indexPatterns } = template; const { form } = useForm({
const { name: nameError, indexPatterns: indexPatternsError } = errors; schema: schemas.logistics,
defaultValue: template,
// hooks options: { stripEmptyFields: false },
const [allIndexPatterns, setAllIndexPatterns] = useState<Template['indexPatterns']>([]);
const [touchedFields, setTouchedFields] = useState({
name: false,
indexPatterns: false,
}); });
const indexPatternOptions = indexPatterns useEffect(() => {
? indexPatterns.map(pattern => ({ label: pattern, value: pattern })) onStepValidityChange(form.isValid);
: []; }, [form.isValid]);
const { name: isNameTouched, indexPatterns: isIndexPatternsTouched } = touchedFields; useEffect(() => {
setDataGetter(form.submit);
}, [form]);
return ( return (
<div data-test-subj="stepLogistics"> <Form form={form} data-test-subj="stepLogistics">
<EuiFlexGroup justifyContent="spaceBetween"> <EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiTitle> <EuiTitle>
@ -78,197 +126,54 @@ export const StepLogistics: React.FunctionComponent<StepProps> = ({
</EuiFlexGroup> </EuiFlexGroup>
<EuiSpacer size="l" /> <EuiSpacer size="l" />
{/* Name */} {/* Name */}
<EuiDescribedFormGroup <UseField
title={ path="name"
<EuiTitle size="s"> component={FormRow}
<h3> componentProps={{
<FormattedMessage title: i18n.name.title,
id="xpack.idxMgmt.templateForm.stepLogistics.nameTitle" titleTag: 'h3',
defaultMessage="Name" description: i18n.name.description,
/> idAria: 'stepLogisticsNameDescription',
</h3> euiFieldProps: { disabled: isEditing },
</EuiTitle> ['data-test-subj']: 'nameField',
} }}
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>
{/* Index patterns */} {/* Index patterns */}
<EuiDescribedFormGroup <UseField
title={ path="indexPatterns"
<EuiTitle size="s"> component={FormRow}
<h3> componentProps={{
<FormattedMessage title: i18n.indexPatterns.title,
id="xpack.idxMgmt.templateForm.stepLogistics.indexPatternsTitle" titleTag: 'h3',
defaultMessage="Index patterns" description: i18n.indexPatterns.description,
/> idAria: 'stepLogisticsIndexPatternsDescription',
</h3> ['data-test-subj']: 'indexPatternsField',
</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>
{/* Order */} {/* Order */}
<EuiDescribedFormGroup <UseField
title={ path="order"
<EuiTitle size="s"> component={FormRow}
<h3> componentProps={{
<FormattedMessage title: i18n.order.title,
id="xpack.idxMgmt.templateForm.stepLogistics.orderTitle" titleTag: 'h3',
defaultMessage="Merge order" description: i18n.order.description,
/> idAria: 'stepLogisticsOrderDescription',
</h3> ['data-test-subj']: 'orderField',
</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>{' '}
{/* Version */} {/* Version */}
<EuiDescribedFormGroup <UseField
title={ path="version"
<EuiTitle size="s"> component={FormRow}
<h3> componentProps={{
<FormattedMessage title: i18n.version.title,
id="xpack.idxMgmt.templateForm.stepLogistics.versionTitle" titleTag: 'h3',
defaultMessage="Version" description: i18n.version.description,
/> idAria: 'stepLogisticsVersionDescription',
</h3> euiFieldProps: { ['data-test-subj']: 'versionField' },
</EuiTitle> }}
} />
description={ </Form>
<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>
); );
}; };

View file

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

View file

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

View file

@ -20,14 +20,19 @@ import {
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
import { settingsDocumentationLink } from '../../../lib/documentation_links'; import { settingsDocumentationLink } from '../../../lib/documentation_links';
import { StepProps } from '../types'; import { StepProps } from '../types';
import { useJsonStep } from './use_json_step';
export const StepSettings: React.FunctionComponent<StepProps> = ({ export const StepSettings: React.FunctionComponent<StepProps> = ({
template, template,
updateTemplate, setDataGetter,
errors, onStepValidityChange,
}) => { }) => {
const { settings } = template; const { content, setContent, error } = useJsonStep({
const { settings: settingsError } = errors; prop: 'settings',
defaultValue: template.settings,
setDataGetter,
onStepValidityChange,
});
return ( return (
<div data-test-subj="stepSettings"> <div data-test-subj="stepSettings">
@ -89,8 +94,8 @@ export const StepSettings: React.FunctionComponent<StepProps> = ({
}} }}
/> />
} }
isInvalid={Boolean(settingsError)} isInvalid={Boolean(error)}
error={settingsError} error={error}
fullWidth fullWidth
> >
<EuiCodeEditor <EuiCodeEditor
@ -113,10 +118,8 @@ export const StepSettings: React.FunctionComponent<StepProps> = ({
defaultMessage: 'Index settings editor', defaultMessage: 'Index settings editor',
} }
)} )}
value={settings} value={content}
onChange={(newSettings: string) => { onChange={(updated: string) => setContent(updated)}
updateTemplate({ settings: newSettings });
}}
data-test-subj="settingsEditor" data-test-subj="settingsEditor"
/> />
</EuiFormRow> </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; * or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with 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 { FormattedMessage } from '@kbn/i18n/react';
import { import {
EuiButton, EuiButton,
@ -13,32 +13,30 @@ import {
EuiForm, EuiForm,
EuiSpacer, EuiSpacer,
} from '@elastic/eui'; } from '@elastic/eui';
import { serializers } from '../../../../../../../src/plugins/es_ui_shared/static/forms/helpers';
import { Template } from '../../../common/types'; import { Template } from '../../../common/types';
import { TemplateSteps } from './template_steps'; import { TemplateSteps } from './template_steps';
import { StepAliases, StepLogistics, StepMappings, StepSettings, StepReview } from './steps'; import { StepAliases, StepLogistics, StepMappings, StepSettings, StepReview } from './steps';
import { import { StepProps, DataGetterFunc } from './types';
validateLogistics, import { SectionError } from '../section_error';
validateSettings,
validateMappings, const { stripEmptyFields } = serializers;
validateAliases,
TemplateValidation,
} from '../../services/template_validation';
import { StepProps } from './types';
import { SectionError } from '..';
interface Props { interface Props {
onSave: (template: Template) => void; onSave: (template: Template) => void;
clearSaveError: () => void; clearSaveError: () => void;
isSaving: boolean; isSaving: boolean;
saveError: any; saveError: any;
template: Template; defaultValue?: Template;
isEditing?: boolean; isEditing?: boolean;
} }
const defaultValidation = { interface ValidationState {
isValid: true, [key: number]: { isValid: boolean };
errors: {}, }
};
const defaultValidation = { isValid: true };
const stepComponentMap: { [key: number]: React.FunctionComponent<StepProps> } = { const stepComponentMap: { [key: number]: React.FunctionComponent<StepProps> } = {
1: StepLogistics, 1: StepLogistics,
@ -48,25 +46,16 @@ const stepComponentMap: { [key: number]: React.FunctionComponent<StepProps> } =
5: StepReview, 5: StepReview,
}; };
const stepValidationMap: { [key: number]: any } = {
1: validateLogistics,
2: validateSettings,
3: validateMappings,
4: validateAliases,
};
export const TemplateForm: React.FunctionComponent<Props> = ({ export const TemplateForm: React.FunctionComponent<Props> = ({
template: initialTemplate, defaultValue = { isManaged: false },
onSave, onSave,
isSaving, isSaving,
saveError, saveError,
clearSaveError, clearSaveError,
isEditing, isEditing,
}) => { }) => {
// hooks
const [currentStep, setCurrentStep] = useState<number>(1); const [currentStep, setCurrentStep] = useState<number>(1);
const [template, setTemplate] = useState<Template>(initialTemplate); const [validation, setValidation] = useState<ValidationState>({
const [validation, setValidation] = useState<{ [key: number]: TemplateValidation }>({
1: defaultValidation, 1: defaultValidation,
2: defaultValidation, 2: defaultValidation,
3: defaultValidation, 3: defaultValidation,
@ -74,42 +63,56 @@ export const TemplateForm: React.FunctionComponent<Props> = ({
5: defaultValidation, 5: defaultValidation,
}); });
const template = useRef<Partial<Template>>(defaultValue);
const stepsDataGetters = useRef<Record<number, DataGetterFunc>>({});
const lastStep = Object.keys(stepComponentMap).length; const lastStep = Object.keys(stepComponentMap).length;
const CurrentStepComponent = stepComponentMap[currentStep]; const CurrentStepComponent = stepComponentMap[currentStep];
const validateStep = stepValidationMap[currentStep];
const stepErrors = validation[currentStep].errors;
const isStepValid = validation[currentStep].isValid; const isStepValid = validation[currentStep].isValid;
const updateValidation = (templateToValidate: Template): void => { const setStepDataGetter = (stepDataGetter: DataGetterFunc) => {
const stepValidation = validateStep(templateToValidate); stepsDataGetters.current[currentStep] = stepDataGetter;
};
const newValidation = { const onStepValidityChange = (isValid: boolean) => {
...validation, setValidation(prev => ({
...{ ...prev,
[currentStep]: stepValidation, [currentStep]: {
isValid,
errors: {},
}, },
}; }));
setValidation(newValidation);
}; };
const updateTemplate = (updatedTemplate: Partial<Template>): void => { const validateAndGetDataFromCurrentStep = async () => {
const newTemplate = { ...template, ...updatedTemplate }; const validateAndGetData = stepsDataGetters.current[currentStep];
updateValidation(newTemplate); if (!validateAndGetData) {
setTemplate(newTemplate); 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 // All steps needs validation, except for the last step
const shouldValidate = currentStep !== lastStep; const shouldValidate = currentStep !== lastStep;
// If step is invalid do not let user proceed let isValid = isStepValid;
if (shouldValidate && !isStepValid) { if (shouldValidate) {
return; isValid = isValid === false ? false : (await validateAndGetDataFromCurrentStep()).isValid;
// If step is invalid do not let user proceed
if (!isValid) {
return;
}
} }
setCurrentStep(nextStep); setCurrentStep(nextStep);
@ -138,12 +141,6 @@ export const TemplateForm: React.FunctionComponent<Props> = ({
/> />
); );
useEffect(() => {
if (!isEditing) {
updateValidation(template);
}
}, []);
return ( return (
<Fragment> <Fragment>
<TemplateSteps <TemplateSteps
@ -172,10 +169,10 @@ export const TemplateForm: React.FunctionComponent<Props> = ({
<EuiForm data-test-subj="templateForm"> <EuiForm data-test-subj="templateForm">
<CurrentStepComponent <CurrentStepComponent
template={template} template={template.current}
updateTemplate={updateTemplate} setDataGetter={setStepDataGetter}
errors={stepErrors}
updateCurrentStep={updateCurrentStep} updateCurrentStep={updateCurrentStep}
onStepValidityChange={onStepValidityChange}
isEditing={isEditing} isEditing={isEditing}
/> />
<EuiSpacer size="l" /> <EuiSpacer size="l" />
@ -218,7 +215,7 @@ export const TemplateForm: React.FunctionComponent<Props> = ({
fill fill
color="secondary" color="secondary"
iconType="check" iconType="check"
onClick={onSave.bind(null, template)} onClick={onSave.bind(null, stripEmptyFields(template.current) as Template)}
data-test-subj="submitButton" data-test-subj="submitButton"
isLoading={isSaving} 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 { Template } from '../../../common/types';
import { TemplateValidation } from '../../services/template_validation';
export interface StepProps { export interface StepProps {
template: Template; template: Partial<Template>;
updateTemplate: (updatedTemplate: Partial<Template>) => void; setDataGetter: (dataGetter: DataGetterFunc) => void;
updateCurrentStep: (step: number) => void; updateCurrentStep: (step: number) => void;
errors: TemplateValidation['errors']; onStepValidityChange: (isValid: boolean) => void;
isEditing?: boolean; 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 }) => { export const TabAliases: React.FunctionComponent<Props> = ({ templateDetails }) => {
const { aliases } = templateDetails; const { aliases } = templateDetails;
if (aliases) { if (aliases && Object.keys(aliases).length) {
return ( return (
<div data-test-subj="aliasesTab"> <div data-test-subj="aliasesTab">
<EuiCodeBlock lang="json">{aliases}</EuiCodeBlock> <EuiCodeBlock lang="json">{JSON.stringify(aliases, null, 2)}</EuiCodeBlock>
</div> </div>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

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

@ -4914,10 +4914,7 @@
"xpack.idxMgmt.templateList.table.orderColumnTitle": "順序", "xpack.idxMgmt.templateList.table.orderColumnTitle": "順序",
"xpack.idxMgmt.templateList.table.reloadTemplatesButtonLabel": "再読み込み", "xpack.idxMgmt.templateList.table.reloadTemplatesButtonLabel": "再読み込み",
"xpack.idxMgmt.templateList.table.settingsColumnTitle": "設定", "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.indexPatternsRequiredError": "インデックスパターンが最低 1 つ必要です。",
"xpack.idxMgmt.templateValidation.invalidJSONError": "無効な JSON フォーマット。",
"xpack.idxMgmt.templateValidation.templateNameInvalidaCharacterError": "テンプレート名に「{invalidChar}」は使用できません", "xpack.idxMgmt.templateValidation.templateNameInvalidaCharacterError": "テンプレート名に「{invalidChar}」は使用できません",
"xpack.idxMgmt.templateValidation.templateNamePeriodError": "テンプレート名はピリオドで始めることはできません。", "xpack.idxMgmt.templateValidation.templateNamePeriodError": "テンプレート名はピリオドで始めることはできません。",
"xpack.idxMgmt.templateValidation.templateNameRequiredError": "テンプレート名が必要です。", "xpack.idxMgmt.templateValidation.templateNameRequiredError": "テンプレート名が必要です。",

View file

@ -4917,10 +4917,7 @@
"xpack.idxMgmt.templateList.table.orderColumnTitle": "顺序", "xpack.idxMgmt.templateList.table.orderColumnTitle": "顺序",
"xpack.idxMgmt.templateList.table.reloadTemplatesButtonLabel": "重新加载", "xpack.idxMgmt.templateList.table.reloadTemplatesButtonLabel": "重新加载",
"xpack.idxMgmt.templateList.table.settingsColumnTitle": "设置", "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.indexPatternsRequiredError": "至少需要一个索引模式。",
"xpack.idxMgmt.templateValidation.invalidJSONError": "JSON 格式无效。",
"xpack.idxMgmt.templateValidation.templateNameInvalidaCharacterError": "模板名称不得包含字符“{invalidChar}”", "xpack.idxMgmt.templateValidation.templateNameInvalidaCharacterError": "模板名称不得包含字符“{invalidChar}”",
"xpack.idxMgmt.templateValidation.templateNamePeriodError": "模板名称不得以句点开头。", "xpack.idxMgmt.templateValidation.templateNamePeriodError": "模板名称不得以句点开头。",
"xpack.idxMgmt.templateValidation.templateNameRequiredError": "模板名称必填。", "xpack.idxMgmt.templateValidation.templateNameRequiredError": "模板名称必填。",

View file

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

View file

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