From 08d934170464100cd08215018ce40509c68cc58a Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 8 Jun 2020 16:29:06 -0400 Subject: [PATCH 1/2] Adds lists and list items and delete functionality --- awx/ui_next/src/api/index.js | 3 + awx/ui_next/src/api/models/Applications.js | 10 + .../ApplicationsList/ApplicationList.test.jsx | 189 ++++++++++++++++++ .../ApplicationsList/ApplicationListItem.jsx | 102 ++++++++++ .../ApplicationListItem.test.jsx | 68 +++++++ .../ApplicationsList/ApplicationsList.jsx | 175 +++++++++++++++- awx/ui_next/src/types.js | 12 ++ 7 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 awx/ui_next/src/api/models/Applications.js create mode 100644 awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx create mode 100644 awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx create mode 100644 awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 36330716fd..f7567750d6 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -1,4 +1,5 @@ import AdHocCommands from './models/AdHocCommands'; +import Applications from './models/Applications'; import Config from './models/Config'; import CredentialInputSources from './models/CredentialInputSources'; import CredentialTypes from './models/CredentialTypes'; @@ -31,6 +32,7 @@ import WorkflowJobTemplates from './models/WorkflowJobTemplates'; import WorkflowJobs from './models/WorkflowJobs'; const AdHocCommandsAPI = new AdHocCommands(); +const ApplicationsAPI = new Applications(); const ConfigAPI = new Config(); const CredentialInputSourcesAPI = new CredentialInputSources(); const CredentialTypesAPI = new CredentialTypes(); @@ -64,6 +66,7 @@ const WorkflowJobsAPI = new WorkflowJobs(); export { AdHocCommandsAPI, + ApplicationsAPI, ConfigAPI, CredentialInputSourcesAPI, CredentialTypesAPI, diff --git a/awx/ui_next/src/api/models/Applications.js b/awx/ui_next/src/api/models/Applications.js new file mode 100644 index 0000000000..50b709bdca --- /dev/null +++ b/awx/ui_next/src/api/models/Applications.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Applications extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/applications/'; + } +} + +export default Applications; diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx new file mode 100644 index 0000000000..367886f2a9 --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { ApplicationsAPI } from '../../../api'; +import ApplicationsList from './ApplicationsList'; + +jest.mock('../../../api/models/Applications'); + +const applications = { + data: { + results: [ + { + id: 1, + name: 'Foo', + summary_fields: { + organization: { name: 'Org 1', id: 10 }, + user_capabilities: { edit: true, delete: true }, + }, + url: '', + organiation: 10, + }, + { + id: 2, + name: 'Bar', + summary_fields: { + organization: { name: 'Org 2', id: 20 }, + user_capabilities: { edit: true, delete: true }, + }, + url: '', + organization: 20, + }, + ], + count: 2, + }, +}; +const options = { data: { actions: { POST: true } } }; +describe('', () => { + let wrapper; + test('should mount properly', async () => { + ApplicationsAPI.read.mockResolvedValue(applications); + ApplicationsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); + }); + test('should have data fetched and render 2 rows', async () => { + ApplicationsAPI.read.mockResolvedValue(applications); + ApplicationsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); + expect(wrapper.find('ApplicationListItem').length).toBe(2); + expect(ApplicationsAPI.read).toBeCalled(); + expect(ApplicationsAPI.readOptions).toBeCalled(); + }); + + test('should delete item successfully', async () => { + ApplicationsAPI.read.mockResolvedValue(applications); + ApplicationsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); + + wrapper + .find('input#select-application-1') + .simulate('change', applications.data.results[0]); + + wrapper.update(); + + expect(wrapper.find('input#select-application-1').prop('checked')).toBe( + true + ); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + expect(ApplicationsAPI.destroy).toBeCalledWith( + applications.data.results[0].id + ); + }); + + test('should throw content error', async () => { + ApplicationsAPI.read.mockRejectedValue( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/applications/', + }, + data: 'An error occurred', + }, + }) + ); + ApplicationsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + + await waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('should render deletion error modal', async () => { + ApplicationsAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/applications/', + }, + data: 'An error occurred', + }, + }) + ); + ApplicationsAPI.read.mockResolvedValue(applications); + ApplicationsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); + + wrapper.find('input#select-application-1').simulate('change', 'a'); + + wrapper.update(); + + expect(wrapper.find('input#select-application-1').prop('checked')).toBe( + true + ); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + wrapper.update(); + + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); + + test('should not render add button', async () => { + ApplicationsAPI.read.mockResolvedValue(applications); + ApplicationsAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); + test('should not render edit button for first list item', async () => { + applications.data.results[0].summary_fields.user_capabilities.edit = false; + ApplicationsAPI.read.mockResolvedValue(applications); + ApplicationsAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); + expect( + wrapper + .find('ApplicationListItem') + .at(0) + .find('PencilAltIcon').length + ).toBe(0); + expect( + wrapper + .find('ApplicationListItem') + .at(1) + .find('PencilAltIcon').length + ).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx new file mode 100644 index 0000000000..ddd0df7bc0 --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { + Button, + DataListAction as _DataListAction, + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, + Tooltip, +} from '@patternfly/react-core'; + +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import { Application } from '../../../types'; +import DataListCell from '../../../components/DataListCell'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; + +function ApplicationListItem({ + application, + isSelected, + onSelect, + detailUrl, + i18n, +}) { + ApplicationListItem.propTypes = { + application: Application.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, + }; + + const labelId = `check-action-${application.id}`; + return ( + + + + + + {application.name} + + , + + + {application.summary_fields.organization.name} + + , + ]} + /> + + {application.summary_fields.user_capabilities.edit ? ( + + + + ) : ( + '' + )} + + + + ); +} +export default withI18n()(ApplicationListItem); diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx new file mode 100644 index 0000000000..0a53dd4cd8 --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import ApplicationListItem from './ApplicationListItem'; + +describe('', () => { + let wrapper; + const application = { + id: 1, + name: 'Foo', + summary_fields: { + organization: { id: 2, name: 'Organization' }, + user_capabilities: { edit: true }, + }, + }; + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('ApplicationListItem').length).toBe(1); + }); + test('should render the proper data', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect( + wrapper.find('DataListCell[aria-label="application name"]').text() + ).toBe('Foo'); + expect( + wrapper.find('DataListCell[aria-label="organization name"]').text() + ).toBe('Organization'); + expect(wrapper.find('input#select-application-1').prop('checked')).toBe( + false + ); + expect(wrapper.find('PencilAltIcon').length).toBe(1); + }); + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('input#select-application-1').prop('checked')).toBe( + true + ); + }); +}); diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx index 6fcf16bb73..f2869c5d2b 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx @@ -1,15 +1,180 @@ -import React from 'react'; -import { Card, PageSection } from '@patternfly/react-core'; +import React, { useCallback, useEffect } from 'react'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import { useLocation, useRouteMatch } from 'react-router-dom'; + +import { Card, PageSection } from '@patternfly/react-core'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import ErrorDetail from '../../../components/ErrorDetail'; +import AlertModal from '../../../components/AlertModal'; + +import DatalistToolbar from '../../../components/DataListToolbar'; +import { ApplicationsAPI } from '../../../api'; +import PaginatedDataList, { + ToolbarDeleteButton, + ToolbarAddButton, +} from '../../../components/PaginatedDataList'; +import useSelected from '../../../util/useSelected'; + +import ApplicationListItem from './ApplicationListItem'; + +const QS_CONFIG = getQSConfig('inventory', { + page: 1, + page_size: 20, + order_by: 'name', +}); +function ApplicationsList({ i18n }) { + const location = useLocation(); + const match = useRouteMatch(); + + const { + isLoading, + error, + request: fetchApplications, + result: { applications, itemCount, actions }, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + + const [response, actionsResponse] = await Promise.all([ + ApplicationsAPI.read(params), + ApplicationsAPI.readOptions(), + ]); + + return { + applications: response.data.results, + itemCount: response.data.count, + actions: actionsResponse.data.actions, + }; + }, [location]), + { + applications: [], + itemCount: 0, + actions: {}, + } + ); + + useEffect(() => { + fetchApplications(); + }, [fetchApplications]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + applications + ); + + const { + isLoading: deleteLoading, + deletionError, + deleteItems: handleDeleteApplications, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + await Promise.all(selected.map(({ id }) => ApplicationsAPI.destroy(id))); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchApplications, + } + ); + + const handleDelete = async () => { + await handleDeleteApplications(); + setSelected([]); + }; + + const canAdd = actions && actions.POST; -function ApplicationsList() { return ( <> -
Applications List
+ ( + + setSelected(isSelected ? [...applications] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [ + , + ] + : []), + , + ]} + /> + )} + renderItem={application => ( + handleSelect(application)} + isSelected={selected.some(row => row.id === application.id)} + /> + )} + emptyStateControls={ + canAdd && ( + + ) + } + />
+ + {i18n._(t`Failed to delete one or more applications.`)} + + ); } -export default ApplicationsList; +export default withI18n()(ApplicationsList); diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 52e07d53cb..5e60df659a 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -42,6 +42,18 @@ export const AccessRecord = shape({ type: string, }); +export const Application = shape({ + id: number.isRequired, + name: string.isRequired, + organization: number, + summary_fields: shape({ + organization: shape({ + id: number.isRequired, + name: string.isRequired, + }), + }), +}); + export const Organization = shape({ id: number.isRequired, name: string.isRequired, From f211c70e69906c57570088b31dc32d892804bbba Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 9 Jun 2020 17:41:38 -0400 Subject: [PATCH 2/2] fixes qs namespace, and location of proptypes --- .../ApplicationsList/ApplicationListItem.jsx | 24 +++++++++++++------ .../ApplicationsList/ApplicationsList.jsx | 22 +++++++++++------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx index ddd0df7bc0..470e4dc255 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx @@ -15,6 +15,7 @@ import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { PencilAltIcon } from '@patternfly/react-icons'; +import { formatDateString } from '../../../util/dates'; import { Application } from '../../../types'; import DataListCell from '../../../components/DataListCell'; @@ -25,6 +26,10 @@ const DataListAction = styled(_DataListAction)` grid-template-columns: 40px; `; +const Label = styled.b` + margin-right: 20px; +`; + function ApplicationListItem({ application, isSelected, @@ -32,13 +37,6 @@ function ApplicationListItem({ detailUrl, i18n, }) { - ApplicationListItem.propTypes = { - application: Application.isRequired, - detailUrl: string.isRequired, - isSelected: bool.isRequired, - onSelect: func.isRequired, - }; - const labelId = `check-action-${application.id}`; return ( {application.summary_fields.organization.name} , + + + {formatDateString(application.modified)} + , ]} /> ); } + +ApplicationListItem.propTypes = { + application: Application.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + export default withI18n()(ApplicationListItem); diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx index f2869c5d2b..5870c341a3 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx @@ -19,7 +19,7 @@ import useSelected from '../../../util/useSelected'; import ApplicationListItem from './ApplicationListItem'; -const QS_CONFIG = getQSConfig('inventory', { +const QS_CONFIG = getQSConfig('applications', { page: 1, page_size: 20, order_by: 'name', @@ -105,12 +105,8 @@ function ApplicationsList({ i18n }) { isDefault: true, }, { - name: i18n._(t`Created by (Username)`), - key: 'created_by__username', - }, - { - name: i18n._(t`Modified by (Username)`), - key: 'modified_by__username', + name: i18n._(t`Description`), + key: 'description', }, ]} toolbarSortColumns={[ @@ -118,6 +114,18 @@ function ApplicationsList({ i18n }) { name: i18n._(t`Name`), key: 'name', }, + { + name: i18n._(t`Created`), + key: 'created', + }, + { + name: i18n._(t`Organization`), + key: 'organization', + }, + { + name: i18n._(t`Description`), + key: 'description', + }, ]} renderToolbar={props => (