diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index c3cfc1167f3c..c7dab6f76bcd 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -7,6 +7,7 @@ import Credentials from './models/Credentials'; import Groups from './models/Groups'; import Hosts from './models/Hosts'; import InstanceGroups from './models/InstanceGroups'; +import Instances from './models/Instances'; import Inventories from './models/Inventories'; import InventoryScripts from './models/InventoryScripts'; import InventorySources from './models/InventorySources'; @@ -19,8 +20,8 @@ import NotificationTemplates from './models/NotificationTemplates'; import Organizations from './models/Organizations'; import ProjectUpdates from './models/ProjectUpdates'; import Projects from './models/Projects'; -import Root from './models/Root'; import Roles from './models/Roles'; +import Root from './models/Root'; import Schedules from './models/Schedules'; import SystemJobs from './models/SystemJobs'; import Teams from './models/Teams'; @@ -42,6 +43,7 @@ const CredentialsAPI = new Credentials(); const GroupsAPI = new Groups(); const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); +const InstancesAPI = new Instances(); const InventoriesAPI = new Inventories(); const InventoryScriptsAPI = new InventoryScripts(); const InventorySourcesAPI = new InventorySources(); @@ -54,8 +56,8 @@ const NotificationTemplatesAPI = new NotificationTemplates(); const OrganizationsAPI = new Organizations(); const ProjectUpdatesAPI = new ProjectUpdates(); const ProjectsAPI = new Projects(); -const RootAPI = new Root(); const RolesAPI = new Roles(); +const RootAPI = new Root(); const SchedulesAPI = new Schedules(); const SystemJobsAPI = new SystemJobs(); const TeamsAPI = new Teams(); @@ -78,6 +80,7 @@ export { GroupsAPI, HostsAPI, InstanceGroupsAPI, + InstancesAPI, InventoriesAPI, InventoryScriptsAPI, InventorySourcesAPI, @@ -90,8 +93,8 @@ export { OrganizationsAPI, ProjectUpdatesAPI, ProjectsAPI, - RootAPI, RolesAPI, + RootAPI, SchedulesAPI, SystemJobsAPI, TeamsAPI, diff --git a/awx/ui_next/src/api/models/InstanceGroups.js b/awx/ui_next/src/api/models/InstanceGroups.js index 94464e0f42a0..82704c95d156 100644 --- a/awx/ui_next/src/api/models/InstanceGroups.js +++ b/awx/ui_next/src/api/models/InstanceGroups.js @@ -4,6 +4,37 @@ class InstanceGroups extends Base { constructor(http) { super(http); this.baseUrl = '/api/v2/instance_groups/'; + + this.associateInstance = this.associateInstance.bind(this); + this.disassociateInstance = this.disassociateInstance.bind(this); + this.readInstanceOptions = this.readInstanceOptions.bind(this); + this.readInstances = this.readInstances.bind(this); + this.readJobs = this.readJobs.bind(this); + } + + associateInstance(instanceGroupId, instanceId) { + return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, { + id: instanceId, + }); + } + + disassociateInstance(instanceGroupId, instanceId) { + return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, { + id: instanceId, + disassociate: true, + }); + } + + readInstances(id, params) { + return this.http.get(`${this.baseUrl}${id}/instances/`, { params }); + } + + readInstanceOptions(id) { + return this.http.options(`${this.baseUrl}${id}/instances/`); + } + + readJobs(id) { + return this.http.get(`${this.baseUrl}${id}/jobs/`); } } diff --git a/awx/ui_next/src/api/models/Instances.js b/awx/ui_next/src/api/models/Instances.js new file mode 100644 index 000000000000..41fa06d5f750 --- /dev/null +++ b/awx/ui_next/src/api/models/Instances.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Instances extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/instances/'; + } +} + +export default Instances; diff --git a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx index f7a1b2da6730..f53ec6b6d4e7 100644 --- a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx +++ b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx @@ -8,11 +8,13 @@ import useRequest from '../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../util/qs'; import useSelected from '../../util/useSelected'; -const QS_CONFIG = getQSConfig('associate', { - page: 1, - page_size: 5, - order_by: 'name', -}); +const QS_CONFIG = (order_by = 'name') => { + return getQSConfig('associate', { + page: 1, + page_size: 5, + order_by, + }); +}; function AssociateModal({ i18n, @@ -23,6 +25,7 @@ function AssociateModal({ fetchRequest, optionsRequest, isModalOpen = false, + displayKey = 'name', }) { const history = useHistory(); const { selected, handleSelect } = useSelected([]); @@ -34,7 +37,10 @@ function AssociateModal({ isLoading, } = useRequest( useCallback(async () => { - const params = parseQueryString(QS_CONFIG, history.location.search); + const params = parseQueryString( + QS_CONFIG(displayKey), + history.location.search + ); const [ { data: { count, results }, @@ -52,7 +58,7 @@ function AssociateModal({ actionsResponse.data.actions?.GET || {} ).filter(key => actionsResponse.data.actions?.GET[key].filterable), }; - }, [fetchRequest, optionsRequest, history.location.search]), + }, [fetchRequest, optionsRequest, history.location.search, displayKey]), { items: [], itemCount: 0, @@ -112,6 +118,7 @@ function AssociateModal({ ]} > item.name) - .join(', '); + if (verifyCannotDisassociate) { + const itemsUnableToDisassociate = itemsToDisassociate + .filter(cannotDisassociate) + .map(item => item.name) + .join(', '); - if (itemsToDisassociate.some(cannotDisassociate)) { - return ( -
- {i18n._( - t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}` - )} -
- ); + if (itemsToDisassociate.some(cannotDisassociate)) { + return ( +
+ {i18n._( + t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}` + )} +
+ ); + } } + if (itemsToDisassociate.length) { return i18n._(t`Disassociate`); } return i18n._(t`Select a row to disassociate`); } - const isDisabled = - itemsToDisassociate.length === 0 || - itemsToDisassociate.some(cannotDisassociate); + let isDisabled = false; + if (verifyCannotDisassociate) { + isDisabled = + itemsToDisassociate.length === 0 || + itemsToDisassociate.some(cannotDisassociate); + } else { + isDisabled = itemsToDisassociate.length === 0; + } // NOTE: Once PF supports tooltips on disabled elements, // we can delete the extra
around the below. @@ -102,7 +111,7 @@ function DisassociateButton({ {itemsToDisassociate.map(item => ( - {item.name} + {item.hostname ? item.hostname : item.name}
))} diff --git a/awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx b/awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx new file mode 100644 index 000000000000..609938b57dc2 --- /dev/null +++ b/awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx @@ -0,0 +1,81 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Switch, Tooltip } from '@patternfly/react-core'; +import AlertModal from '../AlertModal'; +import ErrorDetail from '../ErrorDetail'; +import useRequest from '../../util/useRequest'; +import { InstancesAPI } from '../../api'; +import { useConfig } from '../../contexts/Config'; + +function InstanceToggle({ + className, + fetchInstances, + instance, + onToggle, + i18n, +}) { + const { me } = useConfig(); + const [isEnabled, setIsEnabled] = useState(instance.enabled); + const [showError, setShowError] = useState(false); + + const { result, isLoading, error, request: toggleInstance } = useRequest( + useCallback(async () => { + await InstancesAPI.update(instance.id, { enabled: !isEnabled }); + await fetchInstances(); + return !isEnabled; + }, [instance, isEnabled, fetchInstances]), + instance.enabled + ); + + useEffect(() => { + if (result !== isEnabled) { + setIsEnabled(result); + if (onToggle) { + onToggle(result); + } + } + }, [result, isEnabled, onToggle]); + + useEffect(() => { + if (error) { + setShowError(true); + } + }, [error]); + + return ( + <> + + + + {showError && error && !isLoading && ( + setShowError(false)} + > + {i18n._(t`Failed to toggle instance.`)} + + + )} + + ); +} + +export default withI18n()(InstanceToggle); diff --git a/awx/ui_next/src/components/InstanceToggle/InstanceToggle.test.jsx b/awx/ui_next/src/components/InstanceToggle/InstanceToggle.test.jsx new file mode 100644 index 000000000000..6ac9dbda7e74 --- /dev/null +++ b/awx/ui_next/src/components/InstanceToggle/InstanceToggle.test.jsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { InstancesAPI } from '../../api'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import InstanceToggle from './InstanceToggle'; + +jest.mock('../../api'); + +const mockInstance = { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + related: { + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-05T19:17:18.080033Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 100.0, + jobs_running: 0, + jobs_total: 67, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, +}; + +describe('', () => { + const onToggle = jest.fn(); + const fetchInstances = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should show toggle off', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + + await act(async () => { + wrapper.find('Switch').invoke('onChange')(); + }); + expect(InstancesAPI.update).toHaveBeenCalledWith(1, { + enabled: false, + }); + wrapper.update(); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(false); + expect(onToggle).toHaveBeenCalledWith(false); + expect(fetchInstances).toHaveBeenCalledTimes(1); + }); + + test('should show toggle on', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(false); + + await act(async () => { + wrapper.find('Switch').invoke('onChange')(); + }); + expect(InstancesAPI.update).toHaveBeenCalledWith(1, { + enabled: true, + }); + wrapper.update(); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + expect(onToggle).toHaveBeenCalledWith(true); + expect(fetchInstances).toHaveBeenCalledTimes(1); + }); + + test('should show error modal', async () => { + InstancesAPI.update.mockImplementation(() => { + throw new Error('nope'); + }); + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + + await act(async () => { + wrapper.find('Switch').invoke('onChange')(); + }); + wrapper.update(); + const modal = wrapper.find('AlertModal'); + expect(modal).toHaveLength(1); + expect(modal.prop('isOpen')).toEqual(true); + + act(() => { + modal.invoke('onClose')(); + }); + wrapper.update(); + expect(wrapper.find('AlertModal')).toHaveLength(0); + }); +}); diff --git a/awx/ui_next/src/components/InstanceToggle/index.js b/awx/ui_next/src/components/InstanceToggle/index.js new file mode 100644 index 000000000000..1f2723c528b2 --- /dev/null +++ b/awx/ui_next/src/components/InstanceToggle/index.js @@ -0,0 +1 @@ +export { default } from './InstanceToggle'; diff --git a/awx/ui_next/src/components/OptionsList/OptionsList.jsx b/awx/ui_next/src/components/OptionsList/OptionsList.jsx index b2242d720269..86abce000124 100644 --- a/awx/ui_next/src/components/OptionsList/OptionsList.jsx +++ b/awx/ui_next/src/components/OptionsList/OptionsList.jsx @@ -42,6 +42,7 @@ function OptionsList({ renderItemChip, isLoading, i18n, + displayKey, }) { return ( @@ -52,6 +53,7 @@ function OptionsList({ onRemove={item => deselectItem(item)} isReadOnly={readOnly} renderItemChip={renderItemChip} + displayKey={displayKey} /> )} i.id === item.id)} onSelect={() => selectItem(item)} onDeselect={() => deselectItem(item)} @@ -91,22 +93,24 @@ const Item = shape({ url: string, }); OptionsList.propTypes = { - value: arrayOf(Item).isRequired, - options: arrayOf(Item).isRequired, - optionCount: number.isRequired, - searchColumns: SearchColumns, - sortColumns: SortColumns, + deselectItem: func.isRequired, + displayKey: string, multiple: bool, + optionCount: number.isRequired, + options: arrayOf(Item).isRequired, qsConfig: QSConfig.isRequired, - selectItem: func.isRequired, - deselectItem: func.isRequired, renderItemChip: func, + searchColumns: SearchColumns, + selectItem: func.isRequired, + sortColumns: SortColumns, + value: arrayOf(Item).isRequired, }; OptionsList.defaultProps = { multiple: false, renderItemChip: null, searchColumns: [], sortColumns: [], + displayKey: 'name', }; export default withI18n()(OptionsList); diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx index 4c5c29597621..22f67c5e9469 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx @@ -6,7 +6,13 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useKebabifiedMenu } from '../../contexts/Kebabified'; -function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) { +function ToolbarAddButton({ + linkTo, + onClick, + i18n, + isDisabled, + defaultLabel = i18n._(t`Add`), +}) { const { isKebabified } = useKebabifiedMenu(); if (!linkTo && !onClick) { @@ -14,6 +20,7 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) { 'ToolbarAddButton requires either `linkTo` or `onClick` prop' ); } + if (isKebabified) { return ( - {i18n._(t`Add`)} + {defaultLabel} ); } if (linkTo) { return ( - + ); } return ( - ); } diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx index 4597bbd61c50..06c348a8b95f 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx @@ -21,7 +21,7 @@ import JobList from '../../components/JobList'; import InstanceGroupDetails from './InstanceGroupDetails'; import InstanceGroupEdit from './InstanceGroupEdit'; -import Instances from './Instances'; +import InstanceList from './Instances/InstanceList'; function InstanceGroup({ i18n, setBreadcrumb }) { const { id } = useParams(); @@ -123,7 +123,7 @@ function InstanceGroup({ i18n, setBreadcrumb }) { - + { +describe('', () => { let wrapper; test('should have data fetched and render 3 rows', async () => { diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx new file mode 100644 index 000000000000..f666cecd270c --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx @@ -0,0 +1,245 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useLocation, useParams } from 'react-router-dom'; +import 'styled-components/macro'; + +import DataListToolbar from '../../../components/DataListToolbar'; +import PaginatedDataList, { + ToolbarAddButton, +} from '../../../components/PaginatedDataList'; +import DisassociateButton from '../../../components/DisassociateButton'; +import AssociateModal from '../../../components/AssociateModal'; +import AlertModal from '../../../components/AlertModal'; +import ErrorDetail from '../../../components/ErrorDetail'; + +import useRequest, { + useDeleteItems, + useDismissableError, +} from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import { InstanceGroupsAPI, InstancesAPI } from '../../../api'; +import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs'; + +import InstanceListItem from './InstanceListItem'; + +const QS_CONFIG = getQSConfig('instance', { + page: 1, + page_size: 20, + order_by: 'hostname', +}); + +function InstanceList({ i18n }) { + const [isModalOpen, setIsModalOpen] = useState(false); + const location = useLocation(); + const { id: instanceGroupId } = useParams(); + + const { + result: { + instances, + count, + actions, + relatedSearchableKeys, + searchableKeys, + }, + error: contentError, + isLoading, + request: fetchInstances, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, responseActions] = await Promise.all([ + InstanceGroupsAPI.readInstances(instanceGroupId, params), + InstanceGroupsAPI.readInstanceOptions(instanceGroupId), + ]); + return { + instances: response.data.results, + count: response.data.count, + actions: responseActions.data.actions, + relatedSearchableKeys: ( + responseActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responseActions.data.actions?.GET || {} + ).filter(key => responseActions.data.actions?.GET[key].filterable), + }; + }, [location.search, instanceGroupId]), + { + instances: [], + count: 0, + actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + instances + ); + + useEffect(() => { + fetchInstances(); + }, [fetchInstances]); + + const { + isLoading: isDisassociateLoading, + deleteItems: disassociateInstances, + deletionError: disassociateError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(instance => + InstanceGroupsAPI.disassociateInstance(instanceGroupId, instance.id) + ) + ); + }, [instanceGroupId, selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchInstances, + } + ); + + const { request: handleAssociate, error: associateError } = useRequest( + useCallback( + async instancesToAssociate => { + await Promise.all( + instancesToAssociate.map(instance => + InstanceGroupsAPI.associateInstance(instanceGroupId, instance.id) + ) + ); + fetchInstances(); + }, + [instanceGroupId, fetchInstances] + ) + ); + + const handleDisassociate = async () => { + await disassociateInstances(); + setSelected([]); + }; + + const { error, dismissError } = useDismissableError( + associateError || disassociateError + ); + + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + + const fetchInstancesToAssociate = useCallback( + params => { + return InstancesAPI.read( + mergeParams(params, { not__rampart_groups__id: instanceGroupId }) + ); + }, + [instanceGroupId] + ); + + const readInstancesOptions = () => + InstanceGroupsAPI.readInstanceOptions(instanceGroupId); + + return ( + <> + ( + + setSelected(isSelected ? [...instances] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [ + setIsModalOpen(true)} + defaultLabel={i18n._(t`Associate`)} + />, + ] + : []), + , + ]} + emptyStateControls={ + canAdd ? ( + setIsModalOpen(true)} + /> + ) : null + } + /> + )} + renderItem={instance => ( + handleSelect(instance)} + isSelected={selected.some(row => row.id === instance.id)} + fetchInstances={fetchInstances} + /> + )} + /> + {isModalOpen && ( + setIsModalOpen(false)} + title={i18n._(t`Select Instances`)} + optionsRequest={readInstancesOptions} + displayKey="hostname" + /> + )} + {error && ( + + {associateError + ? i18n._(t`Failed to associate.`) + : i18n._(t`Failed to disassociate one or more instances.`)} + + + )} + + ); +} + +export default withI18n()(InstanceList); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.test.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.test.jsx new file mode 100644 index 000000000000..d2c7edf32d62 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.test.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { InstanceGroupsAPI } from '../../../api'; + +import InstanceList from './InstanceList'; + +jest.mock('../../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + instanceGroupId: 2, + }), +})); + +const instances = [ + { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + related: { + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + }, + { + id: 2, + type: 'instance', + url: '/api/v2/instances/2/', + related: { + jobs: '/api/v2/instances/2/jobs/', + instance_groups: '/api/v2/instances/2/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'foo', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: false, + }, + { + id: 3, + type: 'instance', + url: '/api/v2/instances/3/', + related: { + jobs: '/api/v2/instances/3/jobs/', + instance_groups: '/api/v2/instances/3/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'bar', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: false, + managed_by_policy: true, + }, +]; + +const options = { data: { actions: { POST: true } } }; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + count: instances.length, + results: instances, + }, + }); + InstanceGroupsAPI.readInstanceOptions.mockResolvedValue(options); + const history = createMemoryHistory({ + initialEntries: ['/instance_groups/1/instances'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should have data fetched', () => { + expect(wrapper.find('InstanceList').length).toBe(1); + }); + + test('should fetch instances from the api and render them in the list', () => { + expect(InstanceGroupsAPI.readInstances).toHaveBeenCalled(); + expect(InstanceGroupsAPI.readInstanceOptions).toHaveBeenCalled(); + expect(wrapper.find('InstanceListItem').length).toBe(3); + }); + + test('should show associate group modal when adding an existing group', () => { + wrapper.find('ToolbarAddButton').simulate('click'); + expect(wrapper.find('AssociateModal').length).toBe(1); + wrapper.find('ModalBoxCloseButton').simulate('click'); + expect(wrapper.find('AssociateModal').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.jsx new file mode 100644 index 000000000000..83f2fef75341 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import 'styled-components/macro'; +import { + Badge as PFBadge, + Progress, + ProgressMeasureLocation, + ProgressSize, + DataListAction, + DataListCheck, + DataListItem, + DataListItemRow, + DataListItemCells, +} from '@patternfly/react-core'; + +import _DataListCell from '../../../components/DataListCell'; +import InstanceToggle from '../../../components/InstanceToggle'; +import { Instance } from '../../../types'; + +const Unavailable = styled.span` + color: var(--pf-global--danger-color--200); +`; + +const DataListCell = styled(_DataListCell)` + white-space: nowrap; +`; + +const Badge = styled(PFBadge)` + margin-left: 8px; +`; + +const ListGroup = styled.span` + margin-left: 12px; + + &:first-of-type { + margin-left: 0; + } +`; + +function InstanceListItem({ + instance, + isSelected, + onSelect, + fetchInstances, + i18n, +}) { + const labelId = `check-action-${instance.id}`; + + function usedCapacity(item) { + if (item.enabled) { + return ( + + ); + } + return {i18n._(t`Unavailable`)}; + } + + return ( + + + + + + {instance.hostname} + , + + {i18n._(t`Type`)} + + {instance.managed_by_policy + ? i18n._(t`Auto`) + : i18n._(t`Manual`)} + + , + + + {i18n._(t`Running jobs`)} + {instance.jobs_running} + + + {i18n._(t`Total jobs`)} + {instance.jobs_total} + + , + + {usedCapacity(instance)} + , + ]} + /> + + + + + + ); +} +InstanceListItem.prototype = { + instance: Instance.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InstanceListItem); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.test.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.test.jsx new file mode 100644 index 000000000000..5e3a138f9065 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import InstanceListItem from './InstanceListItem'; + +const instance = [ + { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + related: { + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + }, +]; + +describe('', () => { + let wrapper; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('InstanceListItem').length).toBe(1); + }); + + test('should render the proper data instance', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect( + wrapper.find('PFDataListCell[aria-label="instance host name"]').text() + ).toBe('awx'); + expect(wrapper.find('Progress').prop('value')).toBe(40); + expect( + wrapper.find('PFDataListCell[aria-label="instance type"]').text() + ).toBe('TypeAuto'); + expect(wrapper.find('input#instances-1').prop('checked')).toBe(false); + }); + + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('input#instances-1').prop('checked')).toBe(true); + }); + + test('should display instance toggle', () => { + expect(wrapper.find('InstanceToggle').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx deleted file mode 100644 index b41760edd542..000000000000 --- a/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { Card, PageSection } from '@patternfly/react-core'; - -function Instances() { - return ( - - -
Instances
-
-
- ); -} - -export default Instances; diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/index.js b/awx/ui_next/src/screens/InstanceGroup/Instances/index.js index b018ebb04930..2567e3c8e749 100644 --- a/awx/ui_next/src/screens/InstanceGroup/Instances/index.js +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/index.js @@ -1 +1,2 @@ -export { default } from './Instances'; +export { default as InstanceList } from './InstanceList'; +export { default as InstanceListItem } from './InstanceListItem'; diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 5ee55ef5d970..e1fa5a516314 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -118,6 +118,11 @@ export const InstanceGroup = shape({ name: string.isRequired, }); +export const Instance = shape({ + id: number.isRequired, + name: string.isRequired, +}); + export const Label = shape({ id: number.isRequired, name: string.isRequired, diff --git a/awx/ui_next/testUtils/enzymeHelpers.jsx b/awx/ui_next/testUtils/enzymeHelpers.jsx index bc6666a8ba86..c9077ac00661 100644 --- a/awx/ui_next/testUtils/enzymeHelpers.jsx +++ b/awx/ui_next/testUtils/enzymeHelpers.jsx @@ -42,6 +42,7 @@ const defaultContexts = { ansible_version: null, custom_virtualenvs: [], version: null, + me: { is_superuser: true }, toJSON: () => '/config/', }, router: {