[ML] Job selection flyout: UX improvements (#189239)

## Summary

Related meta issue: https://github.com/elastic/kibana/issues/182042
Fixes https://github.com/elastic/kibana/issues/186228

This PR makes some small UX improvements to the Job selection flyout:

- replaces the callout with the EuiEmptyPrompt
- the Primary action (Apply) is now on the right of the footer and the
Secondary action (Close) is aligned left

<img width="725" alt="image"
src="https://github.com/user-attachments/assets/3469106b-33a4-4060-b0a0-cbfe582187aa">

<img width="717" alt="image"
src="https://github.com/user-attachments/assets/9aae9bc3-04dd-426d-a5ea-9f059dc64e0e">

In dashboard, shows the empty prompt when no jobs in the panel config
flyout:

<img width="779" alt="image"
src="https://github.com/user-attachments/assets/b6526e28-fbaf-43f2-a0d1-27e60bac5cb0">



### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Melissa Alvarez 2024-08-01 08:53:05 -06:00 committed by GitHub
parent fec2318ee3
commit b6b5a89fa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 200 additions and 143 deletions

View file

@ -197,6 +197,7 @@ const MlAnomalyAlertTrigger: FC<MlAnomalyAlertTriggerProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
onChange={useCallback(onAlertParamChange('jobSelection'), [])}
errors={Array.isArray(errors.jobSelection) ? errors.jobSelection : []}
shouldUseDropdownJobCreate
/>
<ConfigValidator

View file

@ -10,12 +10,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui';
import { EuiComboBox, EuiFormRow } from '@elastic/eui';
import { EuiButton, EuiComboBox, EuiEmptyPrompt, EuiFormRow } from '@elastic/eui';
import useMountedState from 'react-use/lib/useMountedState';
import { useMlKibana } from '../application/contexts/kibana';
import type { JobId } from '../../common/types/anomaly_detection_jobs';
import type { MlApiServices } from '../application/services/ml_api_service';
import { ALL_JOBS_SELECTION } from '../../common/constants/alerts';
import { LoadingIndicator } from '../application/components/loading_indicator';
interface JobSelection {
jobIds?: JobId[];
@ -43,6 +44,10 @@ export interface JobSelectorControlProps {
* Available options to select. By default suggest all existing jobs.
*/
options?: Array<EuiComboBoxOptionOption<string>>;
/**
* Flag to indicate whether to use the job creation button in the empty prompt or the dropdown when no jobs are available.
*/
shouldUseDropdownJobCreate?: boolean;
}
export const JobSelectorControl: FC<JobSelectorControlProps> = ({
@ -55,6 +60,7 @@ export const JobSelectorControl: FC<JobSelectorControlProps> = ({
allowSelectAll = false,
createJobUrl,
options: defaultOptions,
shouldUseDropdownJobCreate = false,
}) => {
const {
services: {
@ -66,6 +72,7 @@ export const JobSelectorControl: FC<JobSelectorControlProps> = ({
const isMounted = useMountedState();
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
const [areJobsLoading, setAreJobsLoading] = useState<boolean>(false);
const jobIds = useMemo(() => new Set(), []);
const groupIds = useMemo(() => new Set(), []);
@ -78,6 +85,7 @@ export const JobSelectorControl: FC<JobSelectorControlProps> = ({
);
const fetchOptions = useCallback(async () => {
setAreJobsLoading(true);
try {
const { jobIds: jobIdOptions, groupIds: groupIdOptions } =
await adJobsApiService.getAllJobAndGroupIds();
@ -147,6 +155,7 @@ export const JobSelectorControl: FC<JobSelectorControlProps> = ({
}),
});
}
setAreJobsLoading(false);
}, [
adJobsApiService,
allowSelectAll,
@ -200,7 +209,9 @@ export const JobSelectorControl: FC<JobSelectorControlProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [createJobUrl]);
return (
if (areJobsLoading === true) return <LoadingIndicator />;
return jobIds.size || shouldUseDropdownJobCreate ? (
<EuiFormRow
data-test-subj="mlAnomalyJobSelectionControls"
fullWidth
@ -225,5 +236,32 @@ export const JobSelectorControl: FC<JobSelectorControlProps> = ({
isInvalid={!!errors?.length}
/>
</EuiFormRow>
) : (
<EuiEmptyPrompt
data-test-subj="mlAnomalyJobSelectionControls"
titleSize="xxs"
iconType="warning"
title={
<h4>
<FormattedMessage
id="xpack.ml.embeddables.jobSelector.noJobsFoundTitle"
defaultMessage="No anomaly detection jobs found"
/>
</h4>
}
body={
<EuiButton
fill
color="primary"
onClick={() => navigateToUrl(createJobUrl!)}
disabled={createJobUrl === undefined}
>
<FormattedMessage
id="xpack.ml.embeddables.jobSelector.createJobButtonLabel"
defaultMessage="Create job"
/>
</EuiButton>
}
/>
);
};

View file

@ -123,6 +123,7 @@ const AnomalyDetectionJobsHealthRuleTrigger: FC<MlAnomalyAlertTriggerProps> = ({
defaultMessage="Include jobs or groups"
/>
}
shouldUseDropdownJobCreate
/>
<EuiSpacer size="m" />
@ -148,6 +149,7 @@ const AnomalyDetectionJobsHealthRuleTrigger: FC<MlAnomalyAlertTriggerProps> = ({
/>
}
options={excludeJobsOptions}
shouldUseDropdownJobCreate
/>
<EuiSpacer size="m" />

View file

@ -242,7 +242,9 @@ export const JobSelectorFlyoutContent: FC<JobSelectorFlyoutProps> = ({
</EuiButtonEmpty>
)}
</EuiFlexItem>
{withTimeRangeSelector && applyTimeRangeConfig !== undefined && (
{withTimeRangeSelector &&
applyTimeRangeConfig !== undefined &&
jobs.length !== 0 ? (
<EuiFlexItem grow={false}>
<EuiSwitch
label={i18n.translate(
@ -256,7 +258,7 @@ export const JobSelectorFlyoutContent: FC<JobSelectorFlyoutProps> = ({
data-test-subj="mlFlyoutJobSelectorSwitchApplyTimeRange"
/>
</EuiFlexItem>
)}
) : null}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
@ -278,19 +280,7 @@ export const JobSelectorFlyoutContent: FC<JobSelectorFlyoutProps> = ({
</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>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
@ -302,6 +292,20 @@ export const JobSelectorFlyoutContent: FC<JobSelectorFlyoutProps> = ({
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{jobs.length !== 0 ? (
<EuiButton
onClick={applySelection}
fill
isDisabled={newSelection.length === 0}
data-test-subj="mlFlyoutJobSelectorButtonApply"
>
{i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', {
defaultMessage: 'Apply',
})}
</EuiButton>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>

View file

@ -13,12 +13,11 @@ import { TimeRangeBar } from '../timerange_bar';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiTabbedContent,
EuiCallOut,
EuiButton,
EuiText,
LEFT_ALIGNMENT,
CENTER_ALIGNMENT,
SortableProperties,
@ -265,17 +264,20 @@ export function JobSelectorTable({
<Fragment>
<MlNodeAvailableWarningShared nodeAvailableCallback={setMlNodesAvailable} />
{jobs.length === 0 && (
<EuiCallOut
<EuiEmptyPrompt
titleSize="xs"
iconType="warning"
title={
<FormattedMessage
id="xpack.ml.jobSelector.noJobsFoundTitle"
defaultMessage="No anomaly detection jobs found"
/>
<h4>
<FormattedMessage
id="xpack.ml.jobSelector.noJobsFoundTitle"
defaultMessage="No anomaly detection jobs found"
/>
</h4>
}
iconType="iInCircle"
>
<EuiText textAlign="center">
body={
<EuiButton
fill
color="primary"
onClick={navigateToWizard}
disabled={mlCapabilities.canCreateJob === false || mlNodesAvailable === false}
@ -285,8 +287,8 @@ export function JobSelectorTable({
defaultMessage="Create job"
/>
</EuiButton>
</EuiText>
</EuiCallOut>
}
/>
)}
{jobs.length !== 0 && singleSelection === true && renderJobsTable()}
{jobs.length !== 0 && !singleSelection && renderTabs()}

View file

@ -101,57 +101,59 @@ export const AnomalyChartsInitializer: FC<AnomalyChartsInitializerProps> = ({
errors={jobIdsErrors}
/>
<EuiSpacer size="s" />
<EuiForm>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.anomalyChartsEmbeddable.panelTitleLabel"
defaultMessage="Panel title"
/>
}
isInvalid={!isPanelTitleValid}
>
<EuiFieldText
data-test-subj="panelTitleInput"
id="panelTitle"
name="panelTitle"
value={panelTitle}
onChange={(e) => {
titleManuallyChanged.current = true;
setPanelTitle(e.target.value);
}}
isInvalid={!isPanelTitleValid}
/>
</EuiFormRow>
<EuiFormRow
isInvalid={!isMaxSeriesToPlotValid}
error={
!isMaxSeriesToPlotValid ? (
{jobIds.length > 0 ? (
<EuiForm>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.anomalyChartsEmbeddable.maxSeriesToPlotError"
defaultMessage="Maximum number of series to plot must be between 1 and 50."
id="xpack.ml.anomalyChartsEmbeddable.panelTitleLabel"
defaultMessage="Panel title"
/>
) : undefined
}
label={
<FormattedMessage
id="xpack.ml.anomalyChartsEmbeddable.maxSeriesToPlotLabel"
defaultMessage="Maximum number of series to plot"
}
isInvalid={!isPanelTitleValid}
>
<EuiFieldText
data-test-subj="panelTitleInput"
id="panelTitle"
name="panelTitle"
value={panelTitle}
onChange={(e) => {
titleManuallyChanged.current = true;
setPanelTitle(e.target.value);
}}
isInvalid={!isPanelTitleValid}
/>
}
>
<EuiFieldNumber
data-test-subj="mlAnomalyChartsInitializerMaxSeries"
id="selectMaxSeriesToPlot"
name="selectMaxSeriesToPlot"
value={maxSeriesToPlot}
onChange={(e) => setMaxSeriesToPlot(parseInt(e.target.value, 10))}
min={1}
max={MAX_ANOMALY_CHARTS_ALLOWED}
/>
</EuiFormRow>
</EuiForm>
</EuiFormRow>
<EuiFormRow
isInvalid={!isMaxSeriesToPlotValid}
error={
!isMaxSeriesToPlotValid ? (
<FormattedMessage
id="xpack.ml.anomalyChartsEmbeddable.maxSeriesToPlotError"
defaultMessage="Maximum number of series to plot must be between 1 and 50."
/>
) : undefined
}
label={
<FormattedMessage
id="xpack.ml.anomalyChartsEmbeddable.maxSeriesToPlotLabel"
defaultMessage="Maximum number of series to plot"
/>
}
>
<EuiFieldNumber
data-test-subj="mlAnomalyChartsInitializerMaxSeries"
id="selectMaxSeriesToPlot"
name="selectMaxSeriesToPlot"
value={maxSeriesToPlot}
onChange={(e) => setMaxSeriesToPlot(parseInt(e.target.value, 10))}
min={1}
max={MAX_ANOMALY_CHARTS_ALLOWED}
/>
</EuiFormRow>
</EuiForm>
) : null}
</EuiFlyoutBody>
<EuiFlyoutFooter>

View file

@ -157,52 +157,58 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
}}
errors={jobIdsErrors}
/>
{jobIds.length > 0 ? (
<>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.panelTitleLabel"
defaultMessage="Panel title"
/>
}
isInvalid={!isPanelTitleValid}
fullWidth
>
<EuiFieldText
id="panelTitle"
name="panelTitle"
value={panelTitle}
onChange={(e) => {
titleManuallyChanged.current = true;
setPanelTitle(e.target.value);
}}
isInvalid={!isPanelTitleValid}
fullWidth
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.panelTitleLabel"
defaultMessage="Panel title"
/>
}
isInvalid={!isPanelTitleValid}
fullWidth
>
<EuiFieldText
id="panelTitle"
name="panelTitle"
value={panelTitle}
onChange={(e) => {
titleManuallyChanged.current = true;
setPanelTitle(e.target.value);
}}
isInvalid={!isPanelTitleValid}
fullWidth
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.setupModal.swimlaneTypeLabel"
defaultMessage="Swim lane type"
/>
}
fullWidth
>
<EuiButtonGroup
id="selectSwimlaneType"
name="selectSwimlaneType"
color="primary"
isFullWidth
legend={i18n.translate('xpack.ml.swimlaneEmbeddable.setupModal.swimlaneTypeLabel', {
defaultMessage: 'Swim lane type',
})}
options={swimlaneTypeOptions}
idSelected={swimlaneType}
onChange={(id) => setSwimlaneType(id as SwimlaneType)}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.setupModal.swimlaneTypeLabel"
defaultMessage="Swim lane type"
/>
}
fullWidth
>
<EuiButtonGroup
id="selectSwimlaneType"
name="selectSwimlaneType"
color="primary"
isFullWidth
legend={i18n.translate(
'xpack.ml.swimlaneEmbeddable.setupModal.swimlaneTypeLabel',
{
defaultMessage: 'Swim lane type',
}
)}
options={swimlaneTypeOptions}
idSelected={swimlaneType}
onChange={(id) => setSwimlaneType(id as SwimlaneType)}
/>
</EuiFormRow>
</>
) : null}
{swimlaneType === SWIMLANE_TYPE.VIEW_BY && (
<>

View file

@ -150,29 +150,31 @@ export const SingleMetricViewerInitializer: FC<SingleMetricViewerInitializerProp
}}
{...(errorMessage && { errors: [errorMessage] })}
/>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.singleMetricViewerEmbeddable.panelTitleLabel"
defaultMessage="Panel title"
/>
}
isInvalid={!isPanelTitleValid}
fullWidth
>
<EuiFieldText
data-test-subj="panelTitleInput"
id="panelTitle"
name="panelTitle"
value={panelTitle}
onChange={(e) => {
titleManuallyChanged.current = true;
setPanelTitle(e.target.value);
}}
{job?.job_id && jobId && jobId === job.job_id ? (
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.singleMetricViewerEmbeddable.panelTitleLabel"
defaultMessage="Panel title"
/>
}
isInvalid={!isPanelTitleValid}
fullWidth
/>
</EuiFormRow>
>
<EuiFieldText
data-test-subj="panelTitleInput"
id="panelTitle"
name="panelTitle"
value={panelTitle}
onChange={(e) => {
titleManuallyChanged.current = true;
setPanelTitle(e.target.value);
}}
isInvalid={!isPanelTitleValid}
fullWidth
/>
</EuiFormRow>
) : null}
<EuiSpacer />
{job?.job_id && jobId && jobId === job.job_id ? (
<SeriesControls