From 0ae213496317992dd59a199a412d6625cc5e171a Mon Sep 17 00:00:00 2001
From: Jason Stoltzfus
Date: Thu, 29 Jul 2021 17:58:48 -0400
Subject: [PATCH] [Enterprise Search] Added the InlineEditableTable component
(#107208)
---
.../app_search/components/library/library.tsx | 163 +++++++
.../action_column.test.tsx | 193 ++++++++
.../inline_editable_table/action_column.tsx | 113 +++++
.../tables/inline_editable_table/constants.ts | 8 +
.../editing_column.test.tsx | 135 ++++++
.../inline_editable_table/editing_column.tsx | 58 +++
.../get_updated_columns.test.tsx | 131 ++++++
.../get_updated_columns.tsx | 64 +++
.../tables/inline_editable_table/index.ts | 8 +
.../inline_editable_table.test.tsx | 186 ++++++++
.../inline_editable_table.tsx | 174 +++++++
.../inline_editable_table_logic.test.ts | 434 ++++++++++++++++++
.../inline_editable_table_logic.ts | 168 +++++++
.../inline_editable_tables.scss | 3 +
.../tables/inline_editable_table/types.ts | 32 ++
.../shared/tables/reorderable_table/types.ts | 2 +-
16 files changed, 1871 insertions(+), 1 deletion(-)
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.test.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/constants.ts
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/editing_column.test.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/editing_column.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.test.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/index.ts
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table.test.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table_logic.test.ts
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_table_logic.ts
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/inline_editable_tables.scss
create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/types.ts
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;
}