[ML] Fix job selection flyout (#79850)

* [ML] fix job selection flyout

* [ML] hide time range column

* [ML] show callout when no AD jobs presented

* [ML] close job selector flyout on navigating away from the dashboard

* [ML] add Create job button

* [ML] fix mocks

* [ML] add unit test for callout
This commit is contained in:
Dima Arnautov 2020-10-08 14:59:35 +02:00 committed by GitHub
parent 18e8b63430
commit 14b02a3506
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 260 additions and 153 deletions

View file

@ -6,14 +6,18 @@
import React, { useState, useEffect } from 'react';
import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup, EuiFlyout } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Dictionary } from '../../../../common/types/common';
import { useUrlState } from '../../util/url_state';
// @ts-ignore
import { IdBadges } from './id_badges/index';
import { BADGE_LIMIT, JobSelectorFlyout, JobSelectorFlyoutProps } from './job_selector_flyout';
import {
BADGE_LIMIT,
JobSelectorFlyoutContent,
JobSelectorFlyoutProps,
} from './job_selector_flyout';
import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs';
interface GroupObj {
@ -163,16 +167,18 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
function renderFlyout() {
if (isFlyoutVisible) {
return (
<JobSelectorFlyout
dateFormatTz={dateFormatTz}
timeseriesOnly={timeseriesOnly}
singleSelection={singleSelection}
selectedIds={selectedIds}
onSelectionConfirmed={applySelection}
onJobsFetched={setMaps}
onFlyoutClose={closeFlyout}
maps={maps}
/>
<EuiFlyout onClose={closeFlyout}>
<JobSelectorFlyoutContent
dateFormatTz={dateFormatTz}
timeseriesOnly={timeseriesOnly}
singleSelection={singleSelection}
selectedIds={selectedIds}
onSelectionConfirmed={applySelection}
onJobsFetched={setMaps}
onFlyoutClose={closeFlyout}
maps={maps}
/>
</EuiFlyout>
);
}
}

View file

@ -11,12 +11,13 @@ import {
EuiButtonEmpty,
EuiFlexItem,
EuiFlexGroup,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiSwitch,
EuiTitle,
EuiResizeObserver,
EuiProgress,
} from '@elastic/eui';
import { NewSelectionIdBadges } from './new_selection_id_badges';
// @ts-ignore
@ -39,7 +40,6 @@ export interface JobSelectorFlyoutProps {
newSelection?: string[];
onFlyoutClose: () => void;
onJobsFetched?: (maps: JobSelectionMaps) => void;
onSelectionChange?: (newSelection: string[]) => void;
onSelectionConfirmed: (payload: {
newSelection: string[];
jobIds: string[];
@ -52,13 +52,12 @@ export interface JobSelectorFlyoutProps {
withTimeRangeSelector?: boolean;
}
export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
export const JobSelectorFlyoutContent: FC<JobSelectorFlyoutProps> = ({
dateFormatTz,
selectedIds = [],
singleSelection,
timeseriesOnly,
onJobsFetched,
onSelectionChange,
onSelectionConfirmed,
onFlyoutClose,
maps,
@ -73,6 +72,7 @@ export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
const [newSelection, setNewSelection] = useState(selectedIds);
const [isLoading, setIsLoading] = useState(true);
const [showAllBadges, setShowAllBadges] = useState(false);
const [applyTimeRange, setApplyTimeRange] = useState(true);
const [jobs, setJobs] = useState<MlJobWithTimeRange[]>([]);
@ -80,7 +80,7 @@ export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH);
const [jobGroupsMaps, setJobGroupsMaps] = useState(maps);
const flyoutEl = useRef<{ flyout: HTMLElement }>(null);
const flyoutEl = useRef<HTMLElement | null>(null);
function applySelection() {
// allNewSelection will be a list of all job ids (including those from groups) selected from the table
@ -131,19 +131,19 @@ export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
// Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below.
// Not wrapping it would cause this dependency to change on every render
const handleResize = useCallback(() => {
if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) {
// get all cols in flyout table
const tableHeaderCols: NodeListOf<HTMLElement> = flyoutEl.current.flyout.querySelectorAll(
'table thead th'
);
// get the width of the last col
const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16;
const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth);
setJobs(normalizedJobs);
const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs);
setGroups(updatedGroups);
setGanttBarWidth(derivedWidth);
}
if (jobs.length === 0 || !flyoutEl.current) return;
// get all cols in flyout table
const tableHeaderCols: NodeListOf<HTMLElement> = flyoutEl.current.querySelectorAll(
'table thead th'
);
// get the width of the last col
const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16;
const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth);
setJobs(normalizedJobs);
const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs);
setGroups(updatedGroups);
setGanttBarWidth(derivedWidth);
}, [dateFormatTz, jobs]);
// Fetch jobs list on flyout open
@ -172,119 +172,124 @@ export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
}),
});
}
setIsLoading(false);
}
useEffect(() => {
// Ensure ganttBar width gets calculated on resize
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
useEffect(() => {
handleResize();
}, [handleResize, jobs]);
return (
<EuiFlyout
// @ts-ignore
ref={flyoutEl}
onClose={onFlyoutClose}
aria-labelledby="jobSelectorFlyout"
data-test-subj="mlFlyoutJobSelector"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id="flyoutTitle">
{i18n.translate('xpack.ml.jobSelector.flyoutTitle', {
defaultMessage: 'Job selection',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody className="mlJobSelectorFlyoutBody">
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexGroup wrap responsive={false} gutterSize="xs" alignItems="center">
<NewSelectionIdBadges
limit={BADGE_LIMIT}
maps={jobGroupsMaps}
newSelection={newSelection}
onDeleteClick={removeId}
onLinkClick={() => setShowAllBadges(!showAllBadges)}
showAllBadges={showAllBadges}
/>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" justifyContent="spaceBetween" responsive={false}>
<EuiResizeObserver onResize={handleResize}>
{(resizeRef) => (
<EuiFlexGroup
direction="column"
gutterSize="none"
ref={(e) => {
flyoutEl.current = e;
resizeRef(e);
}}
aria-labelledby="jobSelectorFlyout"
data-test-subj="mlFlyoutJobSelector"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id="flyoutTitle">
{i18n.translate('xpack.ml.jobSelector.flyoutTitle', {
defaultMessage: 'Job selection',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody className="mlJobSelectorFlyoutBody">
{isLoading ? (
<EuiProgress size="xs" color="accent" />
) : (
<>
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexGroup wrap responsive={false} gutterSize="xs" alignItems="center">
<NewSelectionIdBadges
limit={BADGE_LIMIT}
maps={jobGroupsMaps}
newSelection={newSelection}
onDeleteClick={removeId}
onLinkClick={() => setShowAllBadges(!showAllBadges)}
showAllBadges={showAllBadges}
/>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
{!singleSelection && newSelection.length > 0 && (
<EuiButtonEmpty
onClick={clearSelection}
size="xs"
data-test-subj="mlFlyoutJobSelectorButtonClearSelection"
>
{i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', {
defaultMessage: 'Clear all',
})}
</EuiButtonEmpty>
)}
</EuiFlexItem>
{withTimeRangeSelector && (
<EuiFlexItem grow={false}>
<EuiSwitch
label={i18n.translate(
'xpack.ml.jobSelector.applyTimerangeSwitchLabel',
{
defaultMessage: 'Apply time range',
}
)}
checked={applyTimeRange}
onChange={toggleTimerangeSwitch}
data-test-subj="mlFlyoutJobSelectorSwitchApplyTimeRange"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<JobSelectorTable
jobs={jobs}
ganttBarWidth={ganttBarWidth}
groupsList={groups}
onSelection={handleNewSelection}
selectedIds={newSelection}
singleSelection={singleSelection}
timeseriesOnly={timeseriesOnly}
withTimeRangeSelector={withTimeRangeSelector}
/>
</>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
{!singleSelection && newSelection.length > 0 && (
<EuiButtonEmpty
onClick={clearSelection}
size="xs"
data-test-subj="mlFlyoutJobSelectorButtonClearSelection"
>
{i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', {
defaultMessage: 'Clear all',
})}
</EuiButtonEmpty>
)}
<EuiButton
onClick={applySelection}
fill
isDisabled={newSelection.length === 0}
data-test-subj="mlFlyoutJobSelectorButtonApply"
>
{i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', {
defaultMessage: 'Apply',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={onFlyoutClose}
data-test-subj="mlFlyoutJobSelectorButtonClose"
>
{i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlexItem>
{withTimeRangeSelector && (
<EuiFlexItem grow={false}>
<EuiSwitch
label={i18n.translate('xpack.ml.jobSelector.applyTimerangeSwitchLabel', {
defaultMessage: 'Apply time range',
})}
checked={applyTimeRange}
onChange={toggleTimerangeSwitch}
data-test-subj="mlFlyoutJobSelectorSwitchApplyTimeRange"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlyoutFooter>
</EuiFlexGroup>
<JobSelectorTable
jobs={jobs}
ganttBarWidth={ganttBarWidth}
groupsList={groups}
onSelection={handleNewSelection}
selectedIds={newSelection}
singleSelection={singleSelection}
timeseriesOnly={timeseriesOnly}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
onClick={applySelection}
fill
isDisabled={newSelection.length === 0}
data-test-subj="mlFlyoutJobSelectorButtonApply"
>
{i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', {
defaultMessage: 'Apply',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={onFlyoutClose}
data-test-subj="mlFlyoutJobSelectorButtonClose"
>
{i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
)}
</EuiResizeObserver>
);
};

View file

@ -9,11 +9,22 @@ import { PropTypes } from 'prop-types';
import { CustomSelectionTable } from '../../custom_selection_table';
import { JobSelectorBadge } from '../job_selector_badge';
import { TimeRangeBar } from '../timerange_bar';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiTabbedContent } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiTabbedContent,
EuiCallOut,
EuiButton,
EuiText,
} from '@elastic/eui';
import { LEFT_ALIGNMENT, CENTER_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services';
import { i18n } from '@kbn/i18n';
import { useMlKibana } from '../../../contexts/kibana';
import { ML_PAGES } from '../../../../../common/constants/ml_url_generator';
import { PLUGIN_ID } from '../../../../../common/constants/app';
const JOB_FILTER_FIELDS = ['job_id', 'groups'];
const GROUP_FILTER_FIELDS = ['id'];
@ -26,10 +37,17 @@ export function JobSelectorTable({
selectedIds,
singleSelection,
timeseriesOnly,
withTimeRangeSelector,
}) {
const [sortableProperties, setSortableProperties] = useState();
const [currentTab, setCurrentTab] = useState('Jobs');
const {
services: {
application: { navigateToApp },
},
} = useMlKibana();
useEffect(() => {
let sortablePropertyItems = [];
let defaultSortProperty = 'job_id';
@ -125,15 +143,18 @@ export function JobSelectorTable({
<JobSelectorBadge key={`${group}-key`} id={group} isGroup={true} />
)),
},
{
];
if (withTimeRangeSelector) {
columns.push({
label: 'time range',
id: 'timerange',
alignment: LEFT_ALIGNMENT,
render: ({ timeRange = {}, isRunning }) => (
<TimeRangeBar timerange={timeRange} isRunning={isRunning} ganttBarWidth={ganttBarWidth} />
),
},
];
});
}
const filters = [
{
@ -190,15 +211,18 @@ export function JobSelectorTable({
alignment: CENTER_ALIGNMENT,
render: ({ jobIds = [] }) => jobIds.length,
},
{
];
if (withTimeRangeSelector) {
groupColumns.push({
label: 'time range',
id: 'timerange',
alignment: LEFT_ALIGNMENT,
render: ({ timeRange = {} }) => (
<TimeRangeBar timerange={timeRange} ganttBarWidth={ganttBarWidth} />
),
},
];
});
}
return (
<CustomSelectionTable
@ -225,9 +249,32 @@ export function JobSelectorTable({
);
}
const navigateToWizard = async () => {
await navigateToApp(PLUGIN_ID, { path: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB });
};
return (
<Fragment>
{jobs.length === 0 && <EuiLoadingSpinner size="l" />}
{jobs.length === 0 && (
<EuiCallOut
title={
<FormattedMessage
id="xpack.ml.jobSelector.noJobsFoundTitle"
defaultMessage="No anomaly detection jobs found"
/>
}
iconType="iInCircle"
>
<EuiText textAlign="center">
<EuiButton color="primary" onClick={navigateToWizard}>
<FormattedMessage
id="xpack.ml.jobSelector.createJobButtonLabel"
defaultMessage="Create job"
/>
</EuiButton>
</EuiText>
</EuiCallOut>
)}
{jobs.length !== 0 && singleSelection === true && renderJobsTable()}
{jobs.length !== 0 && !singleSelection && renderTabs()}
</Fragment>
@ -242,4 +289,5 @@ JobSelectorTable.propTypes = {
selectedIds: PropTypes.array.isRequired,
singleSelection: PropTypes.bool,
timeseriesOnly: PropTypes.bool,
withTimeRangeSelector: PropTypes.bool,
};

View file

@ -5,14 +5,11 @@
*/
import React from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { fireEvent, render } from '@testing-library/react'; // eslint-disable-line import/no-extraneous-dependencies
import { JobSelectorTable } from './job_selector_table';
jest.mock('../../../services/job_service', () => ({
mlJobService: {
getJob: jest.fn(),
},
}));
jest.mock('../../../contexts/kibana');
const props = {
ganttBarWidth: 299,
@ -124,6 +121,19 @@ describe('JobSelectorTable', () => {
});
describe('Not Single Selection', () => {
test('renders callout when no jobs provided', () => {
const propsEmptyJobs = { ...props, jobs: [], groupsList: [] };
const { getByText } = render(
<I18nProvider>
<JobSelectorTable {...propsEmptyJobs} />
</I18nProvider>
);
const calloutMessage = getByText('No anomaly detection jobs found');
const createJobButton = getByText('Create job');
expect(createJobButton).toBeDefined();
expect(calloutMessage).toBeDefined();
});
test('renders tabs when not singleSelection', () => {
const { getAllByRole } = render(<JobSelectorTable {...props} />);
const tabs = getAllByRole('tab');

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export { useMlKibana } from './kibana_context';

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
export const useMlKibana = jest.fn(() => {
return {
services: {
application: {
navigateToApp: jest.fn(),
},
},
};
});

View file

@ -7,25 +7,33 @@
import React from 'react';
import { CoreStart } from 'kibana/public';
import moment from 'moment';
import { takeUntil } from 'rxjs/operators';
import { from } from 'rxjs';
import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants';
import {
KibanaContextProvider,
toMountPoint,
} from '../../../../../../src/plugins/kibana_react/public';
import { AnomalySwimlaneInitializer } from './anomaly_swimlane_initializer';
import { JobSelectorFlyout } from '../../application/components/job_selector/job_selector_flyout';
import { JobSelectorFlyoutContent } from '../../application/components/job_selector/job_selector_flyout';
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
import { getInitialGroupsMap } from '../../application/components/job_selector/job_selector';
import { getDefaultPanelTitle } from './anomaly_swimlane_embeddable';
import { getMlGlobalServices } from '../../application/app';
import { HttpService } from '../../application/services/http_service';
import { DashboardConstants } from '../../../../../../src/plugins/dashboard/public';
import { AnomalySwimlaneEmbeddableInput } from '..';
export async function resolveAnomalySwimlaneUserInput(
coreStart: CoreStart,
input?: AnomalySwimlaneEmbeddableInput
): Promise<Partial<AnomalySwimlaneEmbeddableInput>> {
const { http, uiSettings, overlays } = coreStart;
const {
http,
uiSettings,
overlays,
application: { currentAppId$ },
} = coreStart;
const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http));
@ -43,7 +51,7 @@ export async function resolveAnomalySwimlaneUserInput(
const flyoutSession = coreStart.overlays.openFlyout(
toMountPoint(
<KibanaContextProvider services={{ ...coreStart, mlServices: getMlGlobalServices(http) }}>
<JobSelectorFlyout
<JobSelectorFlyoutContent
selectedIds={selectedIds}
withTimeRangeSelector={false}
dateFormatTz={dateFormatTz}
@ -87,7 +95,15 @@ export async function resolveAnomalySwimlaneUserInput(
),
{
'data-test-subj': 'mlAnomalySwimlaneEmbeddable',
ownFocus: true,
}
);
// Close the flyout when user navigates out of the dashboard plugin
currentAppId$.pipe(takeUntil(from(flyoutSession.onClose))).subscribe((appId) => {
if (appId !== DashboardConstants.DASHBOARDS_ID) {
flyoutSession.close();
}
});
});
}