[ML] Fixing recognizer wizard input fields (#146325)

Fixes https://github.com/elastic/kibana/issues/144504

Removes the use of `usePartialState` which was causing an infinite
render loop in favour of individual `useStates` for the input settings.

Also contains a bit of code clean up, fixing hook dependencies and
removing dependency cache use.
This commit is contained in:
James Gowdy 2022-11-28 11:01:50 +00:00 committed by GitHub
parent 35e581534c
commit 9eee4c3d47
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 188 additions and 174 deletions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC, useEffect, useState } from 'react';
import React, { FC, useEffect, useState, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
@ -28,7 +28,6 @@ import {
patternValidator,
} from '../../../../../../common/util/validators';
import { JOB_ID_MAX_LENGTH } from '../../../../../../common/constants/validation';
import { usePartialState } from '../../../../components/custom_hooks';
import { TimeRange, TimeRangePicker } from '../../common/components';
export interface JobSettingsFormValues {
@ -41,61 +40,62 @@ export interface JobSettingsFormValues {
interface JobSettingsFormProps {
saveState: SAVE_STATE;
onSubmit: (values: JobSettingsFormValues) => any;
onChange: (values: JobSettingsFormValues) => any;
onSubmit: (values: JobSettingsFormValues) => void;
onJobPrefixChange: (jobPrefix: string) => void;
jobs: ModuleJobUI[];
}
export const JobSettingsForm: FC<JobSettingsFormProps> = ({
onSubmit,
onChange,
onJobPrefixChange,
saveState,
jobs,
}) => {
const { from, to } = getTimeFilterRange();
const { currentDataView: dataView } = useMlContext();
const jobPrefixValidator = composeValidators(
patternValidator(/^([a-z0-9]+[a-z0-9\-_]*)?$/),
maxLengthValidator(JOB_ID_MAX_LENGTH - Math.max(...jobs.map(({ id }) => id.length)))
const jobPrefixValidator = useMemo(
() =>
composeValidators(
patternValidator(/^([a-z0-9]+[a-z0-9\-_]*)?$/),
maxLengthValidator(JOB_ID_MAX_LENGTH - Math.max(...jobs.map(({ id }) => id.length)))
),
[jobs]
);
const [formState, setFormState] = usePartialState({
jobPrefix: '',
startDatafeedAfterSave: true,
useFullIndexData: true,
timeRange: {
start: from,
end: to,
},
useDedicatedIndex: false,
const [jobPrefix, setJobPrefix] = useState('');
const [startDatafeedAfterSave, setStartDatafeedAfterSave] = useState(true);
const [useFullIndexData, setUseFullIndexData] = useState(true);
const [timeRange, setTimeRange] = useState({
start: from,
end: to,
});
const [useDedicatedIndex, setUseDedicatedIndex] = useState(false);
const [validationResult, setValidationResult] = useState<Record<string, any>>({});
const onJobPrefixChange = (value: string) => {
setFormState({
jobPrefix: value && value.toLowerCase(),
});
const createJobSettings = () => {
return {
jobPrefix,
startDatafeedAfterSave,
useFullIndexData,
timeRange,
useDedicatedIndex,
};
};
const handleValidation = () => {
const jobPrefixValidationResult = jobPrefixValidator(formState.jobPrefix);
const handleValidation = useCallback(() => {
const jobPrefixValidationResult = jobPrefixValidator(jobPrefix);
setValidationResult({
jobPrefix: jobPrefixValidationResult,
formValid: !jobPrefixValidationResult,
});
};
}, [jobPrefix, jobPrefixValidator]);
useEffect(() => {
handleValidation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formState.jobPrefix]);
useEffect(() => {
onChange(formState);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formState]);
onJobPrefixChange(jobPrefix);
}, [handleValidation, jobPrefix, onJobPrefixChange]);
return (
<>
@ -150,8 +150,8 @@ export const JobSettingsForm: FC<JobSettingsFormProps> = ({
>
<EuiFieldText
name="jobPrefix"
value={formState.jobPrefix}
onChange={({ target: { value } }) => onJobPrefixChange(value)}
value={jobPrefix}
onChange={({ target: { value } }) => setJobPrefix(value)}
isInvalid={!!validationResult.jobPrefix}
/>
</EuiFormRow>
@ -167,11 +167,9 @@ export const JobSettingsForm: FC<JobSettingsFormProps> = ({
defaultMessage="Start datafeed after save"
/>
}
checked={formState.startDatafeedAfterSave}
checked={startDatafeedAfterSave}
onChange={({ target: { checked } }) => {
setFormState({
startDatafeedAfterSave: checked,
});
setStartDatafeedAfterSave(checked);
}}
/>
</EuiFormRow>
@ -186,24 +184,20 @@ export const JobSettingsForm: FC<JobSettingsFormProps> = ({
values={{ indexPatternTitle: dataView.title }}
/>
}
checked={formState.useFullIndexData}
checked={useFullIndexData}
onChange={({ target: { checked } }) => {
setFormState({
useFullIndexData: checked,
});
setUseFullIndexData(checked);
}}
/>
</EuiFormRow>
{!formState.useFullIndexData && (
{!useFullIndexData && (
<>
<EuiSpacer size="m" />
<TimeRangePicker
setTimeRange={(value) => {
setFormState({
timeRange: value,
});
setTimeRange(value);
}}
timeRange={formState.timeRange}
timeRange={timeRange}
/>
</>
)}
@ -241,11 +235,9 @@ export const JobSettingsForm: FC<JobSettingsFormProps> = ({
<EuiSwitch
id="useDedicatedIndex"
name="useDedicatedIndex"
checked={formState.useDedicatedIndex}
checked={useDedicatedIndex}
onChange={({ target: { checked } }) => {
setFormState({
useDedicatedIndex: checked,
});
setUseDedicatedIndex(checked);
}}
label={i18n.translate('xpack.ml.newJob.recognize.useDedicatedIndexLabel', {
defaultMessage: 'Use dedicated index',
@ -263,7 +255,7 @@ export const JobSettingsForm: FC<JobSettingsFormProps> = ({
isLoading={saveState === SAVE_STATE.SAVING}
disabled={!validationResult.formValid || saveState === SAVE_STATE.SAVING}
onClick={() => {
onSubmit(formState);
onSubmit(createJobSettings());
}}
aria-label={i18n.translate('xpack.ml.newJob.recognize.createJobButtonAriaLabel', {
defaultMessage: 'Create job',

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC, useState, Fragment, useEffect } from 'react';
import React, { FC, useState, Fragment, useEffect, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
@ -17,11 +17,10 @@ import {
EuiCallOut,
EuiPanel,
} from '@elastic/eui';
import { merge } from 'lodash';
import { isEqual, merge } from 'lodash';
import moment from 'moment';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import { useMlKibana, useMlLocator } from '../../../contexts/kibana';
import { ml } from '../../../services/ml_api_service';
import { useMlContext } from '../../../contexts/ml';
import {
DatafeedResponse,
@ -73,7 +72,12 @@ export enum SAVE_STATE {
export const Page: FC<PageProps> = ({ moduleId, existingGroupIds }) => {
const {
services: { notifications },
services: {
notifications,
mlServices: {
mlApiServices: { getTimeFieldRange, setupDataRecognizerConfig, getDataRecognizerModule },
},
},
} = useMlKibana();
const locator = useMlLocator();
@ -109,9 +113,9 @@ export const Page: FC<PageProps> = ({ moduleId, existingGroupIds }) => {
/**
* Loads recognizer module configuration.
*/
const loadModule = async () => {
const loadModule = useCallback(async () => {
try {
const response = await ml.getDataRecognizerModule({ moduleId });
const response = await getDataRecognizerModule({ moduleId });
setJobs(response.jobs);
const kibanaObjectsResult = await checkForSavedObjects(response.kibana as KibanaObjects);
@ -121,137 +125,157 @@ export const Page: FC<PageProps> = ({ moduleId, existingGroupIds }) => {
// mix existing groups from the server with the groups used across all jobs in the module.
const moduleGroups = [...response.jobs.map((j) => j.config.groups || [])].flat();
setExistingGroups([...new Set([...existingGroups, ...moduleGroups])]);
const newGroups = [...new Set([...existingGroups, ...moduleGroups])].sort();
if (!isEqual(newGroups, existingGroups)) {
setExistingGroups(newGroups);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
};
}, [existingGroups, getDataRecognizerModule, moduleId]);
const getTimeRange = async (
useFullIndexData: boolean,
timeRange: TimeRange
): Promise<TimeRange> => {
if (useFullIndexData) {
const runtimeMappings = dataView.getComputedFields().runtimeFields as RuntimeMappings;
const { start, end } = await ml.getTimeFieldRange({
index: dataView.title,
timeFieldName: dataView.timeFieldName,
// By default we want to use full non-frozen time range
query: addExcludeFrozenToQuery(combinedQuery),
...(isPopulatedObject(runtimeMappings) ? { runtimeMappings } : {}),
});
return {
start,
end,
};
} else {
return Promise.resolve(timeRange);
}
};
const getTimeRange = useCallback(
async (useFullIndexData: boolean, timeRange: TimeRange): Promise<TimeRange> => {
if (useFullIndexData) {
const runtimeMappings = dataView.getComputedFields().runtimeFields as RuntimeMappings;
const { start, end } = await getTimeFieldRange({
index: dataView.title,
timeFieldName: dataView.timeFieldName,
// By default we want to use full non-frozen time range
query: addExcludeFrozenToQuery(combinedQuery),
...(isPopulatedObject(runtimeMappings) ? { runtimeMappings } : {}),
});
return {
start,
end,
};
} else {
return Promise.resolve(timeRange);
}
},
[combinedQuery, dataView, getTimeFieldRange]
);
useEffect(() => {
loadModule();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [loadModule]);
/**
* Sets up recognizer module configuration.
*/
const save = async (formValues: JobSettingsFormValues) => {
setSaveState(SAVE_STATE.SAVING);
const {
jobPrefix: resultJobPrefix,
startDatafeedAfterSave,
useDedicatedIndex,
useFullIndexData,
timeRange,
} = formValues;
const resultTimeRange = await getTimeRange(useFullIndexData, timeRange);
try {
let jobOverridesPayload: JobOverride[] | null = Object.values(jobOverrides);
jobOverridesPayload = jobOverridesPayload.length > 0 ? jobOverridesPayload : null;
const response = await ml.setupDataRecognizerConfig({
moduleId,
prefix: resultJobPrefix,
query: tempQuery,
indexPatternName: dataView.title,
const save = useCallback(
async (formValues: JobSettingsFormValues) => {
setSaveState(SAVE_STATE.SAVING);
const {
jobPrefix: resultJobPrefix,
startDatafeedAfterSave,
useDedicatedIndex,
startDatafeed: startDatafeedAfterSave,
...(jobOverridesPayload !== null ? { jobOverrides: jobOverridesPayload } : {}),
...resultTimeRange,
});
const { datafeeds: datafeedsResponse, jobs: jobsResponse, kibana: kibanaResponse } = response;
useFullIndexData,
timeRange,
} = formValues;
setJobs(
jobs.map((job) => {
return {
...job,
datafeedResult: datafeedsResponse.find(({ id }) => id.endsWith(job.id)),
setupResult: jobsResponse.find(({ id }) => id === resultJobPrefix + job.id),
};
})
);
setKibanaObjects(merge(kibanaObjects, kibanaResponse));
const resultTimeRange = await getTimeRange(useFullIndexData, timeRange);
if (locator) {
const url = await locator.getUrl({
page: ML_PAGES.ANOMALY_EXPLORER,
pageState: {
jobIds: jobsResponse.filter(({ success }) => success).map(({ id }) => id),
timeRange: {
from: moment(resultTimeRange.start).format(TIME_FORMAT),
to: moment(resultTimeRange.end).format(TIME_FORMAT),
mode: 'absolute',
},
},
try {
let jobOverridesPayload: JobOverride[] | null = Object.values(jobOverrides);
jobOverridesPayload = jobOverridesPayload.length > 0 ? jobOverridesPayload : null;
const response = await setupDataRecognizerConfig({
moduleId,
prefix: resultJobPrefix,
query: tempQuery,
indexPatternName: dataView.title,
useDedicatedIndex,
startDatafeed: startDatafeedAfterSave,
...(jobOverridesPayload !== null ? { jobOverrides: jobOverridesPayload } : {}),
...resultTimeRange,
});
const {
datafeeds: datafeedsResponse,
jobs: jobsResponse,
kibana: kibanaResponse,
} = response;
setJobs(
jobs.map((job) => {
return {
...job,
datafeedResult: datafeedsResponse.find(({ id }) => id.endsWith(job.id)),
setupResult: jobsResponse.find(({ id }) => id === resultJobPrefix + job.id),
};
})
);
setKibanaObjects(merge(kibanaObjects, kibanaResponse));
if (locator) {
const url = await locator.getUrl({
page: ML_PAGES.ANOMALY_EXPLORER,
pageState: {
jobIds: jobsResponse.filter(({ success }) => success).map(({ id }) => id),
timeRange: {
from: moment(resultTimeRange.start).format(TIME_FORMAT),
to: moment(resultTimeRange.end).format(TIME_FORMAT),
mode: 'absolute',
},
},
});
setResultsUrl(url);
}
const failedJobsCount = jobsResponse.reduce(
(count, { success }) => (success ? count : count + 1),
0
);
const lazyJobsCount = datafeedsResponse.reduce(
(count, { awaitingMlNodeAllocation }) =>
awaitingMlNodeAllocation === true ? count + 1 : count,
0
);
setJobsAwaitingNodeCount(lazyJobsCount);
setSaveState(
failedJobsCount === 0
? SAVE_STATE.SAVED
: failedJobsCount === jobs.length
? SAVE_STATE.FAILED
: SAVE_STATE.PARTIAL_FAILURE
);
} catch (e) {
setSaveState(SAVE_STATE.FAILED);
// eslint-disable-next-line no-console
console.error('Error setting up module', e);
const { toasts } = notifications;
toasts.addDanger({
title: i18n.translate('xpack.ml.newJob.recognize.moduleSetupFailedWarningTitle', {
defaultMessage: 'Error setting up module {moduleId}',
values: { moduleId },
}),
text: i18n.translate('xpack.ml.newJob.recognize.moduleSetupFailedWarningDescription', {
defaultMessage:
'An error occurred trying to create the {count, plural, one {job} other {jobs}} in the module.',
values: {
count: jobs.length,
},
}),
});
setResultsUrl(url);
}
const failedJobsCount = jobsResponse.reduce(
(count, { success }) => (success ? count : count + 1),
0
);
const lazyJobsCount = datafeedsResponse.reduce(
(count, { awaitingMlNodeAllocation }) =>
awaitingMlNodeAllocation === true ? count + 1 : count,
0
);
setJobsAwaitingNodeCount(lazyJobsCount);
setSaveState(
failedJobsCount === 0
? SAVE_STATE.SAVED
: failedJobsCount === jobs.length
? SAVE_STATE.FAILED
: SAVE_STATE.PARTIAL_FAILURE
);
} catch (e) {
setSaveState(SAVE_STATE.FAILED);
// eslint-disable-next-line no-console
console.error('Error setting up module', e);
const { toasts } = notifications;
toasts.addDanger({
title: i18n.translate('xpack.ml.newJob.recognize.moduleSetupFailedWarningTitle', {
defaultMessage: 'Error setting up module {moduleId}',
values: { moduleId },
}),
text: i18n.translate('xpack.ml.newJob.recognize.moduleSetupFailedWarningDescription', {
defaultMessage:
'An error occurred trying to create the {count, plural, one {job} other {jobs}} in the module.',
values: {
count: jobs.length,
},
}),
});
}
};
},
[
dataView.title,
getTimeRange,
jobOverrides,
jobs,
kibanaObjects,
locator,
moduleId,
notifications,
setupDataRecognizerConfig,
tempQuery,
]
);
const onJobOverridesChange = (job: JobOverride) => {
setJobOverrides({
@ -321,9 +345,7 @@ export const Page: FC<PageProps> = ({ moduleId, existingGroupIds }) => {
{isFormVisible && (
<JobSettingsForm
onSubmit={save}
onChange={(formValues) => {
setJobPrefix(formValues.jobPrefix);
}}
onJobPrefixChange={setJobPrefix}
saveState={saveState}
jobs={jobs}
/>