From 5a40d4d895d971da80aba903c7511735ef79d5ae Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 9 Sep 2020 10:04:47 -0400 Subject: [PATCH] [ML] Transforms list: persist pagination through refresh interval (#76786) * use basic table in transforms to persist pagination * simplify state and update types --- .../transform_list/transform_list.tsx | 243 +++++------------- .../transform_list/transform_search_bar.tsx | 181 +++++++++++++ .../transform_list/use_table_settings.ts | 135 ++++++++++ 3 files changed, 376 insertions(+), 183 deletions(-) create mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_table_settings.ts diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index cf32bf0455a1b..dad0f0e5ee282 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -4,31 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { MouseEventHandler, FC, useContext, useState } from 'react'; +import React, { MouseEventHandler, FC, useContext, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { - Direction, - EuiBadge, + EuiBasicTable, + EuiBasicTableProps, EuiButtonEmpty, EuiButtonIcon, EuiCallOut, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, - EuiInMemoryTable, - EuiSearchBarProps, + EuiSearchBar, + EuiSpacer, EuiPopover, EuiTitle, } from '@elastic/eui'; -import { TransformId, TRANSFORM_STATE } from '../../../../../../common'; +import { TransformId } from '../../../../../../common'; import { useRefreshTransformList, TransformListRow, - TRANSFORM_MODE, TRANSFORM_LIST_COLUMN, } from '../../../../common'; import { useStopTransforms } from '../../../../hooks'; @@ -45,9 +44,11 @@ import { import { useStartAction, StartActionName, StartActionModal } from '../action_start'; import { StopActionName } from '../action_stop'; -import { ItemIdToExpandedRowMap, Clause, TermClause, FieldClause, Value } from './common'; -import { getTaskStateBadge, useColumns } from './use_columns'; +import { ItemIdToExpandedRowMap } from './common'; +import { useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; +import { TransformSearchBar, filterTransforms } from './transform_search_bar'; +import { useTableSettings } from './use_table_settings'; function getItemIdToExpandedRowMap( itemIds: TransformId[], @@ -62,14 +63,6 @@ function getItemIdToExpandedRowMap( }, {} as ItemIdToExpandedRowMap); } -function stringMatch(str: string | undefined, substr: any) { - return ( - typeof str === 'string' && - typeof substr === 'string' && - (str.toLowerCase().match(substr.toLowerCase()) === null) === false - ); -} - interface Props { errorMessage: any; isInitialized: boolean; @@ -88,24 +81,14 @@ export const TransformList: FC = ({ const [isLoading, setIsLoading] = useState(false); const { refresh } = useRefreshTransformList({ isLoading: setIsLoading }); - const [filterActive, setFilterActive] = useState(false); - + const [searchQueryText, setSearchQueryText] = useState(''); const [filteredTransforms, setFilteredTransforms] = useState([]); const [expandedRowItemIds, setExpandedRowItemIds] = useState([]); - const [transformSelection, setTransformSelection] = useState([]); const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false); const bulkStartAction = useStartAction(false); const bulkDeleteAction = useDeleteAction(false); - const [searchError, setSearchError] = useState(undefined); - - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - - const [sortField, setSortField] = useState(TRANSFORM_LIST_COLUMN.ID); - const [sortDirection, setSortDirection] = useState('asc'); - const stopTransforms = useStopTransforms(); const { capabilities } = useContext(AuthorizationContext); @@ -114,90 +97,41 @@ export const TransformList: FC = ({ !capabilities.canPreviewTransform || !capabilities.canStartStopTransform; - const onQueryChange = ({ - query, - error, - }: Parameters>[0]) => { - if (error) { - setSearchError(error.message); + const { columns, modals: singleActionModals } = useColumns( + expandedRowItemIds, + setExpandedRowItemIds, + transformSelection + ); + + const updateFilteredItems = (queryClauses: any) => { + if (queryClauses.length) { + const filtered = filterTransforms(transforms, queryClauses); + setFilteredTransforms(filtered); } else { - let clauses: Clause[] = []; - if (query && query.ast !== undefined && query.ast.clauses !== undefined) { - clauses = query.ast.clauses; - } - if (clauses.length > 0) { - setFilterActive(true); - filterTransforms(clauses as Array); - } else { - setFilterActive(false); - } - setSearchError(undefined); + setFilteredTransforms(transforms); } }; - const filterTransforms = (clauses: Array) => { - setIsLoading(true); - // keep count of the number of matches we make as we're looping over the clauses - // we only want to return transforms which match all clauses, i.e. each search term is ANDed - // { transform-one: { transform: { id: transform-one, config: {}, state: {}, ... }, count: 0 }, transform-two: {...} } - const matches: Record = transforms.reduce((p: Record, c) => { - p[c.id] = { - transform: c, - count: 0, - }; - return p; - }, {}); - - clauses.forEach((c) => { - // the search term could be negated with a minus, e.g. -bananas - const bool = c.match === 'must'; - let ts = []; - - if (c.type === 'term') { - // filter term based clauses, e.g. bananas - // match on ID and description - // if the term has been negated, AND the matches - if (bool === true) { - ts = transforms.filter( - (transform) => - stringMatch(transform.id, c.value) === bool || - stringMatch(transform.config.description, c.value) === bool - ); - } else { - ts = transforms.filter( - (transform) => - stringMatch(transform.id, c.value) === bool && - stringMatch(transform.config.description, c.value) === bool - ); + useEffect(() => { + const filterList = () => { + if (searchQueryText !== '') { + const query = EuiSearchBar.Query.parse(searchQueryText); + let clauses: any = []; + if (query && query.ast !== undefined && query.ast.clauses !== undefined) { + clauses = query.ast.clauses; } + updateFilteredItems(clauses); } else { - // filter other clauses, i.e. the mode and status filters - if (Array.isArray(c.value)) { - // the status value is an array of string(s) e.g. ['failed', 'stopped'] - ts = transforms.filter((transform) => - (c.value as Value[]).includes(transform.stats.state) - ); - } else { - ts = transforms.filter((transform) => transform.mode === c.value); - } + updateFilteredItems([]); } - - ts.forEach((t) => matches[t.id].count++); - }); - - // loop through the matches and return only transforms which have match all the clauses - const filtered = Object.values(matches) - .filter((m) => (m && m.count) >= clauses.length) - .map((m) => m.transform); - - setFilteredTransforms(filtered); - setIsLoading(false); - }; - - const { columns, modals: singleActionModals } = useColumns( - expandedRowItemIds, - setExpandedRowItemIds, - transformSelection + }; + filterList(); + // eslint-disable-next-line + }, [searchQueryText, transforms]); // missing dependency updateFilteredItems + + const { onTableChange, pageOfItems, pagination, sorting } = useTableSettings( + TRANSFORM_LIST_COLUMN.ID, + filteredTransforms ); // Before the transforms have been loaded for the first time, display the loading indicator only. @@ -246,23 +180,8 @@ export const TransformList: FC = ({ ); } - const sorting = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(expandedRowItemIds, transforms); - const pagination = { - initialPageIndex: pageIndex, - initialPageSize: pageSize, - totalItemCount: transforms.length, - pageSizeOptions: [10, 20, 50], - hidePerPageOptions: false, - }; - const bulkActionMenuItems = [
bulkStartAction.openModal(transformSelection)}> @@ -331,7 +250,7 @@ export const TransformList: FC = ({ ]; }; - const renderToolsRight = () => ( + const toolsRight = ( @@ -342,56 +261,6 @@ export const TransformList: FC = ({ ); - const search = { - toolsLeft: transformSelection.length > 0 ? renderToolsLeft() : undefined, - toolsRight: renderToolsRight(), - onChange: onQueryChange, - box: { - incremental: true, - }, - filters: [ - { - type: 'field_value_selection' as const, - field: 'state.state', - name: i18n.translate('xpack.transform.statusFilter', { defaultMessage: 'Status' }), - multiSelect: 'or' as const, - options: Object.values(TRANSFORM_STATE).map((val) => ({ - value: val, - name: val, - view: getTaskStateBadge(val), - })), - }, - { - type: 'field_value_selection' as const, - field: 'mode', - name: i18n.translate('xpack.transform.modeFilter', { defaultMessage: 'Mode' }), - multiSelect: false, - options: Object.values(TRANSFORM_MODE).map((val) => ({ - value: val, - name: val, - view: ( - - {val} - - ), - })), - }, - ], - }; - - const onTableChange = ({ - page = { index: 0, size: 10 }, - sort = { field: TRANSFORM_LIST_COLUMN.ID as string, direction: 'asc' }, - }) => { - const { index, size } = page; - setPageIndex(index); - setPageSize(size); - - const { field, direction } = sort; - setSortField(field as string); - setSortDirection(direction as Direction); - }; - const selection = { onSelectionChange: (selected: TransformListRow[]) => setTransformSelection(selected), }; @@ -404,30 +273,38 @@ export const TransformList: FC = ({ {/* Single Action Modals */} {singleActionModals} - - + {transformSelection.length > 0 ? ( + {renderToolsLeft()} + ) : null} + + + + {toolsRight} + + + columns={columns} - error={searchError} hasActions={false} isExpandable={true} isSelectable={false} - items={filterActive ? filteredTransforms : transforms} + items={pageOfItems as TransformListRow[]} itemId={TRANSFORM_LIST_COLUMN.ID} itemIdToExpandedRowMap={itemIdToExpandedRowMap} loading={isLoading || transformsLoading} - onTableChange={onTableChange} - pagination={pagination} - rowProps={(item) => ({ - 'data-test-subj': `transformListRow row-${item.id}`, - })} + onChange={onTableChange as EuiBasicTableProps['onChange']} selection={selection} + pagination={pagination!} sorting={sorting} - search={search} data-test-subj={`transformListTable ${ isLoading || transformsLoading ? 'loading' : 'loaded' }`} + rowProps={(item) => ({ + 'data-test-subj': `transformListRow row-${item.id}`, + })} />
); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar.tsx new file mode 100644 index 0000000000000..fab591f881310 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Dispatch, SetStateAction, FC, Fragment, useState } from 'react'; +import { + EuiBadge, + EuiSearchBar, + EuiSearchBarProps, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + SearchFilterConfig, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { TermClause, FieldClause, Value } from './common'; +import { TRANSFORM_STATE } from '../../../../../../common'; +import { TRANSFORM_MODE, TransformListRow } from '../../../../common'; +import { getTaskStateBadge } from './use_columns'; + +const filters: SearchFilterConfig[] = [ + { + type: 'field_value_selection', + field: 'state.state', + name: i18n.translate('xpack.transform.statusFilter', { defaultMessage: 'Status' }), + multiSelect: 'or', + options: Object.values(TRANSFORM_STATE).map((val) => ({ + value: val, + name: val, + view: getTaskStateBadge(val), + })), + }, + { + type: 'field_value_selection', + field: 'mode', + name: i18n.translate('xpack.transform.modeFilter', { defaultMessage: 'Mode' }), + multiSelect: false, + options: Object.values(TRANSFORM_MODE).map((val) => ({ + value: val, + name: val, + view: ( + + {val} + + ), + })), + }, +]; + +function stringMatch(str: string | undefined, substr: any) { + return ( + typeof str === 'string' && + typeof substr === 'string' && + (str.toLowerCase().match(substr.toLowerCase()) === null) === false + ); +} + +export const filterTransforms = ( + transforms: TransformListRow[], + clauses: Array +) => { + // keep count of the number of matches we make as we're looping over the clauses + // we only want to return transforms which match all clauses, i.e. each search term is ANDed + // { transform-one: { transform: { id: transform-one, config: {}, state: {}, ... }, count: 0 }, transform-two: {...} } + const matches: Record = transforms.reduce((p: Record, c) => { + p[c.id] = { + transform: c, + count: 0, + }; + return p; + }, {}); + + clauses.forEach((c) => { + // the search term could be negated with a minus, e.g. -bananas + const bool = c.match === 'must'; + let ts = []; + + if (c.type === 'term') { + // filter term based clauses, e.g. bananas + // match on ID and description + // if the term has been negated, AND the matches + if (bool === true) { + ts = transforms.filter( + (transform) => + stringMatch(transform.id, c.value) === bool || + stringMatch(transform.config.description, c.value) === bool + ); + } else { + ts = transforms.filter( + (transform) => + stringMatch(transform.id, c.value) === bool && + stringMatch(transform.config.description, c.value) === bool + ); + } + } else { + // filter other clauses, i.e. the mode and status filters + if (Array.isArray(c.value)) { + // the status value is an array of string(s) e.g. ['failed', 'stopped'] + ts = transforms.filter((transform) => (c.value as Value[]).includes(transform.stats.state)); + } else { + ts = transforms.filter((transform) => transform.mode === c.value); + } + } + + ts.forEach((t) => matches[t.id].count++); + }); + + // loop through the matches and return only transforms which have match all the clauses + const filtered = Object.values(matches) + .filter((m) => (m && m.count) >= clauses.length) + .map((m) => m.transform); + + return filtered; +}; + +function getError(errorMessage: string | null) { + if (errorMessage) { + return i18n.translate('xpack.transform.transformList.searchBar.invalidSearchErrorMessage', { + defaultMessage: 'Invalid search: {errorMessage}', + values: { errorMessage }, + }); + } + + return ''; +} + +interface Props { + searchQueryText: string; + setSearchQueryText: Dispatch>; +} + +export const TransformSearchBar: FC = ({ searchQueryText, setSearchQueryText }) => { + const [errorMessage, setErrorMessage] = useState(null); + + const onChange: EuiSearchBarProps['onChange'] = ({ query, error }) => { + if (error) { + setErrorMessage(error.message); + } else if (query !== null && query.text !== undefined) { + setSearchQueryText(query.text); + setErrorMessage(null); + } + }; + + return ( + + + {searchQueryText === undefined && ( + + )} + {searchQueryText !== undefined && ( + + )} + + + + + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_table_settings.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_table_settings.ts new file mode 100644 index 0000000000000..ad40a3f8103b9 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_table_settings.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; +import { Direction, EuiBasicTableProps, EuiTableSortingType } from '@elastic/eui'; +import sortBy from 'lodash/sortBy'; +import get from 'lodash/get'; + +const PAGE_SIZE = 10; +const PAGE_SIZE_OPTIONS = [10, 25, 50]; + +const propertyMap = { + Mode: 'mode', +}; + +// Copying from EUI EuiBasicTable types as type is not correctly picked up for table's onChange +// Can be removed when https://github.com/elastic/eui/issues/4011 is addressed in EUI +export interface Criteria { + page?: { + index: number; + size: number; + }; + sort?: { + field: keyof T; + direction: Direction; + }; +} +export interface CriteriaWithPagination extends Criteria { + page: { + index: number; + size: number; + }; +} + +interface AnalyticsBasicTableSettings { + pageIndex: number; + pageSize: number; + totalItemCount: number; + hidePerPageOptions: boolean; + sortField: keyof T; + sortDirection: Direction; +} + +interface UseTableSettingsReturnValue { + onTableChange: EuiBasicTableProps['onChange']; + pageOfItems: T[]; + pagination: EuiBasicTableProps['pagination']; + sorting: EuiTableSortingType; +} + +export function useTableSettings( + sortByField: keyof TypeOfItem, + items: TypeOfItem[] +): UseTableSettingsReturnValue { + const [tableSettings, setTableSettings] = useState>({ + pageIndex: 0, + pageSize: PAGE_SIZE, + totalItemCount: 0, + hidePerPageOptions: false, + sortField: sortByField, + sortDirection: 'asc', + }); + + const getPageOfItems = ( + list: TypeOfItem[], + index: number, + size: number, + sortField: keyof TypeOfItem, + sortDirection: Direction + ) => { + list = sortBy(list, (item) => + get(item, propertyMap[sortField as keyof typeof propertyMap] || sortField) + ); + list = sortDirection === 'asc' ? list : list.reverse(); + const listLength = list.length; + + let pageStart = index * size; + if (pageStart >= listLength && listLength !== 0) { + // if the page start is larger than the number of items due to + // filters being applied or items being deleted, calculate a new page start + pageStart = Math.floor((listLength - 1) / size) * size; + + setTableSettings({ ...tableSettings, pageIndex: pageStart / size }); + } + return { + pageOfItems: list.slice(pageStart, pageStart + size), + totalItemCount: listLength, + }; + }; + + const onTableChange: EuiBasicTableProps['onChange'] = ({ + page = { index: 0, size: PAGE_SIZE }, + sort = { field: sortByField, direction: 'asc' }, + }: CriteriaWithPagination) => { + const { index, size } = page; + const { field, direction } = sort; + + setTableSettings({ + ...tableSettings, + pageIndex: index, + pageSize: size, + sortField: field, + sortDirection: direction, + }); + }; + + const { pageIndex, pageSize, sortField, sortDirection } = tableSettings; + + const { pageOfItems, totalItemCount } = getPageOfItems( + items, + pageIndex, + pageSize, + sortField, + sortDirection + ); + + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: PAGE_SIZE_OPTIONS, + }; + + const sorting = { + sort: { + field: sortField, + direction: sortDirection, + }, + }; + + return { onTableChange, pageOfItems, pagination, sorting }; +}