From 7a5cf4b81cd43ca5c4d20b8a7f096d0e859b8ded Mon Sep 17 00:00:00 2001 From: Michael Abashian Date: Mon, 17 Jun 2019 13:52:05 -0400 Subject: [PATCH] Add support for deleting templates on templates list (#266) Adds support for deleting templates from the templates list --- .../screens/OrganizationsList.test.jsx | 26 ++--- .../pages/Templates/TemplatesList.test.jsx | 97 ++++++++++++++++--- src/api/index.js | 8 +- src/api/models/JobTemplates.js | 10 ++ src/api/models/WorkflowJobTemplates.js | 10 ++ .../screens/OrganizationsList.jsx | 1 - src/pages/Templates/TemplatesList.jsx | 62 ++++++++++-- 7 files changed, 174 insertions(+), 40 deletions(-) create mode 100644 src/api/models/JobTemplates.js create mode 100644 src/api/models/WorkflowJobTemplates.js diff --git a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx index 90ed0743b4..cf0cbacfb9 100644 --- a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx @@ -1,6 +1,5 @@ import React from 'react'; -import { createMemoryHistory } from 'history'; -import { mountWithContexts } from '../../../enzymeHelpers'; +import { mountWithContexts, waitForElement } from '../../../enzymeHelpers'; import OrganizationsList, { _OrganizationsList } from '../../../../src/pages/Organizations/screens/OrganizationsList'; import { OrganizationsAPI } from '../../../../src/api'; @@ -122,25 +121,16 @@ describe('', () => { expect(fetchOrgs).toBeCalled(); }); - test('error is thrown when org not successfully deleted from api', async () => { - const history = createMemoryHistory({ - initialEntries: ['organizations?order_by=name&page=1&page_size=5'], - }); - wrapper = mountWithContexts( - , - { context: { router: { history } } } - ); - await wrapper.setState({ + test('error is shown when org not successfully deleted from api', async () => { + OrganizationsAPI.destroy = () => Promise.reject(); + wrapper = mountWithContexts(); + wrapper.find('OrganizationsList').setState({ organizations: mockAPIOrgsList.data.results, itemCount: 3, isInitialized: true, - selected: [...mockAPIOrgsList.data.results].push({ - name: 'Organization 6', - id: 'a', - }) + selected: mockAPIOrgsList.data.results.slice(0, 1) }); - wrapper.update(); - const component = wrapper.find('OrganizationsList'); - component.instance().handleOrgDelete(); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + await waitForElement(wrapper, 'Modal', (el) => el.props().isOpen === true && el.props().title === 'Error!'); }); }); diff --git a/__tests__/pages/Templates/TemplatesList.test.jsx b/__tests__/pages/Templates/TemplatesList.test.jsx index 38ed7263f8..e70f320190 100644 --- a/__tests__/pages/Templates/TemplatesList.test.jsx +++ b/__tests__/pages/Templates/TemplatesList.test.jsx @@ -1,38 +1,63 @@ import React from 'react'; import { mountWithContexts, waitForElement } from '../../enzymeHelpers'; import TemplatesList, { _TemplatesList } from '../../../src/pages/Templates/TemplatesList'; -import { UnifiedJobTemplatesAPI } from '../../../src/api'; +import { JobTemplatesAPI, UnifiedJobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../src/api'; jest.mock('../../../src/api'); const mockTemplates = [{ id: 1, - name: 'Template 1', + name: 'Job Template 1', url: '/templates/job_template/1', type: 'job_template', summary_fields: { - inventory: {}, - project: {}, + user_capabilities: { + delete: true + } } }, { id: 2, - name: 'Template 2', + name: 'Job Template 2', url: '/templates/job_template/2', type: 'job_template', summary_fields: { - inventory: {}, - project: {}, + user_capabilities: { + delete: true + } } }, { id: 3, - name: 'Template 3', + name: 'Job Template 3', url: '/templates/job_template/3', type: 'job_template', summary_fields: { - inventory: {}, - project: {}, + user_capabilities: { + delete: true + } + } +}, +{ + id: 4, + name: 'Workflow Job Template 1', + url: '/templates/workflow_job_template/4', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: true + } + } +}, +{ + id: 5, + name: 'Workflow Job Template 2', + url: '/templates/workflow_job_template/5', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: false + } } }]; @@ -60,10 +85,10 @@ describe('', () => { }); test('Templates are retrieved from the api and the components finishes loading', async (done) => { - const loadUnifiedJobTemplates = jest.spyOn(_TemplatesList.prototype, 'loadUnifiedJobTemplates'); + const loadTemplates = jest.spyOn(_TemplatesList.prototype, 'loadTemplates'); const wrapper = mountWithContexts(); await waitForElement(wrapper, 'TemplatesList', (el) => el.state('contentLoading') === true); - expect(loadUnifiedJobTemplates).toHaveBeenCalled(); + expect(loadTemplates).toHaveBeenCalled(); await waitForElement(wrapper, 'TemplatesList', (el) => el.state('contentLoading') === false); done(); }); @@ -84,7 +109,53 @@ describe('', () => { await waitForElement(wrapper, 'TemplatesList', (el) => el.state('contentLoading') === false); wrapper.find('Checkbox#select-all').props().onChange(true); expect(handleSelectAll).toBeCalled(); - await waitForElement(wrapper, 'TemplatesList', (el) => el.state('selected').length === 3); + await waitForElement(wrapper, 'TemplatesList', (el) => el.state('selected').length === 5); done(); }); + + test('delete button is disabled if user does not have delete capabilities on a selected template', async (done) => { + const wrapper = mountWithContexts(); + wrapper.find('TemplatesList').setState({ + templates: mockTemplates, + itemCount: 5, + isInitialized: true, + selected: mockTemplates.slice(0, 4) + }); + await waitForElement(wrapper, 'ToolbarDeleteButton * button', (el) => el.getDOMNode().disabled === false); + wrapper.find('TemplatesList').setState({ + selected: mockTemplates + }); + await waitForElement(wrapper, 'ToolbarDeleteButton * button', (el) => el.getDOMNode().disabled === true); + done(); + }); + + test('api is called to delete templates for each selected template.', () => { + JobTemplatesAPI.destroy = jest.fn(); + WorkflowJobTemplatesAPI.destroy = jest.fn(); + const wrapper = mountWithContexts(); + wrapper.find('TemplatesList').setState({ + templates: mockTemplates, + itemCount: 5, + isInitialized: true, + isModalOpen: true, + selected: mockTemplates.slice(0, 4) + }); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + expect(JobTemplatesAPI.destroy).toHaveBeenCalledTimes(3); + expect(WorkflowJobTemplatesAPI.destroy).toHaveBeenCalledTimes(1); + }); + + test('error is shown when template not successfully deleted from api', async () => { + JobTemplatesAPI.destroy = () => Promise.reject(); + const wrapper = mountWithContexts(); + wrapper.find('TemplatesList').setState({ + templates: mockTemplates, + itemCount: 1, + isInitialized: true, + isModalOpen: true, + selected: mockTemplates.slice(0, 1) + }); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + await waitForElement(wrapper, 'Modal', (el) => el.props().isOpen === true && el.props().title === 'Error!'); + }); }); diff --git a/src/api/index.js b/src/api/index.js index 418b150b04..57429507bb 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,5 +1,6 @@ import Config from './models/Config'; import InstanceGroups from './models/InstanceGroups'; +import JobTemplates from './models/JobTemplates'; import Jobs from './models/Jobs'; import Me from './models/Me'; import Organizations from './models/Organizations'; @@ -7,9 +8,11 @@ import Root from './models/Root'; import Teams from './models/Teams'; import UnifiedJobTemplates from './models/UnifiedJobTemplates'; import Users from './models/Users'; +import WorkflowJobTemplates from './models/WorkflowJobTemplates'; const ConfigAPI = new Config(); const InstanceGroupsAPI = new InstanceGroups(); +const JobTemplatesAPI = new JobTemplates(); const JobsAPI = new Jobs(); const MeAPI = new Me(); const OrganizationsAPI = new Organizations(); @@ -17,15 +20,18 @@ const RootAPI = new Root(); const TeamsAPI = new Teams(); const UnifiedJobTemplatesAPI = new UnifiedJobTemplates(); const UsersAPI = new Users(); +const WorkflowJobTemplatesAPI = new WorkflowJobTemplates(); export { ConfigAPI, InstanceGroupsAPI, + JobTemplatesAPI, JobsAPI, MeAPI, OrganizationsAPI, RootAPI, TeamsAPI, UnifiedJobTemplatesAPI, - UsersAPI + UsersAPI, + WorkflowJobTemplatesAPI }; diff --git a/src/api/models/JobTemplates.js b/src/api/models/JobTemplates.js new file mode 100644 index 0000000000..3ce27b70ff --- /dev/null +++ b/src/api/models/JobTemplates.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class JobTemplates extends Base { + constructor (http) { + super(http); + this.baseUrl = '/api/v2/job_templates/'; + } +} + +export default JobTemplates; diff --git a/src/api/models/WorkflowJobTemplates.js b/src/api/models/WorkflowJobTemplates.js new file mode 100644 index 0000000000..8f553d4eb1 --- /dev/null +++ b/src/api/models/WorkflowJobTemplates.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class WorkflowJobTemplates extends Base { + constructor (http) { + super(http); + this.baseUrl = '/api/v2/workflow_job_templates/'; + } +} + +export default WorkflowJobTemplates; diff --git a/src/pages/Organizations/screens/OrganizationsList.jsx b/src/pages/Organizations/screens/OrganizationsList.jsx index 7d72efba76..c9ee097649 100644 --- a/src/pages/Organizations/screens/OrganizationsList.jsx +++ b/src/pages/Organizations/screens/OrganizationsList.jsx @@ -83,7 +83,6 @@ class OrganizationsList extends Component { this.setState({ contentLoading: true, deletionError: false }); try { await Promise.all(selected.map((org) => OrganizationsAPI.destroy(org.id))); - this.setState({ selected: [] }); } catch (err) { this.setState({ deletionError: true }); } finally { diff --git a/src/pages/Templates/TemplatesList.jsx b/src/pages/Templates/TemplatesList.jsx index 673c940567..e904fe6846 100644 --- a/src/pages/Templates/TemplatesList.jsx +++ b/src/pages/Templates/TemplatesList.jsx @@ -7,11 +7,14 @@ import { PageSection, PageSectionVariants, } from '@patternfly/react-core'; -import { UnifiedJobTemplatesAPI } from '../../api'; +import { JobTemplatesAPI, UnifiedJobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api'; import { getQSConfig, parseNamespacedQueryString } from '../../util/qs'; +import AlertModal from '../../components/AlertModal'; import DatalistToolbar from '../../components/DataListToolbar'; -import PaginatedDataList from '../../components/PaginatedDataList'; +import PaginatedDataList, { + ToolbarDeleteButton +} from '../../components/PaginatedDataList'; import TemplateListItem from './components/TemplateListItem'; // The type value in const QS_CONFIG below does not have a space between job_template and @@ -28,28 +31,35 @@ class TemplatesList extends Component { super(props); this.state = { - contentError: false, contentLoading: true, + contentError: false, + deletionError: false, selected: [], templates: [], itemCount: 0, }; - this.loadUnifiedJobTemplates = this.loadUnifiedJobTemplates.bind(this); + this.loadTemplates = this.loadTemplates.bind(this); this.handleSelectAll = this.handleSelectAll.bind(this); this.handleSelect = this.handleSelect.bind(this); + this.handleTemplateDelete = this.handleTemplateDelete.bind(this); + this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); } componentDidMount () { - this.loadUnifiedJobTemplates(); + this.loadTemplates(); } componentDidUpdate (prevProps) { const { location } = this.props; if (location !== prevProps.location) { - this.loadUnifiedJobTemplates(); + this.loadTemplates(); } } + handleDeleteErrorClose () { + this.setState({ deletionError: false }); + } + handleSelectAll (isSelected) { const { templates } = this.state; const selected = isSelected ? [...templates] : []; @@ -65,7 +75,28 @@ class TemplatesList extends Component { } } - async loadUnifiedJobTemplates () { + async handleTemplateDelete () { + const { selected } = this.state; + + this.setState({ contentLoading: true, deletionError: false }); + 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; + })); + } catch (err) { + this.setState({ deletionError: true }); + } finally { + await this.loadTemplates(); + } + } + + async loadTemplates () { const { location } = this.props; const params = parseNamespacedQueryString(QS_CONFIG, location.search); @@ -88,6 +119,7 @@ class TemplatesList extends Component { const { contentError, contentLoading, + deletionError, templates, itemCount, selected, @@ -120,6 +152,14 @@ class TemplatesList extends Component { showExpandCollapse isAllSelected={isAllSelected} onSelectAll={this.handleSelectAll} + additionalControls={[ + + ]} /> )} renderItem={(template) => ( @@ -134,6 +174,14 @@ class TemplatesList extends Component { )} /> + + {i18n._(t`Failed to delete one or more template.`)} + ); }