diff --git a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx index c3ef0ebff5..87451cf0e7 100644 --- a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import { useLocation, useHistory } from 'react-router-dom'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { CredentialsAPI } from '@api'; @@ -11,12 +11,8 @@ import PaginatedDataList, { ToolbarAddButton, ToolbarDeleteButton, } from '@components/PaginatedDataList'; -import { - getQSConfig, - parseQueryString, - replaceParams, - encodeNonDefaultQueryString, -} from '@util/qs'; +import useRequest, { useDeleteItems } from '@util/useRequest'; +import { getQSConfig, parseQueryString } from '@util/qs'; import { CredentialListItem } from '.'; const QS_CONFIG = getQSConfig('credential', { @@ -26,53 +22,59 @@ const QS_CONFIG = getQSConfig('credential', { }); function CredentialList({ i18n }) { - const [actions, setActions] = useState(null); - const [contentError, setContentError] = useState(null); - const [credentialCount, setCredentialCount] = useState(0); - const [credentials, setCredentials] = useState([]); - const [deletionError, setDeletionError] = useState(null); - const [hasContentLoading, setHasContentLoading] = useState(true); const [selected, setSelected] = useState([]); - const location = useLocation(); - const history = useHistory(); - const loadCredentials = async ({ search }) => { - const params = parseQueryString(QS_CONFIG, search); - setContentError(null); - setHasContentLoading(true); - try { - const [ - { - data: { count, results }, - }, - { - data: { actions: optionActions }, - }, - ] = await Promise.all([ + const { + result: { credentials, credentialCount, actions }, + error: contentError, + isLoading, + request: fetchCredentials, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [creds, credActions] = await Promise.all([ CredentialsAPI.read(params), - loadCredentialActions(), + CredentialsAPI.readOptions(), ]); - - setCredentials(results); - setCredentialCount(count); - setActions(optionActions); - } catch (error) { - setContentError(error); - } finally { - setHasContentLoading(false); + return { + credentials: creds.data.results, + credentialCount: creds.data.count, + actions: credActions.data.actions, + }; + }, [location]), + { + credentials: [], + credentialCount: 0, + actions: {}, } - }; + ); useEffect(() => { - loadCredentials(location); - }, [location]); // eslint-disable-line react-hooks/exhaustive-deps + fetchCredentials(); + }, [fetchCredentials]); - const loadCredentialActions = () => { - if (actions) { - return Promise.resolve({ data: { actions } }); + const isAllSelected = + selected.length > 0 && selected.length === credentials.length; + const { + isLoading: isDeleteLoading, + deleteItems: deleteCredentials, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all(selected.map(({ id }) => CredentialsAPI.destroy(id))); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchCredentials, } - return CredentialsAPI.readOptions(); + ); + + const handleDelete = async () => { + await deleteCredentials(); + setSelected([]); }; const handleSelectAll = isSelected => { @@ -87,45 +89,15 @@ function CredentialList({ i18n }) { } }; - const handleDelete = async () => { - setHasContentLoading(true); - - try { - await Promise.all( - selected.map(credential => CredentialsAPI.destroy(credential.id)) - ); - } catch (error) { - setDeletionError(error); - } - - adjustPagination(); - setSelected([]); - }; - - const adjustPagination = () => { - const params = parseQueryString(QS_CONFIG, location.search); - if (params.page > 1 && selected.length === credentials.length) { - const newParams = encodeNonDefaultQueryString( - QS_CONFIG, - replaceParams(params, { page: params.page - 1 }) - ); - history.push(`${location.pathname}?${newParams}`); - } else { - loadCredentials(location); - } - }; - const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = - selected.length > 0 && selected.length === credentials.length; return ( setDeletionError(null)} + onClose={clearDeletionError} > {i18n._(t`Failed to delete one or more credentials.`)} diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx index f175b8d2dc..6ac8aef809 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -17,8 +17,6 @@ import { getQSConfig, parseQueryString } from '@util/qs'; import AddDropDownButton from '@components/AddDropDownButton'; import InventoryListItem from './InventoryListItem'; -// The type value in const QS_CONFIG below does not have a space between job_inventory and -// workflow_job_inventory so the params sent to the API match what the api expects. const QS_CONFIG = getQSConfig('inventory', { page: 1, page_size: 20, diff --git a/awx/ui_next/src/screens/Job/JobList/JobList.jsx b/awx/ui_next/src/screens/Job/JobList/JobList.jsx index dc1fe1922a..1779febeed 100644 --- a/awx/ui_next/src/screens/Job/JobList/JobList.jsx +++ b/awx/ui_next/src/screens/Job/JobList/JobList.jsx @@ -1,5 +1,5 @@ -import React, { Component } from 'react'; -import { withRouter } from 'react-router-dom'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; @@ -19,6 +19,7 @@ import ErrorDetail from '@components/ErrorDetail'; import PaginatedDataList, { ToolbarDeleteButton, } from '@components/PaginatedDataList'; +import useRequest, { useDeleteItems } from '@util/useRequest'; import { getQSConfig, parseQueryString } from '@util/qs'; import JobListItem from './JobListItem'; @@ -34,257 +35,216 @@ const QS_CONFIG = getQSConfig( ['page', 'page_size', 'id'] ); -class JobList extends Component { - constructor(props) { - super(props); +function JobList({ i18n }) { + const [selected, setSelected] = useState([]); + const location = useLocation(); - this.state = { - hasContentLoading: true, - deletionError: null, - contentError: null, - selected: [], - jobs: [], - itemCount: 0, - }; - this.loadJobs = this.loadJobs.bind(this); - this.handleSelectAll = this.handleSelectAll.bind(this); - this.handleSelect = this.handleSelect.bind(this); - this.handleJobDelete = this.handleJobDelete.bind(this); - this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); - } - - componentDidMount() { - this.loadJobs(); - } - - componentDidUpdate(prevProps) { - const { location } = this.props; - if (location !== prevProps.location) { - this.loadJobs(); - } - } - - handleDeleteErrorClose() { - this.setState({ deletionError: null }); - } - - handleSelectAll(isSelected) { - const { jobs } = this.state; - const selected = isSelected ? [...jobs] : []; - this.setState({ selected }); - } - - handleSelect(item) { - const { selected } = this.state; - if (selected.some(s => s.id === item.id)) { - this.setState({ selected: selected.filter(s => s.id !== item.id) }); - } else { - this.setState({ selected: selected.concat(item) }); - } - } - - async handleJobDelete() { - const { selected, itemCount } = this.state; - this.setState({ hasContentLoading: true }); - try { - await Promise.all( - selected.map(({ type, id }) => { - let deletePromise; - switch (type) { - case 'job': - deletePromise = JobsAPI.destroy(id); - break; - case 'ad_hoc_command': - deletePromise = AdHocCommandsAPI.destroy(id); - break; - case 'system_job': - deletePromise = SystemJobsAPI.destroy(id); - break; - case 'project_update': - deletePromise = ProjectUpdatesAPI.destroy(id); - break; - case 'inventory_update': - deletePromise = InventoryUpdatesAPI.destroy(id); - break; - case 'workflow_job': - deletePromise = WorkflowJobsAPI.destroy(id); - break; - default: - break; - } - return deletePromise; - }) - ); - this.setState({ itemCount: itemCount - selected.length }); - } catch (err) { - this.setState({ deletionError: err }); - } finally { - await this.loadJobs(); - } - } - - async loadJobs() { - const { location } = this.props; - const params = parseQueryString(QS_CONFIG, location.search); - - this.setState({ contentError: null, hasContentLoading: true }); - try { + const { + result: { jobs, itemCount }, + error: contentError, + isLoading, + request: fetchJobs, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); const { data: { count, results }, } = await UnifiedJobsAPI.read(params); - this.setState({ + return { itemCount: count, jobs: results, - selected: [], - }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); + }; + }, [location]), + { + jobs: [], + itemCount: 0, } - } + ); - render() { - const { - contentError, - hasContentLoading, - deletionError, - jobs, - itemCount, - selected, - } = this.state; - const { match, i18n } = this.props; - const isAllSelected = - selected.length === jobs.length && selected.length > 0; - return ( - - - ( - , - ]} - /> - )} - renderItem={job => ( - this.handleSelect(job)} - isSelected={selected.some(row => row.id === job.id)} - /> - )} - /> - - - {i18n._(t`Failed to delete one or more jobs.`)} - - - - ); - } + useEffect(() => { + fetchJobs(); + }, [fetchJobs]); + + const isAllSelected = selected.length === jobs.length && selected.length > 0; + const { + isLoading: isDeleteLoading, + deleteItems: deleteJobs, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(({ type, id }) => { + switch (type) { + case 'job': + return JobsAPI.destroy(id); + case 'ad_hoc_command': + return AdHocCommandsAPI.destroy(id); + case 'system_job': + return SystemJobsAPI.destroy(id); + case 'project_update': + return ProjectUpdatesAPI.destroy(id); + case 'inventory_update': + return InventoryUpdatesAPI.destroy(id); + case 'workflow_job': + return WorkflowJobsAPI.destroy(id); + default: + return null; + } + }) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchJobs, + } + ); + + const handleJobDelete = async () => { + await deleteJobs(); + setSelected([]); + }; + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...jobs] : []); + }; + + const handleSelect = item => { + if (selected.some(s => s.id === item.id)) { + setSelected(selected.filter(s => s.id !== item.id)); + } else { + setSelected(selected.concat(item)); + } + }; + + return ( + + + ( + , + ]} + /> + )} + renderItem={job => ( + handleSelect(job)} + isSelected={selected.some(row => row.id === job.id)} + /> + )} + /> + + + {i18n._(t`Failed to delete one or more jobs.`)} + + + + ); } -export { JobList as _JobList }; -export default withI18n()(withRouter(JobList)); +export default withI18n()(JobList); diff --git a/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx b/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx index 7c1654dcd4..7379b4079a 100644 --- a/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx +++ b/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; - import { AdHocCommandsAPI, InventoryUpdatesAPI, @@ -87,58 +87,118 @@ UnifiedJobsAPI.read.mockResolvedValue({ data: { count: 3, results: mockResults }, }); +function waitForLoaded(wrapper) { + return waitForElement( + wrapper, + 'JobList', + el => el.find('ContentLoading').length === 0 + ); +} + describe('', () => { - test('initially renders succesfully', async done => { - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'JobList', - el => el.state('jobs').length === 6 - ); - - done(); + test('initially renders succesfully', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + expect(wrapper.find('JobListItem')).toHaveLength(6); }); - test('select makes expected state updates', async done => { + test('should select and un-select items', async () => { const [mockItem] = mockResults; - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'JobListItem', el => el.length === 6); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); - wrapper - .find('JobListItem') - .first() - .prop('onSelect')(mockItem); - expect(wrapper.find('JobList').state('selected').length).toEqual(1); + act(() => { + wrapper + .find('JobListItem') + .first() + .invoke('onSelect')(mockItem); + }); + wrapper.update(); + expect( + wrapper + .find('JobListItem') + .first() + .prop('isSelected') + ).toEqual(true); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(1); - wrapper - .find('JobListItem') - .first() - .prop('onSelect')(mockItem); - expect(wrapper.find('JobList').state('selected').length).toEqual(0); - - done(); + act(() => { + wrapper + .find('JobListItem') + .first() + .invoke('onSelect')(mockItem); + }); + wrapper.update(); + expect( + wrapper + .find('JobListItem') + .first() + .prop('isSelected') + ).toEqual(false); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(0); }); - test('select-all-delete makes expected state updates and api calls', async done => { + test('should select and deselect all', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(true); + }); + wrapper.update(); + wrapper.find('JobListItem'); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(6); + + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(false); + }); + wrapper.update(); + wrapper.find('JobListItem'); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(0); + }); + + test('should send all corresponding delete API requests', async () => { AdHocCommandsAPI.destroy = jest.fn(); InventoryUpdatesAPI.destroy = jest.fn(); JobsAPI.destroy = jest.fn(); ProjectUpdatesAPI.destroy = jest.fn(); SystemJobsAPI.destroy = jest.fn(); WorkflowJobsAPI.destroy = jest.fn(); - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'JobListItem', el => el.length === 6); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); - wrapper.find('DataListToolbar').prop('onSelectAll')(true); - expect(wrapper.find('JobList').state('selected').length).toEqual(6); + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(true); + }); + wrapper.update(); + wrapper.find('JobListItem'); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(6); - wrapper.find('DataListToolbar').prop('onSelectAll')(false); - expect(wrapper.find('JobList').state('selected').length).toEqual(0); - - wrapper.find('DataListToolbar').prop('onSelectAll')(true); - expect(wrapper.find('JobList').state('selected').length).toEqual(6); - - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); + }); expect(AdHocCommandsAPI.destroy).toHaveBeenCalledTimes(1); expect(InventoryUpdatesAPI.destroy).toHaveBeenCalledTimes(1); expect(JobsAPI.destroy).toHaveBeenCalledTimes(1); @@ -146,12 +206,12 @@ describe('', () => { expect(SystemJobsAPI.destroy).toHaveBeenCalledTimes(1); expect(WorkflowJobsAPI.destroy).toHaveBeenCalledTimes(1); - done(); + jest.restoreAllMocks(); }); - test('error is shown when job not successfully deleted from api', async done => { - JobsAPI.destroy.mockRejectedValue( - new Error({ + test('error is shown when job not successfully deleted from api', async () => { + JobsAPI.destroy.mockImplementation(() => { + throw new Error({ response: { config: { method: 'delete', @@ -159,21 +219,29 @@ describe('', () => { }, data: 'An error occurred', }, - }) - ); - const wrapper = mountWithContexts(); - wrapper.find('JobList').setState({ - jobs: mockResults, - itemCount: 6, - selected: mockResults.slice(1, 2), + }); }); - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + await act(async () => { + wrapper + .find('JobListItem') + .at(1) + .invoke('onSelect')(); + }); + wrapper.update(); + + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); + }); + wrapper.update(); await waitForElement( wrapper, 'Modal', el => el.props().isOpen === true && el.props().title === 'Error!' ); - - done(); }); }); diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx index 02b176ffda..380011d545 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx @@ -1,11 +1,11 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { useLocation, useHistory, useRouteMatch } from 'react-router-dom'; +import { useLocation, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; import { OrganizationsAPI } from '@api'; -import useRequest from '@util/useRequest'; +import useRequest, { useDeleteItems } from '@util/useRequest'; import AlertModal from '@components/AlertModal'; import DataListToolbar from '@components/DataListToolbar'; import ErrorDetail from '@components/ErrorDetail'; @@ -13,13 +13,7 @@ import PaginatedDataList, { ToolbarAddButton, ToolbarDeleteButton, } from '@components/PaginatedDataList'; -import { - getQSConfig, - parseQueryString, - replaceParams, - encodeNonDefaultQueryString, -} from '@util/qs'; - +import { getQSConfig, parseQueryString } from '@util/qs'; import OrganizationListItem from './OrganizationListItem'; const QS_CONFIG = getQSConfig('organization', { @@ -30,11 +24,9 @@ const QS_CONFIG = getQSConfig('organization', { function OrganizationsList({ i18n }) { const location = useLocation(); - const history = useHistory(); const match = useRouteMatch(); const [selected, setSelected] = useState([]); - const [deletionError, setDeletionError] = useState(null); const addUrl = `${match.url}/add`; @@ -63,58 +55,40 @@ function OrganizationsList({ i18n }) { } ); - const { - isLoading: isDeleteLoading, - error: dError, - request: deleteOrganizations, - } = useRequest( - useCallback(async () => { - return Promise.all( - selected.map(({ id }) => OrganizationsAPI.destroy(id)) - ); - }, [selected]) - ); - - useEffect(() => { - if (dError) { - setDeletionError(dError); - } - }, [dError]); - useEffect(() => { fetchOrganizations(); }, [fetchOrganizations]); + const isAllSelected = + selected.length === organizations.length && selected.length > 0; + const { + isLoading: isDeleteLoading, + deleteItems: deleteOrganizations, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(({ id }) => OrganizationsAPI.destroy(id)) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchOrganizations, + } + ); + const handleOrgDelete = async () => { await deleteOrganizations(); - await adjustPagination(); setSelected([]); }; - const adjustPagination = () => { - const params = parseQueryString(QS_CONFIG, location.search); - if (params.page > 1 && selected.length === organizations.length) { - const newParams = encodeNonDefaultQueryString( - QS_CONFIG, - replaceParams(params, { page: params.page - 1 }) - ); - history.push(`${location.pathname}?${newParams}`); - } else { - fetchOrganizations(); - } - }; - const hasContentLoading = isDeleteLoading || isOrgsLoading; const canAdd = actions && actions.POST; - const isAllSelected = - selected.length === organizations.length && selected.length > 0; const handleSelectAll = isSelected => { - if (isSelected) { - setSelected(organizations); - } else { - setSelected([]); - } + setSelected(isSelected ? [...organizations] : []); }; const handleSelect = row => { @@ -197,7 +171,7 @@ function OrganizationsList({ i18n }) { isOpen={deletionError} variant="danger" title={i18n._(t`Error!`)} - onClose={() => setDeletionError(null)} + onClose={clearDeletionError} > {i18n._(t`Failed to delete one or more organizations.`)} diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 8937a2e364..cc688886b0 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -15,6 +15,7 @@ import ErrorDetail from '@components/ErrorDetail'; import PaginatedDataList, { ToolbarDeleteButton, } from '@components/PaginatedDataList'; +import useRequest, { useDeleteItems } from '@util/useRequest'; import { getQSConfig, parseQueryString } from '@util/qs'; import AddDropDownButton from '@components/AddDropDownButton'; @@ -31,82 +32,80 @@ const QS_CONFIG = getQSConfig('template', { function TemplateList({ i18n }) { const { id: projectId } = useParams(); - const { pathname, search } = useLocation(); + const location = useLocation(); - const [deletionError, setDeletionError] = useState(null); - const [contentError, setContentError] = useState(null); - const [hasContentLoading, setHasContentLoading] = useState(true); - const [jtActions, setJTActions] = useState(null); - const [wfjtActions, setWFJTActions] = useState(null); - const [count, setCount] = useState(0); - const [templates, setTemplates] = useState([]); const [selected, setSelected] = useState([]); - useEffect( - () => { - const loadTemplates = async () => { - const params = { - ...parseQueryString(QS_CONFIG, search), - }; - - let jtOptionsPromise; - if (jtActions) { - jtOptionsPromise = Promise.resolve({ - data: { actions: jtActions }, - }); - } else { - jtOptionsPromise = JobTemplatesAPI.readOptions(); - } - - let wfjtOptionsPromise; - if (wfjtActions) { - wfjtOptionsPromise = Promise.resolve({ - data: { actions: wfjtActions }, - }); - } else { - wfjtOptionsPromise = WorkflowJobTemplatesAPI.readOptions(); - } - if (pathname.startsWith('/projects') && projectId) { - params.jobtemplate__project = projectId; - } - - const promises = Promise.all([ - UnifiedJobTemplatesAPI.read(params), - jtOptionsPromise, - wfjtOptionsPromise, - ]); - setDeletionError(null); - - try { - const [ - { - data: { count: itemCount, results }, - }, - { - data: { actions: jobTemplateActions }, - }, - { - data: { actions: workFlowJobTemplateActions }, - }, - ] = await promises; - setJTActions(jobTemplateActions); - setWFJTActions(workFlowJobTemplateActions); - setCount(itemCount); - setTemplates(results); - setHasContentLoading(false); - } catch (err) { - setContentError(err); - } + const { + result: { templates, count, jtActions, wfjtActions }, + error: contentError, + isLoading, + request: fetchTemplates, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + if (location.pathname.startsWith('/projects') && projectId) { + params.jobtemplate__project = projectId; + } + const results = await Promise.all([ + UnifiedJobTemplatesAPI.read(params), + JobTemplatesAPI.readOptions(), + WorkflowJobTemplatesAPI.readOptions(), + ]); + return { + templates: results[0].data.results, + count: results[0].data.count, + jtActions: results[1].data.actions, + wfjtActions: results[2].data.actions, }; - loadTemplates(); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [pathname, search, count, projectId] + }, [location, projectId]), + { + templates: [], + count: 0, + jtActions: {}, + wfjtActions: {}, + } ); + useEffect(() => { + fetchTemplates(); + }, [fetchTemplates]); + + const isAllSelected = + selected.length === templates.length && selected.length > 0; + const { + isLoading: isDeleteLoading, + deleteItems: deleteTemplates, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(({ type, id }) => { + if (type === 'job_template') { + return JobTemplatesAPI.destroy(id); + } + if (type === 'workflow_job_template') { + return WorkflowJobTemplatesAPI.destroy(id); + } + return false; + }) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchTemplates, + } + ); + + const handleTemplateDelete = async () => { + await deleteTemplates(); + setSelected([]); + }; + const handleSelectAll = isSelected => { - const selectedItems = isSelected ? [...templates] : []; - setSelected(selectedItems); + setSelected(isSelected ? [...templates] : []); }; const handleSelect = template => { @@ -117,26 +116,6 @@ function TemplateList({ i18n }) { } }; - const handleTemplateDelete = async () => { - setHasContentLoading(true); - try { - await Promise.all( - selected.map(({ type, id }) => { - let deletePromise; - if (type === 'job_template') { - deletePromise = JobTemplatesAPI.destroy(id); - } else if (type === 'workflow_job_template') { - deletePromise = WorkflowJobTemplatesAPI.destroy(id); - } - return deletePromise; - }) - ); - setCount(count - selected.length); - } catch (err) { - setDeletionError(err); - } - }; - const canAddJT = jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); const canAddWFJT = @@ -154,8 +133,6 @@ function TemplateList({ i18n }) { url: `/templates/workflow_job_template/add/`, }); } - const isAllSelected = - selected.length === templates.length && selected.length > 0; const addButton = ( ); @@ -164,7 +141,7 @@ function TemplateList({ i18n }) { handleSelect(template)} isSelected={selected.some(row => row.id === template.id)} /> @@ -259,7 +236,7 @@ function TemplateList({ i18n }) { isOpen={deletionError} variant="danger" title={i18n._(t`Error!`)} - onClose={() => setDeletionError(null)} + onClose={clearDeletionError} > {i18n._(t`Failed to delete one or more templates.`)} diff --git a/awx/ui_next/src/util/useRequest.js b/awx/ui_next/src/util/useRequest.js index f6e9426dc8..64a62ebf45 100644 --- a/awx/ui_next/src/util/useRequest.js +++ b/awx/ui_next/src/util/useRequest.js @@ -1,4 +1,10 @@ import { useEffect, useState, useRef, useCallback } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; +import { + parseQueryString, + replaceParams, + encodeNonDefaultQueryString, +} from './qs'; /* * The useRequest hook accepts a request function and returns an object with @@ -47,3 +53,40 @@ export default function useRequest(makeRequest, initialValue) { }, [makeRequest]), }; } + +export function useDeleteItems( + makeRequest, + { qsConfig, allItemsSelected, fetchItems } +) { + const location = useLocation(); + const history = useHistory(); + const [showError, setShowError] = useState(false); + const { error, isLoading, request } = useRequest(makeRequest, null); + + useEffect(() => { + if (error) { + setShowError(true); + } + }, [error]); + + const deleteItems = async () => { + await request(); + const params = parseQueryString(qsConfig, location.search); + if (params.page > 1 && allItemsSelected) { + const newParams = encodeNonDefaultQueryString( + qsConfig, + replaceParams(params, { page: params.page - 1 }) + ); + history.push(`${location.pathname}?${newParams}`); + } else { + fetchItems(); + } + }; + + return { + isLoading, + deleteItems, + deletionError: showError && error, + clearDeletionError: () => setShowError(false), + }; +}