Skip to content

Commit

Permalink
[ML] DF Analytics jobs list: persist pagination through refresh inter…
Browse files Browse the repository at this point in the history
…val (#75996)

* wip: switch analyticsList inMemoryTable to basic and implement search bar

* move basicTable settings to custom hook and update types

* update types

* add types for empty prompt

* ensure sorting works

* add refresh to analytics management list

* ensure table still updates editing job
  • Loading branch information
alvarezmelissa87 authored Sep 2, 2020
1 parent 4762cf5 commit 70cea48
Show file tree
Hide file tree
Showing 8 changed files with 446 additions and 138 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,13 @@
*/

import React, { FC, useCallback, useState, useEffect } from 'react';

import { i18n } from '@kbn/i18n';

import {
Direction,
EuiButton,
EuiCallOut,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiBasicTable,
EuiSearchBar,
EuiSearchBarProps,
EuiSpacer,
} from '@elastic/eui';
Expand Down Expand Up @@ -43,6 +39,39 @@ import {
getGroupQueryText,
} from '../../../../../jobs/jobs_list/components/utils';
import { SourceSelection } from '../source_selection';
import { filterAnalytics, AnalyticsSearchBar } from '../analytics_search_bar';
import { AnalyticsEmptyPrompt } from './empty_prompt';
import { useTableSettings } from './use_table_settings';
import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button';

const filters: EuiSearchBarProps['filters'] = [
{
type: 'field_value_selection',
field: 'job_type',
name: i18n.translate('xpack.ml.dataframe.analyticsList.typeFilter', {
defaultMessage: 'Type',
}),
multiSelect: 'or',
options: Object.values(ANALYSIS_CONFIG_TYPE).map((val) => ({
value: val,
name: val,
view: getJobTypeBadge(val),
})),
},
{
type: 'field_value_selection',
field: 'state',
name: i18n.translate('xpack.ml.dataframe.analyticsList.statusFilter', {
defaultMessage: 'Status',
}),
multiSelect: 'or',
options: Object.values(DATA_FRAME_TASK_STATE).map((val) => ({
value: val,
name: val,
view: getTaskStateBadge(val),
})),
},
];

function getItemIdToExpandedRowMap(
itemIds: DataFrameAnalyticsId[],
Expand Down Expand Up @@ -70,23 +99,23 @@ export const DataFrameAnalyticsList: FC<Props> = ({
const [isInitialized, setIsInitialized] = useState(false);
const [isSourceIndexModalVisible, setIsSourceIndexModalVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);

const [filteredAnalytics, setFilteredAnalytics] = useState<{
active: boolean;
items: DataFrameAnalyticsListRow[];
}>({
active: false,
items: [],
});
const [searchQueryText, setSearchQueryText] = useState('');

const [analytics, setAnalytics] = useState<DataFrameAnalyticsListRow[]>([]);
const [analyticsStats, setAnalyticsStats] = useState<AnalyticStatsBarStats | undefined>(
undefined
);
const [expandedRowItemIds, setExpandedRowItemIds] = useState<DataFrameAnalyticsId[]>([]);

const [errorMessage, setErrorMessage] = useState<any>(undefined);
const [searchError, setSearchError] = useState<any>(undefined);

const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);

const [sortField, setSortField] = useState<string>(DataFrameAnalyticsListColumn.id);
const [sortDirection, setSortDirection] = useState<Direction>('asc');
// Query text/job_id based on url but only after getAnalytics is done first
// selectedJobIdFromUrlInitialized makes sure the query is only run once since analytics is being refreshed constantly
const [selectedIdFromUrlInitialized, setSelectedIdFromUrlInitialized] = useState(false);

const disabled =
!checkPermission('canCreateDataFrameAnalytics') ||
Expand All @@ -100,9 +129,29 @@ export const DataFrameAnalyticsList: FC<Props> = ({
blockRefresh
);

// Query text/job_id based on url but only after getAnalytics is done first
// selectedJobIdFromUrlInitialized makes sure the query is only run once since analytics is being refreshed constantly
const [selectedIdFromUrlInitialized, setSelectedIdFromUrlInitialized] = useState(false);
const setQueryClauses = (queryClauses: any) => {
if (queryClauses.length) {
const filtered = filterAnalytics(analytics, queryClauses);
setFilteredAnalytics({ active: true, items: filtered });
} else {
setFilteredAnalytics({ active: false, items: [] });
}
};

const filterList = () => {
if (searchQueryText !== '' && selectedIdFromUrlInitialized === true) {
// trigger table filtering with query for job id to trigger table filter
const query = EuiSearchBar.Query.parse(searchQueryText);
let clauses: any = [];
if (query && query.ast !== undefined && query.ast.clauses !== undefined) {
clauses = query.ast.clauses;
}
setQueryClauses(clauses);
} else {
setQueryClauses([]);
}
};

useEffect(() => {
if (selectedIdFromUrlInitialized === false && analytics.length > 0) {
const { jobId, groupIds } = getSelectedIdFromUrl(window.location.href);
Expand All @@ -116,9 +165,15 @@ export const DataFrameAnalyticsList: FC<Props> = ({

setSelectedIdFromUrlInitialized(true);
setSearchQueryText(queryText);
} else {
filterList();
}
}, [selectedIdFromUrlInitialized, analytics]);

useEffect(() => {
filterList();
}, [selectedIdFromUrlInitialized, searchQueryText]);

const getAnalyticsCallback = useCallback(() => getAnalytics(true), []);

// Subscribe to the refresh observable to trigger reloading the analytics list.
Expand All @@ -137,6 +192,10 @@ export const DataFrameAnalyticsList: FC<Props> = ({
isMlEnabledInSpace
);

const { onTableChange, pageOfItems, pagination, sorting } = useTableSettings(
filteredAnalytics.active ? filteredAnalytics.items : analytics
);

// Before the analytics have been loaded for the first time, display the loading indicator only.
// Otherwise a user would see 'No data frame analytics found' during the initial loading.
if (!isInitialized) {
Expand All @@ -160,34 +219,10 @@ export const DataFrameAnalyticsList: FC<Props> = ({
if (analytics.length === 0) {
return (
<>
<EuiEmptyPrompt
iconType="createAdvancedJob"
title={
<h2>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptTitle', {
defaultMessage: 'Create your first data frame analytics job',
})}
</h2>
}
actions={
!isManagementTable
? [
<EuiButton
onClick={() => setIsSourceIndexModalVisible(true)}
isDisabled={disabled}
color="primary"
iconType="plusInCircle"
fill
data-test-subj="mlAnalyticsCreateFirstButton"
>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', {
defaultMessage: 'Create job',
})}
</EuiButton>,
]
: []
}
data-test-subj="mlNoDataFrameAnalyticsFound"
<AnalyticsEmptyPrompt
isManagementTable={isManagementTable}
disabled={disabled}
onCreateFirstJobClick={() => setIsSourceIndexModalVisible(true)}
/>
{isSourceIndexModalVisible === true && (
<SourceSelection onClose={() => setIsSourceIndexModalVisible(false)} />
Expand All @@ -196,95 +231,32 @@ export const DataFrameAnalyticsList: FC<Props> = ({
);
}

const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};

const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(expandedRowItemIds, analytics);

const pagination = {
initialPageIndex: pageIndex,
initialPageSize: pageSize,
totalItemCount: analytics.length,
pageSizeOptions: [10, 20, 50],
hidePerPageOptions: false,
};

const handleSearchOnChange: EuiSearchBarProps['onChange'] = (search) => {
if (search.error !== null) {
setSearchError(search.error.message);
return false;
}

setSearchError(undefined);
setSearchQueryText(search.queryText);
return true;
};

const search: EuiSearchBarProps = {
query: searchQueryText,
onChange: handleSearchOnChange,
box: {
incremental: true,
},
filters: [
{
type: 'field_value_selection',
field: 'job_type',
name: i18n.translate('xpack.ml.dataframe.analyticsList.typeFilter', {
defaultMessage: 'Type',
}),
multiSelect: 'or',
options: Object.values(ANALYSIS_CONFIG_TYPE).map((val) => ({
value: val,
name: val,
view: getJobTypeBadge(val),
})),
},
{
type: 'field_value_selection',
field: 'state',
name: i18n.translate('xpack.ml.dataframe.analyticsList.statusFilter', {
defaultMessage: 'Status',
}),
multiSelect: 'or',
options: Object.values(DATA_FRAME_TASK_STATE).map((val) => ({
value: val,
name: val,
view: getTaskStateBadge(val),
})),
},
],
};

const onTableChange: EuiInMemoryTable<DataFrameAnalyticsListRow>['onTableChange'] = ({
page = { index: 0, size: 10 },
sort = { field: DataFrameAnalyticsListColumn.id, direction: 'asc' },
}) => {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
const stats = analyticsStats && (
<EuiFlexItem grow={false}>
<StatsBar stats={analyticsStats} dataTestSub={'mlAnalyticsStatsBar'} />
</EuiFlexItem>
);

const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
};
const managementStats = (
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween">
{stats}
<EuiFlexItem grow={false}>
<RefreshAnalyticsListButton />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);

return (
<>
{modals}
<EuiSpacer size="m" />
{!isManagementTable && <EuiSpacer size="m" />}
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
{analyticsStats && (
<EuiFlexItem grow={false}>
<StatsBar stats={analyticsStats} dataTestSub={'mlAnalyticsStatsBar'} />
</EuiFlexItem>
)}
</EuiFlexItem>
{!isManagementTable && stats}
{isManagementTable && managementStats}
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="s">
{!isManagementTable && (
Expand All @@ -300,22 +272,25 @@ export const DataFrameAnalyticsList: FC<Props> = ({
</EuiFlexGroup>
<EuiSpacer size="m" />
<div data-test-subj="mlAnalyticsTableContainer">
<EuiInMemoryTable
allowNeutralSort={false}
<AnalyticsSearchBar
filters={filters}
searchQueryText={searchQueryText}
setSearchQueryText={setSearchQueryText}
/>
<EuiSpacer size="l" />
<EuiBasicTable<DataFrameAnalyticsListRow>
className="mlAnalyticsTable"
columns={columns}
error={searchError}
hasActions={false}
isExpandable={true}
isSelectable={false}
items={analytics}
items={pageOfItems}
itemId={DataFrameAnalyticsListColumn.id}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
loading={isLoading}
onTableChange={onTableChange}
pagination={pagination}
onChange={onTableChange}
pagination={pagination!}
sorting={sorting}
search={search}
data-test-subj={isLoading ? 'mlAnalyticsTable loading' : 'mlAnalyticsTable loaded'}
rowProps={(item) => ({
'data-test-subj': `mlAnalyticsTableRow row-${item.id}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type Clause = Parameters<typeof Query['isMust']>[0];
type ExtractClauseType<T> = T extends (x: any) => x is infer Type ? Type : never;
export type TermClause = ExtractClauseType<typeof Ast['Term']['isInstance']>;
export type FieldClause = ExtractClauseType<typeof Ast['Field']['isInstance']>;
export type Value = Parameters<typeof Ast['Term']['must']>[0];

interface ProgressSection {
phase: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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, { FC } from 'react';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';

interface Props {
disabled: boolean;
isManagementTable: boolean;
onCreateFirstJobClick: () => void;
}

export const AnalyticsEmptyPrompt: FC<Props> = ({
disabled,
isManagementTable,
onCreateFirstJobClick,
}) => (
<EuiEmptyPrompt
iconType="createAdvancedJob"
title={
<h2>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptTitle', {
defaultMessage: 'Create your first data frame analytics job',
})}
</h2>
}
actions={
!isManagementTable
? [
<EuiButton
onClick={onCreateFirstJobClick}
isDisabled={disabled}
color="primary"
iconType="plusInCircle"
fill
data-test-subj="mlAnalyticsCreateFirstButton"
>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', {
defaultMessage: 'Create job',
})}
</EuiButton>,
]
: []
}
data-test-subj="mlNoDataFrameAnalyticsFound"
/>
);
Loading

0 comments on commit 70cea48

Please sign in to comment.