diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx index e9ef3b3cfee283..9b5d6090f2f8a7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -24,6 +24,7 @@ import { import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Schema, SchemaType } from '../../../shared/schema/types'; +import { InlineEditableTable } from '../../../shared/tables/inline_editable_table'; import { ReorderableTable } from '../../../shared/tables/reorderable_table'; import { Result } from '../result'; @@ -31,6 +32,16 @@ const NO_ITEMS = ( No Items} body={

No Items

} /> ); +// For the InlineEditableTable +// Since InlineEditableTable caches handlers, we need to store this globally somewhere rather than just in useState +interface Foo { + id: number; + foo: string; + bar: string; +} +let globalItems: Foo[] = []; +const getLastItems = () => globalItems; + export const Library: React.FC = () => { const props = { isMetaEngine: false, @@ -91,6 +102,67 @@ export const Library: React.FC = () => { }, ]; + // For the InlineEditableTable + const [items, setItems] = useState([ + { id: 1, foo: 'foo1', bar: '10' }, + { id: 2, foo: 'foo2', bar: '10' }, + ]); + globalItems = items; + const columns = [ + { + field: 'foo', + name: 'Foo', + render: (item: Foo) =>
{item.foo}
, + editingRender: (item: Foo, onChange: (value: string) => void) => ( + onChange(e.target.value)} /> + ), + }, + { + field: 'bar', + name: 'Bar (Must be a number)', + render: (item: Foo) =>
{item.bar}
, + editingRender: (item: Foo, onChange: (value: string) => void) => ( + onChange(e.target.value)} /> + ), + }, + ]; + const onAdd = (item: Foo, onSuccess: () => void) => { + const highestId = Math.max(...getLastItems().map((i) => i.id)); + setItems([ + ...getLastItems(), + { + ...item, + id: highestId + 1, + }, + ]); + onSuccess(); + }; + const onDelete = (item: Foo) => { + setItems(getLastItems().filter((i) => i.id !== item.id)); + }; + const onUpdate = (item: Foo, onSuccess: () => void) => { + setItems( + getLastItems().map((i) => { + if (item.id === i.id) return item; + return i; + }) + ); + onSuccess(); + }; + const validateItem = (item: Foo) => { + let isValidNumber = false; + const num = parseInt(item.bar, 10); + if (!isNaN(num)) isValidNumber = true; + + if (isValidNumber) return {}; + return { + bar: 'Bar must be a valid number', + }; + }; + const onReorder = (newItems: Foo[]) => { + setItems(newItems); + }; + return ( <> @@ -353,8 +425,99 @@ export const Library: React.FC = () => { { name: 'Whatever', render: (item) =>
Whatever
}, ]} /> + + +

InlineEditableTable

+
+ + +

With uneditable items

+
+ + + + + +

Can delete last item

+
+ + { + return ( + No Items} + body={} + /> + ); + }} + /> + + + +

Cannot delete last item

+
+ + + + + +

When isLoading is true

+
+ + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.test.tsx new file mode 100644 index 00000000000000..6328b01cd2be7a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.test.tsx @@ -0,0 +1,193 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { ActionColumn } from './action_column'; + +const requiredParams = { + displayedItems: [], + isActivelyEditing: () => false, + item: { id: 1 }, +}; + +describe('ActionColumn', () => { + const mockValues = { + doesEditingItemValueContainEmptyProperty: false, + editingItemId: 1, + formErrors: [], + isEditing: false, + isEditingUnsavedItem: false, + }; + const mockActions = { + editExistingItem: jest.fn(), + deleteItem: jest.fn(), + doneEditing: jest.fn(), + saveExistingItem: jest.fn(), + saveNewItem: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(mockValues); + setMockActions(mockActions); + }); + + it('renders', () => { + const wrapper = shallow( + false} + isLoading={false} + item={{ id: 1 }} + canRemoveLastItem={false} + lastItemWarning="I am a warning" + uneditableItems={[]} + /> + ); + expect(wrapper.isEmptyRender()).toBe(false); + }); + + it('renders nothing if the item is an uneditableItem', () => { + const item = { id: 1 }; + const wrapper = shallow( + false} + isLoading={false} + item={item} + canRemoveLastItem={false} + lastItemWarning="I am a warning" + uneditableItems={[item]} + /> + ); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + describe('when the user is actively editing', () => { + const isActivelyEditing = () => true; + const activelyEditingParams = { + ...requiredParams, + isActivelyEditing, + }; + + describe('it renders a save button', () => { + const subject = (wrapper: ShallowWrapper) => wrapper.find('[data-test-subj="saveButton"]'); + + it('which is disabled if data is loading', () => { + const wrapper = shallow(); + expect(subject(wrapper).prop('disabled')).toBe(true); + }); + + it('which is disabled if there are form errors', () => { + setMockValues({ + ...mockValues, + formErrors: ['I am an error'], + }); + + const wrapper = shallow(); + expect(subject(wrapper).prop('disabled')).toBe(true); + }); + + it('which is disabled if the item value contains an empty property', () => { + setMockValues({ + ...mockValues, + doesEditingItemValueContainEmptyProperty: true, + }); + + const wrapper = shallow(); + expect(subject(wrapper).prop('disabled')).toBe(true); + }); + + it('which calls saveNewItem when clicked if the user is editing an unsaved item', () => { + setMockValues({ + ...mockValues, + isEditingUnsavedItem: true, + }); + + const wrapper = shallow(); + subject(wrapper).simulate('click'); + expect(mockActions.saveNewItem).toHaveBeenCalled(); + }); + + it('which calls saveExistingItem when clicked if the user is NOT editing an unsaved item', () => { + setMockValues({ + ...mockValues, + isEditingUnsavedItem: false, + }); + + const wrapper = shallow(); + subject(wrapper).simulate('click'); + expect(mockActions.saveExistingItem).toHaveBeenCalled(); + }); + }); + + describe('it renders a cancel button', () => { + const subject = (wrapper: ShallowWrapper) => wrapper.find('[data-test-subj="cancelButton"]'); + + it('which is disabled if data is loading', () => { + const wrapper = shallow(); + expect(subject(wrapper).prop('disabled')).toBe(true); + }); + + it('which calls doneEditing when clicked', () => { + const wrapper = shallow(); + subject(wrapper).simulate('click'); + expect(mockActions.doneEditing).toHaveBeenCalled(); + }); + }); + }); + + describe('when the user is NOT actively editing', () => { + const item = { id: 2 }; + + const mockValuesWhereUserIsNotActivelyEditing = { + ...mockValues, + isEditing: false, + }; + + beforeEach(() => { + setMockValues(mockValuesWhereUserIsNotActivelyEditing); + }); + + describe('it renders an edit button', () => { + const subject = (wrapper: ShallowWrapper) => wrapper.find('[data-test-subj="editButton"]'); + + it('which calls editExistingItem when clicked', () => { + const wrapper = shallow(); + subject(wrapper).simulate('click'); + expect(mockActions.editExistingItem).toHaveBeenCalledWith(item); + }); + }); + + describe('it renders an delete button', () => { + const subject = (wrapper: ShallowWrapper) => wrapper.find('[data-test-subj="deleteButton"]'); + + it('which calls deleteItem when clicked', () => { + const wrapper = shallow(); + subject(wrapper).simulate('click'); + expect(mockActions.deleteItem).toHaveBeenCalledWith(item); + }); + + it('which does not render if candRemoveLastItem is prevented and this is the last item', () => { + const wrapper = shallow( + + ); + expect(subject(wrapper).exists()).toBe(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.tsx new file mode 100644 index 00000000000000..fe0ada8a2f914c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.tsx @@ -0,0 +1,113 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; + +import { + CANCEL_BUTTON_LABEL, + DELETE_BUTTON_LABEL, + EDIT_BUTTON_LABEL, + SAVE_BUTTON_LABEL, +} from '../../constants'; + +import { InlineEditableTableLogic } from './inline_editable_table_logic'; +import { ItemWithAnID } from './types'; + +interface ActionColumnProps { + displayedItems: Item[]; + isActivelyEditing: (i: Item) => boolean; + isLoading?: boolean; + item: Item; + canRemoveLastItem?: boolean; + lastItemWarning?: string; + uneditableItems?: Item[]; +} + +export const ActionColumn = ({ + displayedItems, + isActivelyEditing, + isLoading = false, + item, + canRemoveLastItem, + lastItemWarning, + uneditableItems, +}: ActionColumnProps) => { + const { doesEditingItemValueContainEmptyProperty, formErrors, isEditingUnsavedItem } = useValues( + InlineEditableTableLogic + ); + const { editExistingItem, deleteItem, doneEditing, saveExistingItem, saveNewItem } = useActions( + InlineEditableTableLogic + ); + + if (uneditableItems?.includes(item)) { + return null; + } + + if (isActivelyEditing(item)) { + return ( + + + 0 || + doesEditingItemValueContainEmptyProperty + } + > + {SAVE_BUTTON_LABEL} + + + + + {CANCEL_BUTTON_LABEL} + + + + ); + } + + return ( + + + editExistingItem(item)} + > + {EDIT_BUTTON_LABEL} + + + + {!canRemoveLastItem && displayedItems.length === 1 ? ( + + + {DELETE_BUTTON_LABEL} + + + ) : ( + deleteItem(item)}> + {DELETE_BUTTON_LABEL} + + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/constants.ts new file mode 100644 index 00000000000000..43a5a5b1df9e6a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/constants.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EMPTY_ITEM = { id: null }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/editing_column.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/editing_column.test.tsx new file mode 100644 index 00000000000000..43ced1bd87492e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/editing_column.test.tsx @@ -0,0 +1,135 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiFormRow } from '@elastic/eui'; + +import { EditingColumn } from './editing_column'; + +describe('EditingColumn', () => { + const column = { + name: 'foo', + field: 'foo', + render: jest.fn(), + editingRender: jest.fn().mockReturnValue(
), + }; + + const requiredProps = { + column, + }; + + const mockValues = { + formErrors: [], + editingItemValue: { id: 1 }, + }; + + const mockActions = { + setEditingItemValue: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(mockActions); + setMockValues(mockValues); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.isEmptyRender()).toBe(false); + }); + + describe('when there is a form error for this field', () => { + let wrapper: ShallowWrapper; + beforeEach(() => { + setMockValues({ + ...mockValues, + formErrors: { + foo: 'I am an error for foo and should be displayed', + }, + }); + + wrapper = shallow( + + ); + }); + + it('renders form errors for this field if any are present', () => { + expect(shallow(wrapper.find(EuiFormRow).prop('helpText') as any).html()).toContain( + 'I am an error for foo and should be displayed' + ); + }); + + it('renders as invalid', () => { + expect(wrapper.find(EuiFormRow).prop('isInvalid')).toBe(true); + }); + }); + + it('renders nothing if there is no editingItemValue in state', () => { + setMockValues({ + ...mockValues, + editingItemValue: null, + }); + + const wrapper = shallow(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders the column\'s "editing" view (editingRender)', () => { + setMockValues({ + ...mockValues, + editingItemValue: { id: 1, foo: 'foo', bar: 'bar' }, + formErrors: { foo: ['I am an error for foo'] }, + }); + + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="editing-view"]').exists()).toBe(true); + + expect(column.editingRender).toHaveBeenCalled(); + // The render function is provided with the item currently being edited for rendering + expect(column.editingRender.mock.calls[0][0]).toEqual({ id: 1, foo: 'foo', bar: 'bar' }); + + // The render function is provided with a callback function to save the value once editing is finished + const callback = column.editingRender.mock.calls[0][1]; + callback('someNewValue'); + expect(mockActions.setEditingItemValue).toHaveBeenCalledWith({ + id: 1, + // foo is the 'field' this column is associated with, so that field is updated with the new value + foo: 'someNewValue', + bar: 'bar', + }); + + // The render function is provided with additional properties + expect(column.editingRender.mock.calls[0][2]).toEqual({ + isInvalid: true, // Because there errors for 'foo' + isLoading: true, // Because isLoading was passed as true to this prop + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/editing_column.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/editing_column.tsx new file mode 100644 index 00000000000000..43a1d5de9b44f4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/editing_column.tsx @@ -0,0 +1,58 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiFormRow, EuiText } from '@elastic/eui'; + +import { InlineEditableTableLogic } from './inline_editable_table_logic'; +import { InlineEditableTableColumn, ItemWithAnID } from './types'; + +interface EditingColumnProps { + column: InlineEditableTableColumn; + isLoading?: boolean; +} + +export const EditingColumn = ({ + column, + isLoading = false, +}: EditingColumnProps) => { + const { formErrors, editingItemValue } = useValues(InlineEditableTableLogic); + const { setEditingItemValue } = useActions(InlineEditableTableLogic); + + if (!editingItemValue) return null; + + return ( + + {formErrors[column.field]} + + } + isInvalid={!!formErrors[column.field]} + > + <> + {column.editingRender( + editingItemValue as Item, // TODO we shouldn't need to cast this? + (newValue) => { + setEditingItemValue({ + ...editingItemValue, + [column.field]: newValue, + }); + }, + { + isInvalid: !!formErrors[column.field], + isLoading, + } + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.test.tsx new file mode 100644 index 00000000000000..6fccdfd327df4f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.test.tsx @@ -0,0 +1,131 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { Column } from '../reorderable_table/types'; + +import { ActionColumn } from './action_column'; +import { EditingColumn } from './editing_column'; +import { getUpdatedColumns } from './get_updated_columns'; +import { InlineEditableTableColumn } from './types'; + +interface Foo { + id: number; +} + +describe('getUpdatedColumns', () => { + const displayedItems: Foo[] = []; + const canRemoveLastItem = true; + const lastItemWarning = 'I am a warning'; + const uneditableItems: Foo[] = []; + const item = { id: 1 }; + + describe('it takes an array of InlineEditableTableColumn columns and turns them into ReorderableTable Columns', () => { + const columns: Array> = [ + { + name: 'Foo', + editingRender: jest.fn(), + render: jest.fn(), + field: 'foo', + }, + { + name: 'Bar', + editingRender: jest.fn(), + render: jest.fn(), + field: 'bar', + }, + ]; + let newColumns: Array> = []; + + beforeAll(() => { + newColumns = getUpdatedColumns({ + columns, + displayedItems, + canRemoveLastItem, + lastItemWarning, + uneditableItems, + isActivelyEditing: () => true, + }); + }); + + it('converts the columns to Column objects', () => { + expect(newColumns[0]).toEqual({ + name: 'Foo', + render: expect.any(Function), + }); + expect(newColumns[1]).toEqual({ + name: 'Bar', + render: expect.any(Function), + }); + }); + + it('appends an action column at the end', () => { + expect(newColumns[2]).toEqual({ + flexBasis: '200px', + flexGrow: 0, + render: expect.any(Function), + }); + + const renderResult = newColumns[2].render(item); + const wrapper = shallow(
{renderResult}
); + const actionColumn = wrapper.find(ActionColumn); + expect(actionColumn.props()).toEqual({ + isActivelyEditing: expect.any(Function), + displayedItems, + isLoading: false, + canRemoveLastItem, + lastItemWarning, + uneditableItems, + item, + }); + }); + }); + + describe("the converted column's render prop", () => { + const columns: Array> = [ + { + name: 'Foo', + editingRender: jest.fn(), + render: jest.fn(), + field: 'foo', + }, + ]; + + it("renders with the passed column's editingRender function when the user is actively editing", () => { + const newColumns = getUpdatedColumns({ + columns, + displayedItems, + isLoading: true, + isActivelyEditing: () => true, + }); + + const renderResult = newColumns[0].render(item); + const wrapper = shallow(
{renderResult}
); + const column = wrapper.find(EditingColumn); + expect(column.props()).toEqual({ + column: columns[0], + isLoading: true, + }); + }); + + it("renders with the passed column's render function when the user is NOT actively editing", () => { + const newColumns = getUpdatedColumns({ + columns, + displayedItems, + isActivelyEditing: () => false, + }); + + newColumns[0].render(item); + expect(columns[0].render).toHaveBeenCalledWith(item); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.tsx new file mode 100644 index 00000000000000..8a61e12442f7cd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.tsx @@ -0,0 +1,64 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { Column } from '../reorderable_table/types'; + +import { ActionColumn } from './action_column'; +import { EditingColumn } from './editing_column'; +import { ItemWithAnID, InlineEditableTableColumn } from './types'; + +interface GetUpdatedColumnProps { + columns: Array>; + displayedItems: Item[]; + isActivelyEditing: (item: Item) => boolean; + canRemoveLastItem?: boolean; + isLoading?: boolean; + lastItemWarning?: string; + uneditableItems?: Item[]; +} + +export const getUpdatedColumns = ({ + columns, + displayedItems, + isActivelyEditing, + canRemoveLastItem, + isLoading = false, + lastItemWarning, + uneditableItems, +}: GetUpdatedColumnProps): Array> => { + return [ + ...columns.map((column) => { + const newColumn: Column = { + name: column.name, + render: (item: Item) => { + if (isActivelyEditing(item)) { + return ; + } + return column.render(item); + }, + }; + return newColumn; + }), + { + flexBasis: '200px', + flexGrow: 0, + render: (item: Item) => ( + + ), + }, + ]; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/index.ts new file mode 100644 index 00000000000000..b55fd0df83ed05 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/index.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { InlineEditableTable } from './inline_editable_table'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table.test.tsx new file mode 100644 index 00000000000000..ab59616e9ce786 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table.test.tsx @@ -0,0 +1,186 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; +import { BindLogic } from 'kea'; + +import { ReorderableTable } from '../reorderable_table'; + +jest.mock('./get_updated_columns', () => ({ + getUpdatedColumns: jest.fn(), +})); +import { getUpdatedColumns } from './get_updated_columns'; + +import { InlineEditableTable, InlineEditableTableContents } from './inline_editable_table'; +import { InlineEditableTableLogic } from './inline_editable_table_logic'; + +const items = [{ id: 1 }, { id: 2 }]; +const requiredParams = { + columns: [], + items, + title: 'Some Title', +}; + +interface Foo { + id: number; +} + +describe('InlineEditableTable', () => { + const mockValues = {}; + const mockActions = {}; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(mockValues); + setMockActions(mockActions); + }); + + it('wraps the table in a bound logic, and passes through only required props to the underlying component', () => { + const instanceId = 'MyInstance'; + const onAdd = jest.fn(); + const onDelete = jest.fn(); + const onReorder = jest.fn(); + const onUpdate = jest.fn(); + const transformItem = jest.fn(); + const validateItem = jest.fn(); + const wrapper = shallow( + + ); + const bindLogic = wrapper.find(BindLogic); + expect(bindLogic.props()).toEqual( + expect.objectContaining({ + logic: InlineEditableTableLogic, + props: { + columns: requiredParams.columns, + instanceId, + onAdd, + onDelete, + onReorder, + onUpdate, + transformItem, + validateItem, + }, + }) + ); + + expect(bindLogic.children().props()).toEqual(requiredParams); + }); + + it('renders a ReorderableTable', () => { + const wrapper = shallow(); + const reorderableTable = wrapper.find(ReorderableTable); + expect(reorderableTable.exists()).toBe(true); + expect(reorderableTable.prop('items')).toEqual(items); + expect(wrapper.find('[data-test-subj="actionButton"]').children().text()).toEqual('New row'); + }); + + it('renders a description if one is provided', () => { + const wrapper = shallow( + Some Description

} /> + ); + expect(wrapper.find('[data-test-subj="description"]').exists()).toBe(true); + }); + + it('can specify items in the table that are uneditable', () => { + const uneditableItems = [{ id: 3 }]; + const wrapper = shallow( + + ); + expect(wrapper.find(ReorderableTable).prop('unreorderableItems')).toBe(uneditableItems); + }); + + it('can apply an additional className', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('.editableTable.myTestClassName').exists()).toBe(true); + }); + + it('will use the value of addButtonText as custom text on the New Row button', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="actionButton"]').children().text()).toEqual( + 'Add a new row custom text' + ); + }); + + describe('when a user is editing an unsaved item', () => { + beforeEach(() => setMockValues({ ...mockValues, isEditingUnsavedItem: true })); + + it('will change the displayed items to END with an empty item', () => { + const wrapper = shallow(); + expect(wrapper.find(ReorderableTable).prop('items')).toEqual([...items, { id: null }]); + }); + + it('will change the displayed items to START with an empty item when there are uneditableItems', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(ReorderableTable).prop('items')).toEqual([{ id: null }, ...items]); + }); + }); + + it('will style the row that is currently being edited', () => { + setMockValues({ ...mockValues, isEditing: true, editingItemId: 2 }); + const itemList = [{ id: 1 }, { id: 2 }]; + const wrapper = shallow(); + const rowProps = wrapper.find(ReorderableTable).prop('rowProps') as (item: any) => object; + expect(rowProps(items[0])).toEqual({ className: '' }); + // Since editingItemId is 2 and the second item (position 1) in item list has an id of 2, it gets this class + expect(rowProps(items[1])).toEqual({ className: 'is-being-edited' }); + }); + + it('will update the passed columns and pass them through to the underlying table', () => { + const updatedColumns = {}; + const canRemoveLastItem = true; + const isLoading = true; + const lastItemWarning = 'A warning'; + const uneditableItems: Foo[] = []; + + (getUpdatedColumns as jest.Mock).mockReturnValue(updatedColumns); + const wrapper = shallow( + + ); + const columns = wrapper.find(ReorderableTable).prop('columns'); + expect(columns).toEqual(updatedColumns); + + expect(getUpdatedColumns).toHaveBeenCalledWith({ + columns: requiredParams.columns, + displayedItems: items, + isActivelyEditing: expect.any(Function), + canRemoveLastItem, + isLoading, + lastItemWarning, + uneditableItems, + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table.tsx new file mode 100644 index 00000000000000..fd7aac957b234c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table.tsx @@ -0,0 +1,174 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import classNames from 'classnames'; + +import { useActions, useValues, BindLogic } from 'kea'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { ReorderableTable } from '../reorderable_table'; + +import { EMPTY_ITEM } from './constants'; +import { getUpdatedColumns } from './get_updated_columns'; +import { InlineEditableTableLogic } from './inline_editable_table_logic'; +import { FormErrors, InlineEditableTableColumn, ItemWithAnID } from './types'; + +import './inline_editable_tables.scss'; + +interface InlineEditableTableProps { + columns: Array>; + items: Item[]; + title: string; + addButtonText?: string; + canRemoveLastItem?: boolean; + className?: string; + description?: React.ReactNode; + isLoading?: boolean; + lastItemWarning?: string; + noItemsMessage?: (editNewItem: () => void) => React.ReactNode; + uneditableItems?: Item[]; +} + +export const InlineEditableTable = ( + props: InlineEditableTableProps & { + instanceId: string; + onAdd(item: Item, onSuccess: () => void): void; + onDelete(item: Item, onSuccess: () => void): void; + onReorder?(items: Item[], oldItems: Item[], onSuccess: () => void): void; + onUpdate(item: Item, onSuccess: () => void): void; + transformItem?(item: Item): Item; + validateItem?(item: Item): FormErrors; + } +) => { + const { + instanceId, + columns, + onAdd, + onDelete, + onReorder, + onUpdate, + transformItem, + validateItem, + ...rest + } = props; + return ( + + + + ); +}; + +export const InlineEditableTableContents = ({ + columns, + items, + title, + addButtonText, + canRemoveLastItem, + className, + description, + isLoading, + lastItemWarning, + noItemsMessage = () => null, + uneditableItems, + ...rest +}: InlineEditableTableProps) => { + const { editingItemId, isEditing, isEditingUnsavedItem } = useValues(InlineEditableTableLogic); + const { editNewItem, reorderItems } = useActions(InlineEditableTableLogic); + + // TODO These two things shoud just be selectors + const isEditingItem = (item: Item) => item.id === editingItemId; + const isActivelyEditing = (item: Item) => isEditing && isEditingItem(item); + + const displayedItems = isEditingUnsavedItem + ? uneditableItems + ? [EMPTY_ITEM, ...items] + : [...items, EMPTY_ITEM] + : items; + + const updatedColumns = getUpdatedColumns({ + columns, + // TODO We shouldn't need this cast here + displayedItems: displayedItems as Item[], + isActivelyEditing, + canRemoveLastItem, + isLoading, + lastItemWarning, + uneditableItems, + }); + + return ( + <> + + + +

{title}

+
+ {!!description && ( + <> + + + {description} + + + )} +
+ + + {addButtonText || + i18n.translate('xpack.enterpriseSearch.inlineEditableTable.newRowButtonLabel', { + defaultMessage: 'New row', + })} + + +
+ + ({ + className: classNames({ + 'is-being-edited': isActivelyEditing(item), + }), + })} + noItemsMessage={noItemsMessage(editNewItem)} + onReorder={reorderItems} + disableDragging={isEditing} + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table_logic.test.ts new file mode 100644 index 00000000000000..f690a38620ecba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table_logic.test.ts @@ -0,0 +1,434 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter } from '../../../__mocks__/kea_logic'; + +import { omit } from 'lodash'; + +import { InlineEditableTableLogic } from './inline_editable_table_logic'; + +interface Foo { + id: number; + foo: string; + bar: string; +} + +describe('InlineEditableTableLogic', () => { + const { mount } = new LogicMounter(InlineEditableTableLogic); + + const DEFAULT_VALUES = { + editingItemId: null, + editingItemValue: null, + formErrors: {}, + isEditing: false, + }; + + const SELECTORS = { + doesEditingItemValueContainEmptyProperty: false, + isEditingUnsavedItem: false, + }; + + // Values without selectors + const logicValuesWithoutSelectors = (logic: any) => omit(logic.values, Object.keys(SELECTORS)); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const DEFAULT_LOGIC_PARAMS = { + instanceId: '1', + columns: [ + { + field: 'foo', + render: jest.fn(), + editingRender: jest.fn(), + }, + { + field: 'bar', + render: jest.fn(), + editingRender: jest.fn(), + }, + ], + onAdd: jest.fn(), + onDelete: jest.fn(), + onReorder: jest.fn(), + onUpdate: jest.fn(), + transformItem: jest.fn(), + validateItem: jest.fn(), + }; + + const mountLogic = (values: object = {}, params: object = DEFAULT_LOGIC_PARAMS) => + mount(values, params); + + it('has expected default values', () => { + const logic = mountLogic(); + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + ...SELECTORS, + }); + }); + + describe('actions', () => { + describe('deleteItem', () => { + const logic = mountLogic(); + logic.actions.deleteItem(); + expect(logicValuesWithoutSelectors(logic)).toEqual(DEFAULT_VALUES); + }); + + describe('doneEditing', () => { + it('resets a bunch of values', () => { + const logic = mountLogic({ + isEditing: true, + editingItemId: 1, + editingItemValue: {}, + formErrors: { foo: 'I am error' }, + }); + logic.actions.doneEditing(); + expect(logicValuesWithoutSelectors(logic)).toEqual(DEFAULT_VALUES); + }); + }); + + describe('editNewItem', () => { + it('updates state to reflect a new item being edited', () => { + const logic = mountLogic({ + isEditing: false, + editingItemId: 1, + editingItemValue: { + id: 1, + foo: 'some foo', + bar: 'some bar', + }, + }); + logic.actions.editNewItem(); + expect(logicValuesWithoutSelectors(logic)).toEqual({ + ...DEFAULT_VALUES, + isEditing: true, + editingItemId: null, + editingItemValue: { + // Note that new values do not yet have an id + foo: '', + bar: '', + }, + }); + }); + }); + + describe('editExistingItem', () => { + it('updates state to reflect the item that was passed being edited', () => { + const logic = mountLogic({ + isEditing: false, + editingItemId: 1, + editingItemValue: { + id: 1, + foo: '', + bar: '', + }, + }); + logic.actions.editExistingItem({ + id: 2, + foo: 'existing foo', + bar: 'existing bar', + }); + expect(logicValuesWithoutSelectors(logic)).toEqual({ + ...DEFAULT_VALUES, + isEditing: true, + editingItemId: 2, + editingItemValue: { + id: 2, + foo: 'existing foo', + bar: 'existing bar', + }, + }); + }); + }); + + describe('setFormErrors', () => { + it('sets formErrors', () => { + const formErrors = { + bar: 'I am an error', + }; + const logic = mountLogic(); + logic.actions.setFormErrors(formErrors); + expect(logicValuesWithoutSelectors(logic)).toEqual({ + ...DEFAULT_VALUES, + formErrors, + }); + }); + }); + + describe('setEditingItemValue', () => { + it('updates the state of the item currently being edited and resets form errors', () => { + const logic = mountLogic({ + editingItemValue: { + id: 1, + foo: '', + bar: '', + }, + formErrors: { foo: 'I am error' }, + }); + logic.actions.setEditingItemValue({ + id: 1, + foo: 'blah blah', + bar: '', + }); + expect(logicValuesWithoutSelectors(logic)).toEqual({ + ...DEFAULT_VALUES, + editingItemValue: { + id: 1, + foo: 'blah blah', + bar: '', + }, + formErrors: {}, + }); + }); + }); + }); + + describe('selectors', () => { + describe('isEditingUnsavedItem', () => { + it('is true when the user is currently editing an unsaved item', () => { + const logic = mountLogic({ + isEditing: true, + editingItemId: null, + }); + + expect(logic.values.isEditingUnsavedItem).toBe(true); + }); + + it('is false when the user is NOT currently editing an unsaved item', () => { + const logic = mountLogic({ + isEditing: true, + editingItemId: 1, + }); + + expect(logic.values.isEditingUnsavedItem).toBe(false); + }); + }); + + describe('doesEditingItemValueContainEmptyProperty', () => { + it('is true when the user is currently editing an item that has empty properties', () => { + const logic = mountLogic({ + isEditing: true, + editingItemValue: { + id: 1, + foo: '', + }, + editingItemId: 1, + }); + + expect(logic.values.doesEditingItemValueContainEmptyProperty).toBe(true); + }); + + it('is false when no properties are empty', () => { + const logic = mountLogic({ + isEditing: true, + editingItemValue: { + id: 1, + foo: 'foo', + }, + editingItemId: 1, + }); + + expect(logic.values.doesEditingItemValueContainEmptyProperty).toBe(false); + }); + + it('is false when the user is not editing anything', () => { + const logic = mountLogic({ + isEditing: true, + editingItemValue: null, + editingItemId: null, + }); + + expect(logic.values.doesEditingItemValueContainEmptyProperty).toBe(false); + }); + }); + }); + + describe('listeners', () => { + describe('reorderItems', () => { + it('will call the provided onReorder callback', () => { + const items: Foo[] = []; + const oldItems: Foo[] = []; + const logic = mountLogic(); + logic.actions.reorderItems(items, oldItems); + expect(DEFAULT_LOGIC_PARAMS.onReorder).toHaveBeenCalledWith( + items, + oldItems, + expect.any(Function) + ); + }); + + it('will not call the onReorder callback if one was not provided', () => { + const items: Foo[] = []; + const oldItems: Foo[] = []; + const logic = mountLogic( + {}, + { + ...DEFAULT_LOGIC_PARAMS, + onReorder: undefined, + } + ); + logic.actions.reorderItems(items, oldItems); + }); + }); + + describe('saveExistingItem', () => { + it('will call the provided onUpdate callback if the item being edited validates', () => { + const editingItemValue = {}; + DEFAULT_LOGIC_PARAMS.validateItem.mockReturnValue({}); + const logic = mountLogic({ + ...DEFAULT_VALUES, + editingItemValue, + }); + logic.actions.saveExistingItem(); + expect(DEFAULT_LOGIC_PARAMS.onUpdate).toHaveBeenCalledWith( + editingItemValue, + expect.any(Function) + ); + }); + + it('will set form errors and not call the provided onUpdate callback if the item being edited does not validate', () => { + const editingItemValue = {}; + const formErrors = { + foo: 'some error', + }; + DEFAULT_LOGIC_PARAMS.validateItem.mockReturnValue(formErrors); + const logic = mountLogic({ + ...DEFAULT_VALUES, + editingItemValue, + }); + jest.spyOn(logic.actions, 'setFormErrors'); + logic.actions.saveExistingItem(); + expect(DEFAULT_LOGIC_PARAMS.onUpdate).not.toHaveBeenCalled(); + expect(logic.actions.setFormErrors).toHaveBeenCalledWith(formErrors); + }); + + it('will do neither if no value is currently being edited', () => { + const editingItemValue = null; + const logic = mountLogic({ + ...DEFAULT_VALUES, + editingItemValue, + }); + jest.spyOn(logic.actions, 'setFormErrors'); + logic.actions.saveExistingItem(); + expect(DEFAULT_LOGIC_PARAMS.onUpdate).not.toHaveBeenCalled(); + expect(logic.actions.setFormErrors).not.toHaveBeenCalled(); + }); + + it('will always call the provided onUpdate callback if no validateItem param was provided', () => { + const editingItemValue = {}; + const logic = mountLogic( + { + ...DEFAULT_VALUES, + editingItemValue, + }, + { + ...DEFAULT_LOGIC_PARAMS, + validateItem: undefined, + } + ); + logic.actions.saveExistingItem(); + expect(DEFAULT_LOGIC_PARAMS.onUpdate).toHaveBeenCalledWith( + editingItemValue, + expect.any(Function) + ); + }); + }); + + describe('saveNewItem', () => { + it('will call the provided onAdd callback if the new item validates', () => { + const editingItemValue = {}; + DEFAULT_LOGIC_PARAMS.validateItem.mockReturnValue({}); + const logic = mountLogic( + { + ...DEFAULT_VALUES, + editingItemValue, + }, + { + ...DEFAULT_LOGIC_PARAMS, + transformItem: undefined, + } + ); + logic.actions.saveNewItem(); + expect(DEFAULT_LOGIC_PARAMS.onAdd).toHaveBeenCalledWith( + editingItemValue, + expect.any(Function) + ); + }); + + it('will transform the item first if transformItem callback is provided', () => { + const editingItemValue = {}; + const transformedItem = {}; + DEFAULT_LOGIC_PARAMS.validateItem.mockReturnValue({}); + DEFAULT_LOGIC_PARAMS.transformItem.mockReturnValue(transformedItem); + const logic = mountLogic({ + ...DEFAULT_VALUES, + editingItemValue, + }); + logic.actions.saveNewItem(); + expect(DEFAULT_LOGIC_PARAMS.onAdd).toHaveBeenCalledWith( + transformedItem, + expect.any(Function) + ); + }); + + it('will set form errors and not call the provided onAdd callback if the item being edited does not validate', () => { + const editingItemValue = {}; + const formErrors = { + foo: 'some error', + }; + DEFAULT_LOGIC_PARAMS.validateItem.mockReturnValue(formErrors); + const logic = mountLogic({ + ...DEFAULT_VALUES, + editingItemValue, + }); + jest.spyOn(logic.actions, 'setFormErrors'); + logic.actions.saveNewItem(); + expect(DEFAULT_LOGIC_PARAMS.onAdd).not.toHaveBeenCalled(); + expect(logic.actions.setFormErrors).toHaveBeenCalledWith(formErrors); + }); + + it('will do nothing if no value is currently being edited', () => { + const editingItemValue = null; + const logic = mountLogic({ + ...DEFAULT_VALUES, + editingItemValue, + }); + jest.spyOn(logic.actions, 'setFormErrors'); + logic.actions.saveNewItem(); + expect(DEFAULT_LOGIC_PARAMS.onAdd).not.toHaveBeenCalled(); + expect(logic.actions.setFormErrors).not.toHaveBeenCalled(); + }); + + it('will always call the provided onAdd callback if no validateItem param was provided', () => { + const editingItemValue = {}; + const logic = mountLogic( + { + ...DEFAULT_VALUES, + editingItemValue, + }, + { + ...DEFAULT_LOGIC_PARAMS, + validateItem: undefined, + transformItem: undefined, + } + ); + logic.actions.saveNewItem(); + expect(DEFAULT_LOGIC_PARAMS.onAdd).toHaveBeenCalledWith( + editingItemValue, + expect.any(Function) + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table_logic.ts new file mode 100644 index 00000000000000..ab6694861a6ad6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table_logic.ts @@ -0,0 +1,168 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { FormErrors, InlineEditableTableColumn, ItemWithAnID } from './types'; + +interface InlineEditableTableActions { + deleteItem(item: Item): { item: Item }; + doneEditing(): void; + editNewItem(): void; + editExistingItem(item: Item): { item: Item }; + reorderItems(items: Item[], oldItems: Item[]): { items: Item[]; oldItems: Item[] }; + saveExistingItem(): void; + saveNewItem(): void; + setEditingItemValue(newValue: Item): { item: Item }; + setFormErrors(formErrors: FormErrors): { formErrors: FormErrors }; +} + +const generateEmptyItem = ( + columns: Array> +): Item => { + const emptyItem = columns.reduce((acc, column) => ({ ...acc, [column.field]: '' }), {}) as Item; + return emptyItem; +}; + +const getUnsavedItemId = () => null; +const doesIdMatchUnsavedId = (idToCheck: number) => idToCheck === getUnsavedItemId(); + +interface InlineEditableTableValues { + // TODO This could likely be a selector + isEditing: boolean; + // TODO we should editingItemValue have editingItemValue and editingItemId should be a selector + editingItemId: Item['id'] | null; // editingItem is null when the user is editing a new but not saved item + editingItemValue: Item | null; + formErrors: FormErrors; + isEditingUnsavedItem: boolean; + doesEditingItemValueContainEmptyProperty: boolean; +} + +interface InlineEditableTableProps { + columns: Array>; + instanceId: string; + // TODO Because these callbacks are params, they are only set on the logic once (i.e., they are cached) + // which makes using "useState" to back this really hard. + onAdd(item: Item, onSuccess: () => void): void; + onDelete(item: Item, onSuccess: () => void): void; + onReorder?(items: Item[], oldItems: Item[], onSuccess: () => void): void; + onUpdate(item: Item, onSuccess: () => void): void; + transformItem?(item: Item): Item; + validateItem?(item: Item): FormErrors; +} + +type InlineEditableTableLogicType = MakeLogicType< + InlineEditableTableValues, + InlineEditableTableActions, + InlineEditableTableProps +>; + +export const InlineEditableTableLogic = kea>({ + path: (key: string) => ['enterprise_search', 'inline_editable_table_logic', key], + key: (props) => props.instanceId, + actions: () => ({ + deleteItem: (item) => ({ item }), + doneEditing: true, + editNewItem: true, + editExistingItem: (item) => ({ item }), + reorderItems: (items, oldItems) => ({ items, oldItems }), + saveExistingItem: true, + saveNewItem: true, + setEditingItemValue: (newValue) => ({ item: newValue }), + setFormErrors: (formErrors) => ({ formErrors }), + }), + reducers: ({ props: { columns } }) => ({ + isEditing: [ + false, + { + doneEditing: () => false, + editNewItem: () => true, + editExistingItem: () => true, + }, + ], + editingItemId: [ + null, + { + doneEditing: () => null, + editNewItem: () => getUnsavedItemId(), + editExistingItem: (_, { item }) => item.id, + }, + ], + editingItemValue: [ + null, + { + doneEditing: () => null, + editNewItem: () => generateEmptyItem(columns), + editExistingItem: (_, { item }) => item, + setEditingItemValue: (_, { item }) => item, + }, + ], + formErrors: [ + {}, + { + doneEditing: () => ({}), + setEditingItemValue: () => ({}), + setFormErrors: (_, { formErrors }) => formErrors, + }, + ], + }), + selectors: ({ selectors }) => ({ + isEditingUnsavedItem: [ + () => [selectors.isEditing, selectors.editingItemId], + (isEditing, editingItemId) => { + return isEditing && doesIdMatchUnsavedId(editingItemId); + }, + ], + doesEditingItemValueContainEmptyProperty: [ + () => [selectors.editingItemValue], + (editingItemValue: object) => { + return ( + Object.values(editingItemValue || {}).findIndex( + (value) => typeof value === 'string' && value.length === 0 + ) > -1 + ); + }, + ], + }), + listeners: ({ + values, + actions, + props: { onAdd, onDelete, onReorder, onUpdate, transformItem, validateItem }, + }) => ({ + saveNewItem: () => { + if (!values.editingItemValue) return; + + const itemToSave = transformItem + ? transformItem(values.editingItemValue) + : values.editingItemValue; + const errors: FormErrors = + typeof validateItem === 'undefined' ? {} : validateItem(itemToSave); + if (Object.keys(errors).length) { + actions.setFormErrors(errors); + } else { + onAdd(itemToSave, actions.doneEditing); + } + }, + deleteItem: ({ item: itemToDelete }) => { + onDelete(itemToDelete, actions.doneEditing); + }, + reorderItems: ({ items, oldItems }) => { + if (onReorder) onReorder(items, oldItems, actions.doneEditing); + }, + saveExistingItem: () => { + if (!values.editingItemValue) return; + const itemToSave = values.editingItemValue; + const errors: FormErrors = + typeof validateItem === 'undefined' ? {} : validateItem(itemToSave); + if (Object.keys(errors).length) { + actions.setFormErrors(errors); + } else { + onUpdate(itemToSave, actions.doneEditing); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_tables.scss b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_tables.scss new file mode 100644 index 00000000000000..5a000dcc4899e4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_tables.scss @@ -0,0 +1,3 @@ +.inlineEditableTable__descriptionText { + max-width: 60rem; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/types.ts new file mode 100644 index 00000000000000..35e60cdf4e1469 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/types.ts @@ -0,0 +1,32 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { Column } from '../reorderable_table/types'; + +export interface FormErrors { + [key: string]: string | undefined; +} + +export type ItemWithAnID = { + id: number | null; +} & object; + +export interface EditingRenderFlags { + isInvalid: boolean; + isLoading: boolean; +} + +export interface InlineEditableTableColumn extends Column { + field: string; + editingRender: ( + item: Item, + onChange: (value: string) => void, + flags: EditingRenderFlags + ) => React.ReactNode; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/types.ts index 77c1495977d2f4..1560906b5c8aab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/types.ts @@ -11,6 +11,6 @@ export interface DraggableUXStyles { flexGrow?: number; } export interface Column extends DraggableUXStyles { - name: string; + name?: string; render: (item: Item) => React.ReactNode; }