diff --git a/src-docs/src/views/inline_edit/inline_edit_example.js b/src-docs/src/views/inline_edit/inline_edit_example.js index a8e311eecb5..7d0e95b0a0d 100644 --- a/src-docs/src/views/inline_edit/inline_edit_example.js +++ b/src-docs/src/views/inline_edit/inline_edit_example.js @@ -20,6 +20,9 @@ const inlineEditTitleSource = require('!!raw-loader!./inline_edit_title'); import InlineEditModeProps from './inline_edit_mode_props'; const inlineEditModePropsSource = require('!!raw-loader!./inline_edit_mode_props'); +import InlineEditSave from './inline_edit_save'; +const inlineEditSaveSource = require('!!raw-loader!././inline_edit_save'); + import InlineEditConfirm from './inline_edit_confirm'; const inlineEditConfirmSource = require('!!raw-loader!././inline_edit_confirm'); @@ -38,6 +41,7 @@ export const InlineEditExample = { ), + isNew: true, sections: [ { title: 'Display and edit basic text', @@ -80,6 +84,26 @@ export const InlineEditExample = { demo: , props: { EuiInlineEditTitle }, }, + { + title: 'Saving edited text', + text: ( + <> +

+ Use the onSave property to retrieve the value of + the edited text when the save button is pressed, and the{' '} + onConfirm callback (if passed) returns{' '} + true .{' '} +

+ + ), + source: [ + { + type: GuideSectionTypes.TSX, + code: inlineEditSaveSource, + }, + ], + demo: , + }, { title: 'Loading and invalid states', text: ( diff --git a/src-docs/src/views/inline_edit/inline_edit_save.tsx b/src-docs/src/views/inline_edit/inline_edit_save.tsx new file mode 100644 index 00000000000..1dc8017d567 --- /dev/null +++ b/src-docs/src/views/inline_edit/inline_edit_save.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { EuiButton, EuiInlineEditText, EuiSpacer } from '../../../../src'; + +export default () => { + const saveToLocalStorage = (newInlineEditValue: string) => { + localStorage.setItem('inlineEditValue', newInlineEditValue); + }; + + const removeFromLocalStorage = () => { + localStorage.removeItem('inlineEditValue'); + }; + + const defaultInlineEditValue = + localStorage.getItem('inlineEditValue') || + 'This value will persist when you refresh the page!'; + + return ( + <> + saveToLocalStorage(onSaveVal)} + /> + + + + + Remove saved value from local storage + + + ); +}; diff --git a/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap b/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap new file mode 100644 index 00000000000..9ae73106ac9 --- /dev/null +++ b/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap @@ -0,0 +1,902 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiInlineEditForm Edit Mode disables the save button when input is invalid 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+`; + +exports[`EuiInlineEditForm Edit Mode renders 1`] = ` +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+`; + +exports[`EuiInlineEditForm Edit Mode renders EuiSkeletonRectangles in place of editMode buttons when loading 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`EuiInlineEditForm Edit Mode renders editModeProps.formRowProps 1`] = ` +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+`; + +exports[`EuiInlineEditForm Edit Mode renders editModeProps.inputProps 1`] = ` +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+`; + +exports[`EuiInlineEditForm Edit Mode renders save button and cancel button aria-labels 1`] = ` +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+
+
+
+ Loaded +
+ + +
+
+
+
+
+
+
+`; + +exports[`EuiInlineEditForm Read Mode renders 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditForm Read Mode renders readModeProps onto the button 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditForm Read Mode renders small size 1`] = ` +
+ +
+`; diff --git a/src/components/inline_edit/__snapshots__/inline_edit_text.test.tsx.snap b/src/components/inline_edit/__snapshots__/inline_edit_text.test.tsx.snap index 345679c9b9a..42d20b7f64e 100644 --- a/src/components/inline_edit/__snapshots__/inline_edit_text.test.tsx.snap +++ b/src/components/inline_edit/__snapshots__/inline_edit_text.test.tsx.snap @@ -1,11 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`EuiInlineEditText props renders as text 1`] = ` +exports[`EuiInlineEditText renders 1`] = `
+ + + +
+`; + +exports[`EuiInlineEditText text sizes renders m 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditText text sizes renders s 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditText text sizes renders xs 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditTitle title sizes renders size l 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditTitle title sizes renders size m 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditTitle title sizes renders size s 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditTitle title sizes renders size xs 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditTitle title sizes renders size xxs 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditTitle title sizes renders size xxxs 1`] = ` +
+ diff --git a/src/components/inline_edit/inline_edit_form.test.tsx b/src/components/inline_edit/inline_edit_form.test.tsx new file mode 100644 index 00000000000..1710f805942 --- /dev/null +++ b/src/components/inline_edit/inline_edit_form.test.tsx @@ -0,0 +1,313 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render } from '../../test/rtl'; +import { requiredProps } from '../../test/required_props'; +import { fireEvent } from '@testing-library/dom'; + +import { + EuiInlineEditForm, + EuiInlineEditFormProps, + SMALL_SIZE_FORM, + MEDIUM_SIZE_FORM, +} from './inline_edit_form'; + +describe('EuiInlineEditForm', () => { + const commonInlineEditFormProps: EuiInlineEditFormProps = { + ...requiredProps, + defaultValue: 'Hello World!', + inputAriaLabel: 'Edit inline', + sizes: MEDIUM_SIZE_FORM, + children: (readModeValue) => readModeValue, + }; + + describe('Read Mode', () => { + it('renders', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders readModeProps onto the button', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(getByTestSubject('euiInlineReadModeButton')).toBeTruthy(); + }); + + it('renders small size', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + }); + + describe('Edit Mode', () => { + it('renders', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders editModeProps.inputProps', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(getByTestSubject('customInput')).toBeTruthy(); + }); + + it('renders editModeProps.formRowProps', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(getByTestSubject('customErrorText')).toBeTruthy(); + }); + + it('renders save button and cancel button aria-labels', () => { + const { container, getByLabelText } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(getByLabelText("Yes! Let's save.")).toBeTruthy(); + expect(getByLabelText('Uh no. Do not save.')).toBeTruthy(); + }); + + it('renders EuiSkeletonRectangles in place of editMode buttons when loading', () => { + const { container, queryByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + + expect(container.querySelectorAll('.euiSkeletonRectangle')).toHaveLength( + 2 + ); + + expect(queryByTestSubject('euiInlineEditModeSaveButton')).toBeFalsy(); + expect(queryByTestSubject('euiInlineEditModeCancelButton')).toBeFalsy(); + }); + + it('disables the save button when input is invalid ', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + + expect( + getByTestSubject('euiInlineEditModeInput').hasAttribute('aria-invalid') + ).toBeTruthy(); + + expect(getByTestSubject('euiInlineEditModeSaveButton')).toBeDisabled(); + expect( + getByTestSubject('euiInlineEditModeCancelButton') + ).not.toBeDisabled(); + }); + + it('returns the latest value within EuiFieldText upon saving', () => { + const onSaveFunction = jest.fn(); + + const { getByTestSubject } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + + expect(onSaveFunction).toHaveBeenCalledWith('New message!'); + }); + }); + + describe('Toggling between readMode and editMode', () => { + it('clicking on the readModeButton takes us to editMode', () => { + const { getByTestSubject, queryByTestSubject } = render( + + ); + + fireEvent.click(getByTestSubject('euiInlineReadModeButton')); + expect(getByTestSubject('euiInlineEditModeInput')).toBeTruthy(); + expect(queryByTestSubject('euiInlineReadModeButton')).toBeFalsy(); + }); + + it('saves text and returns to readMode', () => { + const { getByTestSubject, getByText } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + expect( + getByTestSubject('euiInlineEditModeInput').getAttribute('value') + ).toEqual('New message!'); + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + + expect(getByTestSubject('euiInlineReadModeButton')).toBeTruthy(); + expect(getByText('New message!')).toBeTruthy(); + }); + + it('cancels text and returns to readMode', () => { + const onSave = jest.fn(); + + const { getByTestSubject, getByText } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + expect( + getByTestSubject('euiInlineEditModeInput').getAttribute('value') + ).toEqual('New message!'); + fireEvent.click(getByTestSubject('euiInlineEditModeCancelButton')); + + expect(getByTestSubject('euiInlineReadModeButton')).toBeTruthy(); + expect(getByText('Hello World!')).toBeTruthy(); + expect(onSave).not.toHaveBeenCalled(); + }); + + describe('onConfirm behavior on save', () => { + it('returns to readMode with updated text when onConfirm returns true', () => { + const { getByTestSubject, getByText } = render( + true} + /> + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + + expect(getByTestSubject('euiInlineReadModeButton')).toBeTruthy(); + expect(getByText('New message!')).toBeTruthy(); + }); + + it('stays in editMode when onConfirm returns false', () => { + const onSave = jest.fn(); + + const { getByTestSubject, queryByTestSubject } = render( + false} + /> + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + + expect(queryByTestSubject('euiInlineReadModeButton')).toBeFalsy(); + expect(getByTestSubject('euiInlineEditModeInput')).toBeTruthy(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('sends the editMode text to the onConfirm callback', () => { + const { getByText, getByTestSubject } = render( + { + return editModeValue === '' ? false : true; + }} + /> + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: '' }, + }); + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + + expect(getByTestSubject('euiInlineEditModeInput')).toBeTruthy(); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'hey there' }, + }); + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + + expect(getByTestSubject('euiInlineReadModeButton')).toBeTruthy(); + expect(getByText('hey there')).toBeTruthy(); + }); + }); + }); +}); diff --git a/src/components/inline_edit/inline_edit_form.tsx b/src/components/inline_edit/inline_edit_form.tsx index f256b1dfa7e..23b68582707 100644 --- a/src/components/inline_edit/inline_edit_form.tsx +++ b/src/components/inline_edit/inline_edit_form.tsx @@ -6,7 +6,12 @@ * Side Public License, v 1. */ -import React, { ReactNode, FunctionComponent, useState } from 'react'; +import React, { + ReactNode, + FunctionComponent, + useState, + HTMLAttributes, +} from 'react'; import classNames from 'classnames'; import { CommonProps } from '../common'; @@ -27,53 +32,56 @@ import { useEuiI18n } from '../i18n'; import { useGeneratedHtmlId } from '../../services/accessibility'; // Props shared between the internal form component as well as consumer-facing components -export type EuiInlineEditCommonProps = CommonProps & { - defaultValue: string; - /** - * Allow users to pass in a function that is called when the confirm button is clicked - * The function should return a boolean flag that will determine if the value will be saved. - * When the flag is true, the value will be saved. When the flag is false, the user will be - * returned to editMode. - */ - onConfirm?: () => boolean; - /** - * Form label that appears above the form control - * This is required for accessibility because there is no visual label on the input - */ - inputAriaLabel: string; - /** - * Aria-label for save button in editMode - */ - saveButtonAriaLabel?: string; - /** - * Aria-label for cancel button in editMode - */ - cancelButtonAriaLabel?: string; - /** - * Start in editMode - */ - startWithEditOpen?: boolean; - /** - * Props that will be applied directly to the EuiEmptyButton displayed in readMode - */ - readModeProps?: Omit; - /** - * Props that will be applied directly to the `EuiFormRow` and `EuiFieldText` input displayed in editMode - */ - editModeProps?: { - formRowProps?: Partial; - inputProps?: Partial; +export type EuiInlineEditCommonProps = HTMLAttributes & + CommonProps & { + defaultValue: string; + /** + * Callback that passes the updated value of the edited text when the save button is pressed, + * and the `onConfirm` callback (if passed) returns true + */ + onSave?: (onSaveValue: string) => void; + /** + * Callback that fires when users click the save button, but before the text actually saves. Passes the current edited + * text value as an argument. + */ + onConfirm?: (editModeValue: string) => boolean; + /** + * Form label that appears above the form control + * This is required for accessibility because there is no visual label on the input + */ + inputAriaLabel: string; + /** + * Aria-label for save button in editMode + */ + saveButtonAriaLabel?: string; + /** + * Aria-label for cancel button in editMode + */ + cancelButtonAriaLabel?: string; + /** + * Start in editMode + */ + startWithEditOpen?: boolean; + /** + * Props that will be applied directly to the EuiEmptyButton displayed in readMode + */ + readModeProps?: Omit; + /** + * Props that will be applied directly to the `EuiFormRow` and `EuiFieldText` input displayed in editMode + */ + editModeProps?: { + formRowProps?: Partial; + inputProps?: Partial; + }; + /** + * Loading state when changes are saved in editMode + */ + isLoading?: boolean; + /** + * Validation for the form control used to edit text in editMode + */ + isInvalid?: boolean; }; - /** - * Loading state when changes are saved in editMode - */ - isLoading?: boolean; - - /** - * Validation for the form control used to edit text in editMode - */ - isInvalid?: boolean; -}; // Internal-only props, passed by the consumer-facing components export type EuiInlineEditFormProps = EuiInlineEditCommonProps & { @@ -115,8 +123,9 @@ export const EuiInlineEditForm: FunctionComponent = ({ startWithEditOpen, readModeProps, editModeProps, - isLoading, + isLoading = false, isInvalid, + onSave, }) => { const classes = classNames('euiInlineEdit', className); @@ -147,15 +156,13 @@ export const EuiInlineEditForm: FunctionComponent = ({ }; const saveInlineEditValue = () => { - if (editModeValue && onConfirm && !onConfirm()) { - // If there is text, an onConfirm method is present, and it has returned false, cancel the action + if (onConfirm && !onConfirm(editModeValue)) { + // If an onConfirm method is present, and it has returned false, cancel the action return; - } else if (editModeValue) { + } else { setReadModeValue(editModeValue); setIsEditing(!isEditing); - } else { - // If there's no text, cancel the action, reset the input text, and return to readMode - cancelInlineEdit(); + onSave?.(editModeValue); } }; @@ -179,6 +186,7 @@ export const EuiInlineEditForm: FunctionComponent = ({ compressed={sizes.compressed} isInvalid={isInvalid} isLoading={isLoading} + data-test-subj="euiInlineEditModeInput" {...editModeProps?.inputProps} /> @@ -201,6 +209,7 @@ export const EuiInlineEditForm: FunctionComponent = ({ size={sizes.buttonSize} iconSize={sizes.iconSize} disabled={isInvalid} + data-test-subj="euiInlineEditModeSaveButton" /> @@ -224,6 +233,7 @@ export const EuiInlineEditForm: FunctionComponent = ({ display="base" size={sizes.buttonSize} iconSize={sizes.iconSize} + data-test-subj="euiInlineEditModeCancelButton" /> @@ -244,6 +254,7 @@ export const EuiInlineEditForm: FunctionComponent = ({ onClick={() => { setIsEditing(!isEditing); }} + data-test-subj="euiInlineReadModeButton" {...readModeProps} > {children(readModeValue)} diff --git a/src/components/inline_edit/inline_edit_text.test.tsx b/src/components/inline_edit/inline_edit_text.test.tsx index eb47d25e5b8..736f2b99e8c 100644 --- a/src/components/inline_edit/inline_edit_text.test.tsx +++ b/src/components/inline_edit/inline_edit_text.test.tsx @@ -10,20 +10,39 @@ import React from 'react'; import { render } from '../../test/rtl'; import { requiredProps } from '../../test/required_props'; -import { EuiInlineEditText } from './inline_edit_text'; +import { EuiInlineEditText, EuiInlineEditTextProps } from './inline_edit_text'; +import { TEXT_SIZES } from '../text/text'; describe('EuiInlineEditText', () => { - describe('props', () => { - test('renders as text', () => { - const { container } = render( - - ); - - expect(container.firstChild).toMatchSnapshot(); + const inlineEditTextProps: EuiInlineEditTextProps = { + ...requiredProps, + inputAriaLabel: 'Edit text inline', + defaultValue: 'Hello World!', + }; + + it('renders', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + describe('text sizes', () => { + // Remove 'relative' from text sizes available for EuiInlineEditText + const availableTextSizes = TEXT_SIZES.filter((size) => size !== 'relative'); + + availableTextSizes.forEach((size: string) => { + test(`renders ${size}`, () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); }); }); }); diff --git a/src/components/inline_edit/inline_edit_text.tsx b/src/components/inline_edit/inline_edit_text.tsx index 1719e369fdd..5a9451dba73 100644 --- a/src/components/inline_edit/inline_edit_text.tsx +++ b/src/components/inline_edit/inline_edit_text.tsx @@ -39,7 +39,7 @@ export const EuiInlineEditText: FunctionComponent = ({ startWithEditOpen, readModeProps, editModeProps, - isLoading = false, + isLoading, isInvalid = false, ...rest }) => { diff --git a/src/components/inline_edit/inline_edit_title.test.tsx b/src/components/inline_edit/inline_edit_title.test.tsx index 9104347af45..84363d4fd11 100644 --- a/src/components/inline_edit/inline_edit_title.test.tsx +++ b/src/components/inline_edit/inline_edit_title.test.tsx @@ -10,21 +10,44 @@ import React from 'react'; import { render } from '../../test/rtl'; import { requiredProps } from '../../test/required_props'; -import { EuiInlineEditTitle } from './inline_edit_title'; +import { + EuiInlineEditTitle, + EuiInlineEditTitleProps, +} from './inline_edit_title'; +import { TITLE_SIZES } from '../title/title'; describe('EuiInlineEditTitle', () => { - describe('props', () => { - test('renders as title', () => { - const { container } = render( - - ); - - expect(container.firstChild).toMatchSnapshot(); + const inlineEditTitleProps: EuiInlineEditTitleProps = { + ...requiredProps, + inputAriaLabel: 'Edit title inline', + defaultValue: 'Hello World!', + heading: 'h1', + }; + + it('renders', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders the heading prop', () => { + const { container } = render( + + ); + expect(container.querySelector('h3')).toBeTruthy(); + }); + + describe('title sizes', () => { + TITLE_SIZES.forEach((size) => { + it(`renders size ${size}`, () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); }); }); }); diff --git a/src/components/inline_edit/inline_edit_title.tsx b/src/components/inline_edit/inline_edit_title.tsx index a35ee2f8ef2..be663fa1c61 100644 --- a/src/components/inline_edit/inline_edit_title.tsx +++ b/src/components/inline_edit/inline_edit_title.tsx @@ -45,7 +45,7 @@ export const EuiInlineEditTitle: FunctionComponent = ({ startWithEditOpen = false, readModeProps, editModeProps, - isLoading = false, + isLoading, isInvalid = false, ...rest }) => {