Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 95 additions & 69 deletions webpack/JobInvocationDetail/JobInvocationHostTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableInde
import { getControllerSearchProps } from 'foremanReact/constants';
import { Icon } from 'patternfly-react';
import PropTypes from 'prop-types';
import React, { useEffect, useMemo, useState, useRef } from 'react';
import React, {
useEffect,
useMemo,
useState,
useRef,
useCallback,
} from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { useForemanSettings } from 'foremanReact/Root/Context/ForemanContext';
Expand Down Expand Up @@ -93,19 +99,22 @@ const JobInvocationHostTable = ({
});

// Search filter
const constructFilter = (filter = initialFilter, search = urlSearchQuery) => {
const dropdownFilterClause =
filter && filter !== 'all_statuses'
? `job_invocation.result = ${filter}`
: null;
const parts = [dropdownFilterClause, search];
return parts
.filter(x => x)
.map(fragment => `(${fragment})`)
.join(' AND ');
};
const constructFilter = useCallback(
(filter = initialFilter, search = urlSearchQuery) => {
const dropdownFilterClause =
filter && filter !== 'all_statuses'
? `job_invocation.result = ${filter}`
: null;
const parts = [dropdownFilterClause, search];
return parts
.filter(x => x)
.map(fragment => `(${fragment})`)
.join(' AND ');
},
[initialFilter, urlSearchQuery]
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prevents filterApiCall from changing on every render.


const handleResponse = (data, key) => {
const handleResponse = useCallback((data, key) => {
if (key === JOB_INVOCATION_HOSTS) {
const ids = data.data.results.map(i => i.id);

Expand All @@ -114,76 +123,94 @@ const JobInvocationHostTable = ({
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stabilizes reference for use in makeApiCall dependency array.


setStatus(STATUS_UPPERCASE.RESOLVED);
};
}, []);

// Call hosts data with params
const makeApiCall = (requestParams, callParams = {}) => {
dispatch(
APIActions.get({
key: callParams.key ?? ALL_JOB_HOSTS,
url: callParams.url ?? `/api/job_invocations/${id}/hosts`,
params: requestParams,
handleSuccess: data => handleResponse(data, callParams.key),
handleError: () => setStatus(STATUS_UPPERCASE.ERROR),
errorToast: ({ response }) =>
response?.data?.error?.full_messages?.[0] || response,
})
);
};
const makeApiCall = useCallback(
(requestParams, callParams = {}) => {
dispatch(
APIActions.get({
key: callParams.key ?? ALL_JOB_HOSTS,
url: callParams.url ?? `/api/job_invocations/${id}/hosts`,
params: requestParams,
handleSuccess: data => handleResponse(data, callParams.key),
handleError: () => setStatus(STATUS_UPPERCASE.ERROR),
errorToast: ({ response }) =>
response?.data?.error?.full_messages?.[0] || response,
})
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used as a dependency in filterApiCall and initialization useEffect.

},
[dispatch, id, handleResponse]
);

const filterApiCall = newAPIOptions => {
const newParams = newAPIOptions?.params ?? newAPIOptions ?? {};
const filterApiCall = useCallback(
newAPIOptions => {
const newParams = newAPIOptions?.params ?? newAPIOptions ?? {};

const filterSearch = constructFilter(
initialFilter,
newParams.search ?? urlSearchQuery
);

const finalParams = {
...defaultParams,
...newParams,
};

if (filterSearch === AWAITING_STATUS_FILTER) {
finalParams.awaiting = 'true';
} else if (filterSearch !== '') {
finalParams.search = filterSearch;
}
const filterSearch = constructFilter(
initialFilter,
newParams.search ?? urlSearchQuery
);

makeApiCall(finalParams, { key: JOB_INVOCATION_HOSTS });
const finalParams = {
...defaultParams,
...newParams,
};

const urlSearchParams = new URLSearchParams(window.location.search);
if (filterSearch === AWAITING_STATUS_FILTER) {
finalParams.awaiting = 'true';
} else if (filterSearch !== '') {
finalParams.search = filterSearch;
}

['page', 'per_page', 'order'].forEach(key => {
if (finalParams[key]) urlSearchParams.set(key, finalParams[key]);
});
makeApiCall(finalParams, { key: JOB_INVOCATION_HOSTS });

history.push({ search: urlSearchParams.toString() });
};
const urlSearchParams = new URLSearchParams(window.location.search);

['page', 'per_page', 'order'].forEach(key => {
if (finalParams[key]) urlSearchParams.set(key, finalParams[key]);
});

history.push({ search: urlSearchParams.toString() });
},
[
initialFilter,
urlSearchQuery,
defaultParams,
makeApiCall,
history,
constructFilter,
]
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used as a dependency in useEffect below.


// Filter change
const handleFilterChange = newFilter => {
onFilterUpdate(newFilter);
};
const handleFilterChange = useCallback(
newFilter => {
onFilterUpdate(newFilter);
},
[onFilterUpdate]
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prevents unnecessary re-renders when passed to child components.


// Effects
// run after mount
const initializedRef = useRef(false);
useEffect(() => {
// Job Invo template load
makeApiCall(
{},
{
url: `/job_invocations/${id}/hosts`,
key: LIST_TEMPLATE_INVOCATIONS,
if (!initializedRef.current) {
// Job Invo template load
makeApiCall(
{},
{
url: `/job_invocations/${id}/hosts`,
key: LIST_TEMPLATE_INVOCATIONS,
}
);

if (initialFilter === '') {
onFilterUpdate('all_statuses');
}
);

if (initialFilter === '') {
onFilterUpdate('all_statuses');
initializedRef.current = true;
}

// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [makeApiCall, id, initialFilter, onFilterUpdate]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initializedRef pattern allows mount-only behavior while satisfying exhaustive-deps.


useEffect(() => {
if (initialFilter !== '') filterApiCall();
Expand All @@ -192,8 +219,7 @@ const JobInvocationHostTable = ({
prevStatusLabel.current = statusLabel;
filterApiCall();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialFilter, statusLabel, id]);
}, [initialFilter, statusLabel, id, filterApiCall]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without filterApiCall in deps, the effect could capture a stale closure.


const {
updateSearchQuery: updateSearchQueryBulk,
Expand Down
9 changes: 4 additions & 5 deletions webpack/JobInvocationDetail/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,14 @@ const JobInvocationDetailPage = ({
return () => {
dispatch(stopInterval(JOB_INVOCATION_KEY));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, id, finished, autoRefresh]);
Copy link
Member

@MariaAga MariaAga Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jeremylenz Here there are also functions that are used and not in the dependency array (getJobInvocation, stopInterval), should they also be added?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, if they are functions imported from another file or created from outside the component, the rule doesn't care about them.


const taskId = task?.id;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Complex expressions in dependency arrays can't be statically analyzed by the linter.

useEffect(() => {
if (task?.id !== undefined) {
dispatch(getTask(`${task?.id}`));
if (taskId !== undefined) {
dispatch(getTask(`${taskId}`));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, task?.id]);
}, [dispatch, taskId]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allows tracking task ID changes without depending on the entire task object.


const pageStatus =
items.id === undefined
Expand Down
21 changes: 11 additions & 10 deletions webpack/JobWizard/JobWizard.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,6 @@ export const JobWizard = ({ rerunData }) => {
concurrency_control = {},
},
}) => {
if (category !== job_category) {
setCategory(job_category);
}
const advancedTemplateValues = {};
const defaultTemplateValues = {};
const inputs = template_inputs;
Expand Down Expand Up @@ -131,8 +128,7 @@ export const JobWizard = ({ rerunData }) => {
};
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[category.length]
[setTemplateValues, setAdvancedValues]
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used as a dependency in two useEffect hooks below.

useEffect(() => {
if (rerunData) {
Expand All @@ -153,8 +149,7 @@ export const JobWizard = ({ rerunData }) => {
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rerunData]);
}, [rerunData, setDefaults]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without setDefaults in deps, the effect could use a stale version.

useEffect(() => {
if (jobTemplateID) {
dispatch(
Expand Down Expand Up @@ -199,8 +194,14 @@ export const JobWizard = ({ rerunData }) => {
})
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rerunData, jobTemplateID, dispatch]);
}, [
rerunData,
jobTemplateID,
dispatch,
setDefaults,
setTemplateValues,
setAdvancedValues,
]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three functions are called within the effect and need to be tracked to prevent stale closures.


const [isStartsBeforeError, setIsStartsBeforeError] = useState(false);
const [isStartsAtError, setIsStartsAtError] = useState(false);
Expand Down Expand Up @@ -514,7 +515,7 @@ JobWizard.propTypes = {
}),
execution_timeout_interval: PropTypes.number,
time_to_pickup: PropTypes.number,
remote_execution_feature_id: PropTypes.string,
remote_execution_feature_id: PropTypes.number,
template_invocations: PropTypes.arrayOf(
PropTypes.shape({
template_id: PropTypes.number,
Expand Down
12 changes: 10 additions & 2 deletions webpack/JobWizard/autofill.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ export const useAutoFill = ({
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fills]);
}, [
fills,
setFills,
setSelectedTargets,
setHostsSearchQuery,
setJobTemplateID,
setTemplateValues,
setAdvancedValues,
dispatch,
]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All setters and dispatch are called within the effect and must be tracked to prevent stale closures.

};
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Array [
"port": null,
"preventInvalidHostname": false,
"protocol": null,
"query": "resource=ForemanTasks%3A%3ATask&name=some+search",
"query": "resource=ForemanTasks%3A%3ATask",
"urn": null,
"username": null,
},
Expand Down
38 changes: 25 additions & 13 deletions webpack/JobWizard/steps/CategoryAndTemplate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,13 @@ const ConnectedCategoryAndTemplate = ({
})
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobCategoriesStatus]);
}, [
jobCategoriesStatus,
dispatch,
isCategoryPreselected,
setCategory,
setJobTemplate,
]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are used within the effect's callbacks and need to be tracked.


const jobCategories = useSelector(selectJobCategories);
const jobTemplatesSearch = useSelector(selectJobTemplatesSearch);
Expand All @@ -73,22 +78,29 @@ const ConnectedCategoryAndTemplate = ({
per_page: 'all',
}),
handleSuccess: response => {
if (!jobTemplate)
setJobTemplate(
current =>
current ||
Number(
filterJobTemplates(response?.data?.results)[0]?.id
) ||
null
);
const filteredTemplates = filterJobTemplates(
response?.data?.results
);
setJobTemplate(current => {
// Check if current template is in the new category's template list.
// This preserves the user's selection when changing categories on rerun,
// preventing the category from flashing and reverting back (Issue #38899).
// We check the state value (current) rather than the prop to avoid race conditions.
if (
current &&
filteredTemplates.some(template => template.id === current)
) {
return current;
}
// Otherwise, select the first template from the new category
return Number(filteredTemplates[0]?.id) || null;
});
},
})
);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [category, dispatch]);
}, [category, dispatch, jobTemplatesSearch, setJobTemplate]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: jobTemplate is intentionally NOT in deps. The functional setState handles undefined, and including it would cause unnecessary re-renders when the template changes.


const jobTemplates = useSelector(selectJobTemplates);

Expand Down
Loading