Index pattern field editor - Add warning and type 'confirm' on delete or save (#95237) (#95526)

* add runtime field change/delete confirm dialog
This commit is contained in:
Matthew Kime 2021-03-26 11:32:40 -05:00 committed by GitHub
parent 030f7a6e4d
commit d8c01e6cc6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 206 additions and 42 deletions

View file

@ -6,12 +6,13 @@
* Side Public License, v 1.
*/
import React from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiConfirmModal } from '@elastic/eui';
import { EuiCallOut, EuiConfirmModal, EuiFieldText, EuiFormRow, EuiSpacer } from '@elastic/eui';
const geti18nTexts = (fieldsToDelete?: string[]) => {
let modalTitle = '';
let confirmButtonText = '';
if (fieldsToDelete) {
const isSingle = fieldsToDelete.length === 1;
@ -19,27 +20,35 @@ const geti18nTexts = (fieldsToDelete?: string[]) => {
? i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.deleteSingleTitle',
{
defaultMessage: `Remove field '{name}'?`,
defaultMessage: `Remove field '{name}'`,
values: { name: fieldsToDelete[0] },
}
)
: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.deleteMultipleTitle',
{
defaultMessage: `Remove {count} fields?`,
defaultMessage: `Remove {count} fields`,
values: { count: fieldsToDelete.length },
}
);
confirmButtonText = isSingle
? i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.removeButtonLabel',
{
defaultMessage: `Remove field`,
}
)
: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.removeMultipleButtonLabel',
{
defaultMessage: `Remove fields`,
}
);
}
return {
modalTitle,
confirmButtonText: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.removeButtonLabel',
{
defaultMessage: 'Remove',
}
),
confirmButtonText,
cancelButtonText: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.cancelButtonLabel',
{
@ -52,6 +61,19 @@ const geti18nTexts = (fieldsToDelete?: string[]) => {
defaultMessage: 'You are about to remove these runtime fields:',
}
),
typeConfirm: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.typeConfirm',
{
defaultMessage: "Type 'REMOVE' to confirm",
}
),
warningRemovingFields: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningRemovingFields',
{
defaultMessage:
'Warning: Removing fields may break searches or visualizations that rely on this field.',
}
),
};
};
@ -65,6 +87,7 @@ export function DeleteFieldModal({ fieldsToDelete, closeModal, confirmDelete }:
const i18nTexts = geti18nTexts(fieldsToDelete);
const { modalTitle, confirmButtonText, cancelButtonText, warningMultipleFields } = i18nTexts;
const isMultiple = Boolean(fieldsToDelete.length > 1);
const [confirmContent, setConfirmContent] = useState<string>();
return (
<EuiConfirmModal
title={modalTitle}
@ -74,17 +97,28 @@ export function DeleteFieldModal({ fieldsToDelete, closeModal, confirmDelete }:
cancelButtonText={cancelButtonText}
buttonColor="danger"
confirmButtonText={confirmButtonText}
confirmButtonDisabled={confirmContent?.toUpperCase() !== 'REMOVE'}
>
{isMultiple && (
<>
<p>{warningMultipleFields}</p>
<ul>
{fieldsToDelete.map((fieldName) => (
<li key={fieldName}>{fieldName}</li>
))}
</ul>
</>
)}
<EuiCallOut color="warning" title={i18nTexts.warningRemovingFields} iconType="alert" size="s">
{isMultiple && (
<>
<p>{warningMultipleFields}</p>
<ul>
{fieldsToDelete.map((fieldName) => (
<li key={fieldName}>{fieldName}</li>
))}
</ul>
</>
)}
</EuiCallOut>
<EuiSpacer />
<EuiFormRow label={i18nTexts.typeConfirm}>
<EuiFieldText
value={confirmContent}
onChange={(e) => setConfirmContent(e.target.value)}
data-test-subj="deleteModalConfirmText"
/>
</EuiFormRow>
</EuiConfirmModal>
);
}

View file

@ -68,7 +68,7 @@ describe('<FieldEditorFlyoutContent />', () => {
const { find } = setup({ ...defaultProps, field });
expect(find('flyoutTitle').text()).toBe(`Edit ${field.name} field`);
expect(find('flyoutTitle').text()).toBe(`Edit field 'foo'`);
expect(find('nameField.input').props().value).toBe(field.name);
expect(find('typeField').props().value).toBe(field.type);
expect(find('scriptField').props().value).toBe(field.script.source);

View file

@ -8,6 +8,7 @@
import React, { useState, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlyoutHeader,
EuiFlyoutBody,
@ -19,6 +20,10 @@ import {
EuiButton,
EuiCallOut,
EuiSpacer,
EuiText,
EuiConfirmModal,
EuiFieldText,
EuiFormRow,
} from '@elastic/eui';
import { DocLinksStart, CoreStart } from 'src/core/public';
@ -30,16 +35,6 @@ import type { Props as FieldEditorProps, FieldEditorFormState } from './field_ed
const geti18nTexts = (field?: Field) => {
return {
flyoutTitle: field
? i18n.translate('indexPatternFieldEditor.editor.flyoutEditFieldTitle', {
defaultMessage: 'Edit {fieldName} field',
values: {
fieldName: field.name,
},
})
: i18n.translate('indexPatternFieldEditor.editor.flyoutDefaultTitle', {
defaultMessage: 'Create field',
}),
closeButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCloseButtonLabel', {
defaultMessage: 'Close',
}),
@ -49,6 +44,31 @@ const geti18nTexts = (field?: Field) => {
formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', {
defaultMessage: 'Fix errors in form before continuing.',
}),
cancelButtonText: i18n.translate(
'indexPatternFieldEditor.saveRuntimeField.confirmationModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
),
confirmButtonText: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.saveButtonLabel',
{
defaultMessage: 'Save',
}
),
warningChangingFields: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningChangingFields',
{
defaultMessage:
'Warning: Changing name or type may break searches or visualizations that rely on this field.',
}
),
typeConfirm: i18n.translate(
'indexPatternFieldEditor.saveRuntimeField.confirmModal.typeConfirm',
{
defaultMessage: "Type 'CHANGE' to continue:",
}
),
};
};
@ -97,6 +117,7 @@ const FieldEditorFlyoutContentComponent = ({
runtimeFieldValidator,
isSavingField,
}: Props) => {
const isEditingExistingField = !!field;
const i18nTexts = geti18nTexts(field);
const [formState, setFormState] = useState<FieldEditorFormState>({
@ -112,6 +133,8 @@ const FieldEditorFlyoutContentComponent = ({
);
const [isValidating, setIsValidating] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [confirmContent, setConfirmContent] = useState<string>();
const { submit, isValid: isFormValid, isSubmitted } = formState;
const { fields } = indexPattern;
@ -129,6 +152,8 @@ const FieldEditorFlyoutContentComponent = ({
const onClickSave = useCallback(async () => {
const { isValid, data } = await submit();
const nameChange = field?.name !== data.name;
const typeChange = field?.type !== data.type;
if (isValid) {
if (data.script) {
@ -147,9 +172,13 @@ const FieldEditorFlyoutContentComponent = ({
}
}
onSave(data);
if (isEditingExistingField && (nameChange || typeChange)) {
setIsModalVisible(true);
} else {
onSave(data);
}
}
}, [onSave, submit, runtimeFieldValidator]);
}, [onSave, submit, runtimeFieldValidator, field, isEditingExistingField]);
const namesNotAllowed = useMemo(() => fields.map((fld) => fld.name), [fields]);
@ -180,12 +209,70 @@ const FieldEditorFlyoutContentComponent = ({
[fieldTypeToProcess, namesNotAllowed, existingConcreteFields]
);
const modal = isModalVisible ? (
<EuiConfirmModal
title={`Confirm changes to '${field?.name}'`}
data-test-subj="runtimeFieldSaveConfirmModal"
cancelButtonText={i18nTexts.cancelButtonText}
confirmButtonText={i18nTexts.confirmButtonText}
confirmButtonDisabled={confirmContent?.toUpperCase() !== 'CHANGE'}
onCancel={() => {
setIsModalVisible(false);
setConfirmContent('');
}}
onConfirm={async () => {
const { data } = await submit();
onSave(data);
}}
>
<EuiCallOut
color="warning"
title={i18nTexts.warningChangingFields}
iconType="alert"
size="s"
/>
<EuiSpacer />
<EuiFormRow label={i18nTexts.typeConfirm}>
<EuiFieldText
value={confirmContent}
onChange={(e) => setConfirmContent(e.target.value)}
data-test-subj="saveModalConfirmText"
/>
</EuiFormRow>
</EuiConfirmModal>
) : null;
return (
<>
<EuiFlyoutHeader>
<EuiTitle size="m" data-test-subj="flyoutTitle">
<h2 id="fieldEditorTitle">{i18nTexts.flyoutTitle}</h2>
<EuiTitle data-test-subj="flyoutTitle">
<h2>
{field ? (
<FormattedMessage
id="indexPatternFieldEditor.editor.flyoutEditFieldTitle"
defaultMessage="Edit field '{fieldName}'"
values={{
fieldName: field.name,
}}
/>
) : (
<FormattedMessage
id="indexPatternFieldEditor.editor.flyoutDefaultTitle"
defaultMessage="Create field"
/>
)}
</h2>
</EuiTitle>
<EuiText color="subdued">
<p>
<FormattedMessage
id="indexPatternFieldEditor.editor.flyoutEditFieldSubtitle"
defaultMessage="Index pattern: {patternName}"
values={{
patternName: <i>{indexPattern.title}</i>,
}}
/>
</p>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
@ -246,6 +333,7 @@ const FieldEditorFlyoutContentComponent = ({
</>
)}
</EuiFlyoutFooter>
{modal}
</>
);
};

View file

@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }) {
const browser = getService('browser');
const retry = getService('retry');
const PageObjects = getPageObjects(['settings']);
const testSubjects = getService('testSubjects');
describe('runtime fields', function () {
this.tags(['skipFirefox']);
@ -47,6 +48,20 @@ export default function ({ getService, getPageObjects }) {
expect(parseInt(await PageObjects.settings.getFieldsTabCount())).to.be(startingCount + 1);
});
});
it('should modify runtime field', async function () {
await PageObjects.settings.filterField(fieldName);
await testSubjects.click('editFieldFormat');
await PageObjects.settings.setFieldType('Long');
await PageObjects.settings.changeFieldScript('emit(6);');
await PageObjects.settings.clickSaveField();
await PageObjects.settings.confirmSave();
});
it('should delete runtime field', async function () {
await testSubjects.click('deleteField');
await PageObjects.settings.confirmDelete();
});
});
});
}

View file

@ -502,6 +502,16 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
await this.closeIndexPatternFieldEditor();
}
public async confirmSave() {
await testSubjects.setValue('saveModalConfirmText', 'change');
await testSubjects.click('confirmModalConfirmButton');
}
public async confirmDelete() {
await testSubjects.setValue('deleteModalConfirmText', 'remove');
await testSubjects.click('confirmModalConfirmButton');
}
async closeIndexPatternFieldEditor() {
await retry.waitFor('field editor flyout to close', async () => {
return !(await testSubjects.exists('euiFlyoutCloseButton'));
@ -543,6 +553,17 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
browser.pressKeys(script);
}
async changeFieldScript(script: string) {
log.debug('set script = ' + script);
const formatRow = await testSubjects.find('valueRow');
const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0];
retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea()));
const monacoTextArea = await getMonacoTextArea();
await monacoTextArea.focus();
browser.pressKeys(browser.keys.DELETE.repeat(30));
browser.pressKeys(script);
}
async clickAddScriptedField() {
log.debug('click Add Scripted Field');
await testSubjects.click('addScriptedFieldLink');

View file

@ -10,7 +10,6 @@ import { FtrProviderContext } from '../ftr_provider_context';
export function FieldEditorProvider({ getService }: FtrProviderContext) {
const browser = getService('browser');
const retry = getService('retry');
const testSubjects = getService('testSubjects');
class FieldEditor {
@ -33,10 +32,17 @@ export function FieldEditorProvider({ getService }: FtrProviderContext) {
await browser.pressKeys(script);
}
public async save() {
await retry.try(async () => {
await testSubjects.click('fieldSaveButton');
await testSubjects.missingOrFail('fieldSaveButton', { timeout: 2000 });
});
await testSubjects.click('fieldSaveButton');
}
public async confirmSave() {
await testSubjects.setValue('saveModalConfirmText', 'change');
await testSubjects.click('confirmModalConfirmButton');
}
public async confirmDelete() {
await testSubjects.setValue('deleteModalConfirmText', 'remove');
await testSubjects.click('confirmModalConfirmButton');
}
}

View file

@ -50,6 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.lens.editField();
await fieldEditor.setName('runtimefield2');
await fieldEditor.save();
await fieldEditor.confirmSave();
await PageObjects.lens.searchField('runtime');
await PageObjects.lens.waitForField('runtimefield2');
await PageObjects.lens.dragFieldToDimensionTrigger(
@ -66,6 +67,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should able to remove field', async () => {
await PageObjects.lens.clickField('runtimefield2');
await PageObjects.lens.removeField();
await fieldEditor.confirmDelete();
await PageObjects.lens.waitForFieldMissing('runtimefield2');
});
});

View file

@ -192,8 +192,6 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await retry.try(async () => {
await testSubjects.click('lnsFieldListPanelRemove');
await testSubjects.missingOrFail('lnsFieldListPanelRemove');
await testSubjects.click('confirmModalConfirmButton');
await testSubjects.missingOrFail('confirmModalConfirmButton');
});
},