[TableListView] Fix regression when resetting search (#162034)

This commit is contained in:
Sébastien Loix 2023-07-18 12:47:01 +01:00 committed by GitHub
parent 72907cfe1e
commit c6deb252b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 370 additions and 80 deletions

View file

@ -47,23 +47,23 @@ export const TabbedTableListView = ({
[activeTabId, tabs]
);
const onFetchSuccess = useCallback(() => {
setHasInitialFetchReturned(true);
}, []);
const [tableList, setTableList] = useState<React.ReactNode>(null);
useEffect(() => {
async function loadTableList() {
const newTableList = await getActiveTab().getTableList({
onFetchSuccess: () => {
if (!hasInitialFetchReturned) {
setHasInitialFetchReturned(true);
}
},
onFetchSuccess,
setPageDataTestSubject,
});
setTableList(newTableList);
}
loadTableList();
}, [hasInitialFetchReturned, activeTabId, tabs, getActiveTab]);
}, [activeTabId, tabs, getActiveTab, onFetchSuccess]);
return (
<KibanaPageTemplate panelled data-test-subj={pageDataTestSubject}>

View file

@ -82,10 +82,8 @@ export const TableListView = <T extends UserContentCommonSchema>({
const [pageDataTestSubject, setPageDataTestSubject] = useState<string>();
const onFetchSuccess = useCallback(() => {
if (!hasInitialFetchReturned) {
setHasInitialFetchReturned(true);
}
}, [hasInitialFetchReturned]);
setHasInitialFetchReturned(true);
}, []);
return (
<PageTemplate panelled data-test-subj={pageDataTestSubject}>

View file

@ -39,11 +39,21 @@ export function getReducer<T extends UserContentCommonSchema>() {
}
}
let hasNoItems = state.hasNoItems;
const hasQuery = state.searchQuery.text !== '';
if (hasQuery) {
hasNoItems = undefined;
} else {
hasNoItems = items.length === 0;
}
return {
...state,
hasInitialFetchReturned: true,
isFetchingItems: false,
items,
hasNoItems,
totalItems: action.data.response.total,
hasUpdatedAtMetadata,
tableSort: tableSort ?? state.tableSort,

View file

@ -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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { TestBed } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
export const getActions = ({ find, form, component }: TestBed) => {
/** Open the sort select drop down menu */
const openSortSelect = () => {
find('tableSortSelectBtn').at(0).simulate('click');
};
// --- Search Box ---
/** Set the search box value */
const updateSearchText = async (value: string) => {
await act(async () => {
find('tableListSearchBox').simulate('keyup', {
key: 'Enter',
target: { value },
});
});
component.update();
};
/** Get the Search box value */
const getSearchBoxValue = () => find('tableListSearchBox').props().defaultValue;
// --- Row Actions ---
const selectRow = (rowId: string) => {
act(() => {
form.selectCheckBox(`checkboxSelectRow-${rowId}`);
});
component.update();
};
const clickDeleteSelectedItemsButton = () => {
act(() => {
find('deleteSelectedItems').simulate('click');
});
component.update();
};
const clickConfirmModalButton = async () => {
await act(async () => {
find('confirmModalConfirmButton').simulate('click');
});
component.update();
};
return {
openSortSelect,
updateSearchText,
getSearchBoxValue,
selectRow,
clickDeleteSelectedItemsButton,
clickConfirmModalButton,
};
};

View file

@ -22,6 +22,7 @@ import {
type TableListViewTableProps,
type UserContentCommonSchema,
} from './table_list_view_table';
import { getActions } from './table_list_view.test.helpers';
const mockUseEffect = useEffect;
@ -54,12 +55,6 @@ const twoDaysAgoToString = new Date(twoDaysAgo.getTime()).toDateString();
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1));
const yesterdayToString = new Date(yesterday.getTime()).toDateString();
const getActions = (testBed: TestBed) => ({
openSortSelect() {
testBed.find('tableSortSelectBtn').at(0).simulate('click');
},
});
describe('TableListView', () => {
const requiredProps: TableListViewTableProps = {
entityName: 'test',
@ -91,50 +86,102 @@ describe('TableListView', () => {
}
);
test('render default empty prompt', async () => {
let testBed: TestBed;
describe('empty prompt', () => {
test('render default empty prompt', async () => {
let testBed: TestBed;
await act(async () => {
testBed = await setup();
await act(async () => {
testBed = await setup();
});
const { component, exists } = testBed!;
component.update();
expect(component.find(EuiEmptyPrompt).length).toBe(1);
expect(exists('newItemButton')).toBe(false);
});
const { component, exists } = testBed!;
component.update();
// avoid trapping users in empty prompt that can not create new items
test('render default empty prompt with create action when createItem supplied', async () => {
let testBed: TestBed;
expect(component.find(EuiEmptyPrompt).length).toBe(1);
expect(exists('newItemButton')).toBe(false);
});
await act(async () => {
testBed = await setup({ createItem: () => undefined });
});
// avoid trapping users in empty prompt that can not create new items
test('render default empty prompt with create action when createItem supplied', async () => {
let testBed: TestBed;
const { component, exists } = testBed!;
component.update();
await act(async () => {
testBed = await setup({ createItem: () => undefined });
expect(component.find(EuiEmptyPrompt).length).toBe(1);
expect(exists('newItemButton')).toBe(true);
});
const { component, exists } = testBed!;
component.update();
test('render custom empty prompt', async () => {
let testBed: TestBed;
expect(component.find(EuiEmptyPrompt).length).toBe(1);
expect(exists('newItemButton')).toBe(true);
});
const CustomEmptyPrompt = () => {
return <EuiEmptyPrompt data-test-subj="custom-empty-prompt" title={<h1>Table empty</h1>} />;
};
test('render custom empty prompt', async () => {
let testBed: TestBed;
await act(async () => {
testBed = await setup({ emptyPrompt: <CustomEmptyPrompt /> });
});
const CustomEmptyPrompt = () => {
return <EuiEmptyPrompt data-test-subj="custom-empty-prompt" title={<h1>Table empty</h1>} />;
};
const { component, exists } = testBed!;
component.update();
await act(async () => {
testBed = await setup({ emptyPrompt: <CustomEmptyPrompt /> });
expect(exists('custom-empty-prompt')).toBe(true);
});
const { component, exists } = testBed!;
component.update();
test('render empty prompt after deleting all items from table', async () => {
// NOTE: this test is using helpers that are being tested in the
// "should allow select items to be deleted" test below.
// If this test fails, check that one first.
expect(exists('custom-empty-prompt')).toBe(true);
const hits: UserContentCommonSchema[] = [
{
id: 'item-1',
type: 'dashboard',
updatedAt: '2020-01-01T00:00:00Z',
attributes: {
title: 'Item 1',
},
references: [],
},
];
const findItems = jest.fn().mockResolvedValue({ total: 1, hits });
const deleteItems = jest.fn();
let testBed: TestBed;
const EmptyPrompt = () => {
return <EuiEmptyPrompt data-test-subj="custom-empty-prompt" title={<h1>Table empty</h1>} />;
};
await act(async () => {
testBed = await setup({ emptyPrompt: <EmptyPrompt />, findItems, deleteItems });
});
const { component, exists, table } = testBed!;
const { selectRow, clickConfirmModalButton, clickDeleteSelectedItemsButton } = getActions(
testBed!
);
component.update();
expect(exists('custom-empty-prompt')).toBe(false);
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
const [row] = tableCellsValues;
expect(row[1]).toBe('Item 1'); // Note: row[0] is the checkbox
// We delete the item in the table and expect the empty prompt to show
findItems.mockResolvedValue({ total: 0, hits: [] });
selectRow('item-1');
clickDeleteSelectedItemsButton();
await clickConfirmModalButton();
expect(exists('custom-empty-prompt')).toBe(true);
});
});
describe('default columns', () => {
@ -798,7 +845,20 @@ describe('TableListView', () => {
let testBed: TestBed;
const initialFilter = 'tag:(tag-1)';
const findItems = jest.fn().mockResolvedValue({ total: 0, hits: [] });
const findItems = jest.fn().mockResolvedValue({
total: 1,
hits: [
{
id: 'item-1',
type: 'dashboard',
updatedAt: new Date('2023-07-15').toISOString(),
attributes: {
title: 'Item 1',
},
references: [],
},
],
});
await act(async () => {
testBed = await setupInitialFilter({
@ -824,6 +884,173 @@ describe('TableListView', () => {
});
});
describe('search', () => {
const updatedAt = new Date('2023-07-15').toISOString();
const hits: UserContentCommonSchema[] = [
{
id: 'item-1',
type: 'dashboard',
updatedAt,
attributes: {
title: 'Item 1',
},
references: [],
},
{
id: 'item-2',
type: 'dashboard',
updatedAt,
attributes: {
title: 'Item 2',
},
references: [],
},
];
const findItems = jest.fn();
const setupSearch = (...args: Parameters<ReturnType<typeof registerTestBed>>) => {
const testBed = registerTestBed<string, TableListViewTableProps>(
WithServices<TableListViewTableProps>(TableListViewTable),
{
defaultProps: {
...requiredProps,
findItems,
urlStateEnabled: false,
entityName: 'Foo',
entityNamePlural: 'Foos',
},
memoryRouter: { wrapComponent: true },
}
)(...args);
const { updateSearchText, getSearchBoxValue } = getActions(testBed);
return {
testBed,
updateSearchText,
getSearchBoxValue,
getLastCallArgsFromFindItems: () => findItems.mock.calls[findItems.mock.calls.length - 1],
};
};
beforeEach(() => {
findItems.mockReset().mockResolvedValue({ total: hits.length, hits });
});
test('should search the table items', async () => {
let testBed: TestBed;
let updateSearchText: (value: string) => Promise<void>;
let getLastCallArgsFromFindItems: () => Parameters<typeof findItems>;
let getSearchBoxValue: () => string;
await act(async () => {
({ testBed, getLastCallArgsFromFindItems, getSearchBoxValue, updateSearchText } =
await setupSearch());
});
const { component, table } = testBed!;
component.update();
let searchTerm = '';
let expected = '';
[searchTerm] = getLastCallArgsFromFindItems!();
expect(getSearchBoxValue!()).toBe(expected);
expect(searchTerm).toBe(expected);
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toMatchInlineSnapshot(`
Array [
Array [
"Item 1",
"Sat Jul 15 2023",
],
Array [
"Item 2",
"Sat Jul 15 2023",
],
]
`);
findItems.mockResolvedValueOnce({
total: 1,
hits: [
{
id: 'item-from-search',
type: 'dashboard',
updatedAt: new Date('2023-07-01').toISOString(),
attributes: {
title: 'Item from search',
},
references: [],
},
],
});
expected = 'foo';
await updateSearchText!(expected);
[searchTerm] = getLastCallArgsFromFindItems!();
expect(getSearchBoxValue!()).toBe(expected);
expect(searchTerm).toBe(expected);
expect(table.getMetaData('itemsInMemTable').tableCellsValues).toMatchInlineSnapshot(`
Array [
Array [
"Item from search",
"July 1, 2023",
],
]
`);
});
test('should search and render empty list if no result', async () => {
let testBed: TestBed;
let updateSearchText: (value: string) => Promise<void>;
await act(async () => {
({ testBed, updateSearchText } = await setupSearch());
});
const { component, table, find } = testBed!;
component.update();
findItems.mockResolvedValueOnce({
total: 0,
hits: [],
});
await updateSearchText!('unknown items');
expect(table.getMetaData('itemsInMemTable').tableCellsValues).toMatchInlineSnapshot(`
Array [
Array [
"No Foos matched your search.",
],
]
`);
await act(async () => {
find('clearSearchButton').simulate('click');
});
component.update();
// We should get back the initial 2 items (Item 1 and Item 2)
expect(table.getMetaData('itemsInMemTable').tableCellsValues).toMatchInlineSnapshot(`
Array [
Array [
"Item 1",
"Sat Jul 15 2023",
],
Array [
"Item 2",
"Sat Jul 15 2023",
],
]
`);
});
});
describe('url state', () => {
let router: Router | undefined;
@ -1153,10 +1380,11 @@ describe('TableListView', () => {
};
test('should allow select items to be deleted', async () => {
const {
testBed: { table, find, exists, component, form },
deleteItems,
} = await setupTest();
const { testBed, deleteItems } = await setupTest();
const { table, exists, component } = testBed;
const { selectRow, clickDeleteSelectedItemsButton, clickConfirmModalButton } =
getActions(testBed);
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
@ -1165,28 +1393,21 @@ describe('TableListView', () => {
['', 'Item 1Item 1 description', twoDaysAgoToString],
]);
// Select the second item
const selectedHit = hits[1];
expect(exists('deleteSelectedItems')).toBe(false);
act(() => {
// Select the second item
form.selectCheckBox(`checkboxSelectRow-${selectedHit.id}`);
});
component.update();
selectRow(selectedHit.id);
// Delete button is now visible
expect(exists('deleteSelectedItems')).toBe(true);
// Click delete and validate that confirm modal opens
expect(component.exists('.euiModal--confirmation')).toBe(false);
act(() => {
find('deleteSelectedItems').simulate('click');
});
component.update();
clickDeleteSelectedItemsButton();
expect(component.exists('.euiModal--confirmation')).toBe(true);
await act(async () => {
find('confirmModalConfirmButton').simulate('click');
});
await clickConfirmModalButton();
expect(deleteItems).toHaveBeenCalledWith([selectedHit]);
});

View file

@ -108,6 +108,7 @@ export interface TableListViewTableProps<
contentEditor?: ContentEditorConfig;
tableCaption: string;
/** Flag to force a new fetch of the table items. Whenever it changes, the `findItems()` will be called. */
refreshListBouncer?: boolean;
onFetchSuccess: () => void;
setPageDataTestSubject: (subject: string) => void;
@ -115,6 +116,12 @@ export interface TableListViewTableProps<
export interface State<T extends UserContentCommonSchema = UserContentCommonSchema> {
items: T[];
/**
* Flag to indicate if there aren't any item when **no filteres are applied**.
* When there are no item we render an empty prompt.
* Default to `undefined` to indicate that we don't know yet if there are items or not.
*/
hasNoItems: boolean | undefined;
hasInitialFetchReturned: boolean;
isFetchingItems: boolean;
isDeletingItems: boolean;
@ -293,6 +300,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
const isMounted = useRef(false);
const fetchIdx = useRef(0);
/**
* The "onTableSearchChange()" handler has an async behavior. We want to be able to discard
* previsous search changes and only handle the last one. For that we keep a counter of the changes.
@ -335,9 +343,10 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
const initialState = useMemo<State<T>>(
() => ({
items: [],
hasNoItems: undefined,
totalItems: 0,
hasInitialFetchReturned: false,
isFetchingItems: false,
isFetchingItems: true,
isDeletingItems: false,
showDeleteModal: false,
hasUpdatedAtMetadata: false,
@ -364,6 +373,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
hasInitialFetchReturned,
isFetchingItems,
items,
hasNoItems,
fetchError,
showDeleteModal,
isDeletingItems,
@ -374,8 +384,6 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
tableSort,
} = state;
const hasQuery = searchQuery.text !== '';
const hasNoItems = hasInitialFetchReturned && items.length === 0 && !hasQuery;
const showFetchError = Boolean(fetchError);
const showLimitError = !showFetchError && totalItems > listingLimit;
@ -857,17 +865,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
// ------------
// Effects
// ------------
useDebounce(
() => {
// Do not call fetchItems on dependency changes when initial fetch does not load any items
// to avoid flashing between empty table and no items view
if (!hasNoItems) {
fetchItems();
}
},
300,
[fetchItems, refreshListBouncer]
);
useDebounce(fetchItems, 300, [fetchItems, refreshListBouncer]);
useEffect(() => {
if (!urlStateEnabled) {

View file

@ -77,8 +77,8 @@ export const EventAnnotationGroupTableList = ({
const [refreshListBouncer, setRefreshListBouncer] = useState(false);
const refreshList = useCallback(() => {
setRefreshListBouncer(!refreshListBouncer);
}, [refreshListBouncer]);
setRefreshListBouncer((prev) => !prev);
}, []);
const fetchItems = useCallback(
(

View file

@ -13,8 +13,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'filesManagement']);
const testSubjects = getService('testSubjects');
// FLAKY: https://github.com/elastic/kibana/issues/160178
describe.skip('Files management', () => {
describe('Files management', () => {
before(async () => {
await PageObjects.filesManagement.navigateTo();
});