From 3a6528dc0d6d0ef814685af0ca93de4aff999395 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 10 Mar 2020 10:08:23 -0400 Subject: [PATCH 1/2] Adds toolbar --- awx/ui_next/src/api/models/JobTemplates.js | 8 + .../screens/Template/shared/SurveyList.jsx | 218 ++++++++++++++++-- .../Template/shared/SurveyList.test.jsx | 94 +++++++- .../Template/shared/SurveyListItem.jsx | 17 +- .../Template/shared/SurveyListItem.test.jsx | 4 +- .../screens/Template/shared/SurveyToolbar.jsx | 65 ++++++ .../Template/shared/SurveyToolbar.test.jsx | 87 +++++++ 7 files changed, 468 insertions(+), 25 deletions(-) create mode 100644 awx/ui_next/src/screens/Template/shared/SurveyToolbar.jsx create mode 100644 awx/ui_next/src/screens/Template/shared/SurveyToolbar.test.jsx diff --git a/awx/ui_next/src/api/models/JobTemplates.js b/awx/ui_next/src/api/models/JobTemplates.js index 98174bc1e7..03d2ab099a 100644 --- a/awx/ui_next/src/api/models/JobTemplates.js +++ b/awx/ui_next/src/api/models/JobTemplates.js @@ -74,6 +74,14 @@ class JobTemplates extends SchedulesMixin( readSurvey(id) { return this.http.get(`${this.baseUrl}${id}/survey_spec/`); } + + updateSurvey(id, survey = null) { + return this.http.post(`${this.baseUrl}${id}/survey_spec/`, survey); + } + + destroySurvey(id) { + return this.http.delete(`${this.baseUrl}${id}/survey_spec/`); + } } export default JobTemplates; diff --git a/awx/ui_next/src/screens/Template/shared/SurveyList.jsx b/awx/ui_next/src/screens/Template/shared/SurveyList.jsx index d5ae1a3c35..ca5b39055c 100644 --- a/awx/ui_next/src/screens/Template/shared/SurveyList.jsx +++ b/awx/ui_next/src/screens/Template/shared/SurveyList.jsx @@ -1,48 +1,230 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import useRequest, { useDeleteItems } from '@util/useRequest'; +import { Button } from '@patternfly/react-core'; -import useRequest from '@util/useRequest'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; +import ErrorDetail from '@components/ErrorDetail'; import { JobTemplatesAPI } from '@api'; +import ContentEmpty from '@components/ContentEmpty'; +import { getQSConfig } from '@util/qs'; +import AlertModal from '@components/AlertModal'; import SurveyListItem from './SurveyListItem'; +import SurveyToolbar from './SurveyToolbar'; + +const QS_CONFIG = getQSConfig('survey', { + page: 1, +}); + +function SurveyList({ template, i18n }) { + const [selected, setSelected] = useState([]); + const [surveyEnabled, setSurveyEnabled] = useState(template.survey_enabled); + const [showToggleError, setShowToggleError] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); -function SurveyList({ template }) { const { - result: questions, + result: { questions, name, description }, error: contentError, isLoading, request: fetchSurvey, } = useRequest( useCallback(async () => { const { - data: { spec }, + data: { spec = [], description: surveyDescription, name: surveyName }, } = await JobTemplatesAPI.readSurvey(template.id); - - return spec.map((s, index) => ({ ...s, id: index })); - }, [template.id]) + return { + questions: spec.map((s, index) => ({ ...s, id: index })), + description: surveyDescription, + name: surveyName, + }; + }, [template.id]), + { questions: [], name: '', description: '' } ); useEffect(() => { fetchSurvey(); }, [fetchSurvey]); - if (contentError) { - return ; - } - if (isLoading) { - return ; - } - return ( - questions?.length > 0 && - questions.map((question, index) => ( + const isAllSelected = + selected.length === questions?.length && selected.length > 0; + + const { + isLoading: isDeleteLoading, + deleteItems: deleteQuestions, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + if (isAllSelected) { + return JobTemplatesAPI.destroySurvey(template.id); + } + const surveyQuestions = []; + questions.forEach(q => { + if (!selected.some(s => s.id === q.id)) { + surveyQuestions.push(q); + } + }); + return JobTemplatesAPI.updateSurvey(template.id, { + name, + description, + spec: surveyQuestions, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected]), + { + qsConfig: QS_CONFIG, + fetchItems: fetchSurvey, + } + ); + const { + isToggleLoading, + error: toggleError, + request: toggleSurvey, + } = useRequest( + useCallback(async () => { + await JobTemplatesAPI.update(template.id, { + survey_enabled: !surveyEnabled, + }); + return setSurveyEnabled(!surveyEnabled); + }, [template, surveyEnabled]), + template.survey_enabled + ); + + useEffect(() => { + if (toggleError) { + setShowToggleError(true); + } + }, [toggleError]); + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...questions] : []); + }; + + const handleSelect = item => { + if (selected.some(s => s.id === item.id)) { + setSelected(selected.filter(s => s.id !== item.id)); + } else { + setSelected(selected.concat(item)); + } + }; + + const handleDelete = async () => { + await deleteQuestions(); + setIsDeleteModalOpen(false); + setSelected([]); + }; + const canEdit = template.summary_fields.user_capabilities.edit; + const canDelete = template.summary_fields.user_capabilities.delete; + + let content; + if ( + (isLoading || isToggleLoading || isDeleteLoading) && + questions?.length <= 0 + ) { + content = ; + } else if (contentError) { + content = ; + } else if (!questions || questions?.length <= 0) { + content = ( + + ); + } else { + content = questions?.map((question, index) => ( s.id === question.id)} + onSelect={() => handleSelect(question)} /> - )) + )); + } + return ( + <> + setIsDeleteModalOpen(true)} + /> + {content} + {isDeleteModalOpen && ( + { + setIsDeleteModalOpen(false); + setSelected([]); + }} + actions={[ + , + , + ]} + > +
{i18n._(t`This action will delete the following:`)}
+ {selected.map(question => ( + + {question.question_name} +
+
+ ))} +
+ )} + {deletionError && ( + + {i18n._(t`Failed to delete one or more jobs.`)} + + + )} + {toggleError && ( + setShowToggleError(false)} + > + {i18n._(t`Failed to toggle host.`)} + + + )} + ); } + export default withI18n()(SurveyList); diff --git a/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx b/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx index 271ddf39bb..6f3e3e7415 100644 --- a/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import SurveyList from './SurveyList'; import { JobTemplatesAPI } from '@api'; import mockJobTemplateData from './data.job_template.json'; @@ -10,7 +10,11 @@ jest.mock('@api/models/JobTemplates'); describe('', () => { beforeEach(() => { JobTemplatesAPI.readSurvey.mockResolvedValue({ - data: { spec: [{ question_name: 'Foo', type: 'text', default: 'Bar' }] }, + data: { + name: 'Survey', + description: 'description for survey', + spec: [{ question_name: 'Foo', type: 'text', default: 'Bar' }], + }, }); }); test('expect component to mount successfully', async () => { @@ -30,7 +34,9 @@ describe('', () => { ); }); expect(JobTemplatesAPI.readSurvey).toBeCalledWith(7); + wrapper.update(); + expect(wrapper.find('SurveyListItem').length).toBe(1); }); test('error in retrieving the survey throws an error', async () => { @@ -41,7 +47,91 @@ describe('', () => { ); }); + wrapper.update(); + expect(wrapper.find('ContentError').length).toBe(1); }); + test('can toggle survey on and off', async () => { + JobTemplatesAPI.update.mockResolvedValue(); + let wrapper; + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + expect(wrapper.find('Switch').length).toBe(1); + expect(wrapper.find('Switch').prop('isChecked')).toBe(false); + await act(async () => { + await wrapper.find('Switch').invoke('onChange')(true); + }); + + wrapper.update(); + + expect(wrapper.find('Switch').prop('isChecked')).toBe(true); + expect(JobTemplatesAPI.update).toBeCalledWith(7, { + survey_enabled: true, + }); + }); + + test('selectAll enables delete button and calls the api to delete properly', async () => { + let wrapper; + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'SurveyListItem'); + expect(wrapper.find('Button[variant="danger"]').prop('isDisabled')).toBe( + true + ); + + expect( + wrapper.find('Checkbox[aria-label="Select all"]').prop('isChecked') + ).toBe(false); + act(() => { + wrapper.find('Checkbox[aria-label="Select all"]').invoke('onChange')( + { target: { checked: true } }, + true + ); + }); + + wrapper.update(); + + expect( + wrapper.find('Checkbox[aria-label="Select all"]').prop('isChecked') + ).toBe(true); + + expect(wrapper.find('Button[variant="danger"]').prop('isDisabled')).toBe( + false + ); + act(() => { + wrapper.find('Button[variant="danger"]').invoke('onClick')(); + }); + + wrapper.update(); + + await act(() => + wrapper.find('Button[aria-label="confirm delete"]').invoke('onClick')() + ); + expect(JobTemplatesAPI.destroySurvey).toBeCalledWith(7); + }); +}); +describe('Survey with no questions', () => { + test('Survey with no questions renders empty state', async () => { + JobTemplatesAPI.readSurvey.mockResolvedValue({}); + let wrapper; + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + expect(wrapper.find('ContentEmpty').length).toBe(1); + expect(wrapper.find('SurveyListItem').length).toBe(0); + }); }); diff --git a/awx/ui_next/src/screens/Template/shared/SurveyListItem.jsx b/awx/ui_next/src/screens/Template/shared/SurveyListItem.jsx index f50b428c25..ca99cf17f7 100644 --- a/awx/ui_next/src/screens/Template/shared/SurveyListItem.jsx +++ b/awx/ui_next/src/screens/Template/shared/SurveyListItem.jsx @@ -28,11 +28,18 @@ const Button = styled(_Button)` padding-bottom: 0; padding-left: 0; `; -function SurveyListItem({ question, i18n, isLast, isFirst }) { +function SurveyListItem({ + question, + i18n, + isLast, + isFirst, + isChecked, + onSelect, +}) { return ( - + - + diff --git a/awx/ui_next/src/screens/Template/shared/SurveyListItem.test.jsx b/awx/ui_next/src/screens/Template/shared/SurveyListItem.test.jsx index 2c55648eee..9a98115617 100644 --- a/awx/ui_next/src/screens/Template/shared/SurveyListItem.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/SurveyListItem.test.jsx @@ -4,7 +4,7 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers'; import SurveyListItem from './SurveyListItem'; describe('', () => { - const item = { question_name: 'Foo', default: 'Bar', type: 'text' }; + const item = { question_name: 'Foo', default: 'Bar', type: 'text', id: 1 }; test('renders successfully', () => { let wrapper; act(() => { @@ -32,7 +32,7 @@ describe('', () => { let wrapper; act(() => { wrapper = mountWithContexts( - + ); }); const moveUp = wrapper diff --git a/awx/ui_next/src/screens/Template/shared/SurveyToolbar.jsx b/awx/ui_next/src/screens/Template/shared/SurveyToolbar.jsx new file mode 100644 index 0000000000..f3d880cefd --- /dev/null +++ b/awx/ui_next/src/screens/Template/shared/SurveyToolbar.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; + +import { + DataToolbar, + DataToolbarContent, + DataToolbarGroup, + DataToolbarItem, +} from '@patternfly/react-core/dist/umd/experimental'; +import { Switch, Checkbox, Button } from '@patternfly/react-core'; +import { ToolbarAddButton } from '@components/PaginatedDataList'; + +function SurveyToolbar({ + isAllSelected, + onSelectAll, + i18n, + surveyEnabled, + onToggleSurvey, + isDeleteDisabled, + onToggleDeleteModal, +}) { + return ( + + + + { + onSelectAll(isChecked); + }} + aria-label={i18n._(t`Select all`)} + id="select-all" + /> + + + onToggleSurvey(!surveyEnabled)} + /> + + + + + + + + + + + + ); +} + +export default withI18n()(SurveyToolbar); diff --git a/awx/ui_next/src/screens/Template/shared/SurveyToolbar.test.jsx b/awx/ui_next/src/screens/Template/shared/SurveyToolbar.test.jsx new file mode 100644 index 0000000000..1b708dc56f --- /dev/null +++ b/awx/ui_next/src/screens/Template/shared/SurveyToolbar.test.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import SurveyToolbar from './SurveyToolbar'; + +jest.mock('@api/models/JobTemplates'); + +describe('', () => { + test('delete Button is disabled', async () => { + let wrapper; + + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + expect(wrapper.find('Button[variant="danger"]').prop('isDisabled')).toBe( + true + ); + }); + + test('delete Button is enabled', async () => { + let wrapper; + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + expect( + wrapper.find('Checkbox[aria-label="Select all"]').prop('isChecked') + ).toBe(true); + expect(wrapper.find('Button[variant="danger"]').prop('isDisabled')).toBe( + false + ); + }); + + test('switch is off', async () => { + let wrapper; + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + expect(wrapper.find('Switch').length).toBe(1); + expect(wrapper.find('Switch').prop('isChecked')).toBe(false); + }); + + test('switch is on', async () => { + let wrapper; + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + expect(wrapper.find('Switch').length).toBe(1); + expect(wrapper.find('Switch').prop('isChecked')).toBe(true); + }); +}); From 65e988b44c3398b91e1fe6578303e2c4bacb41db Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 10 Mar 2020 11:39:37 -0400 Subject: [PATCH 2/2] Impoves test and removes uncessary condition --- .../screens/Template/shared/SurveyList.jsx | 5 +--- .../Template/shared/SurveyList.test.jsx | 15 ++++++------ .../Template/shared/SurveyToolbar.test.jsx | 24 +++++++++---------- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/awx/ui_next/src/screens/Template/shared/SurveyList.jsx b/awx/ui_next/src/screens/Template/shared/SurveyList.jsx index ca5b39055c..3758dbdba9 100644 --- a/awx/ui_next/src/screens/Template/shared/SurveyList.jsx +++ b/awx/ui_next/src/screens/Template/shared/SurveyList.jsx @@ -120,10 +120,7 @@ function SurveyList({ template, i18n }) { const canDelete = template.summary_fields.user_capabilities.delete; let content; - if ( - (isLoading || isToggleLoading || isDeleteLoading) && - questions?.length <= 0 - ) { + if (isLoading || isToggleLoading || isDeleteLoading) { content = ; } else if (contentError) { content = ; diff --git a/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx b/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx index 6f3e3e7415..c044eb9da4 100644 --- a/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx @@ -20,7 +20,7 @@ describe('', () => { test('expect component to mount successfully', async () => { let wrapper; await act(async () => { - wrapper = await mountWithContexts( + wrapper = mountWithContexts( ); }); @@ -29,21 +29,20 @@ describe('', () => { test('expect api to be called to get survey', async () => { let wrapper; await act(async () => { - wrapper = await mountWithContexts( + wrapper = mountWithContexts( ); }); expect(JobTemplatesAPI.readSurvey).toBeCalledWith(7); wrapper.update(); - expect(wrapper.find('SurveyListItem').length).toBe(1); }); test('error in retrieving the survey throws an error', async () => { JobTemplatesAPI.readSurvey.mockRejectedValue(new Error()); let wrapper; await act(async () => { - wrapper = await mountWithContexts( + wrapper = mountWithContexts( ); }); @@ -56,7 +55,7 @@ describe('', () => { JobTemplatesAPI.update.mockResolvedValue(); let wrapper; await act(async () => { - wrapper = await mountWithContexts( + wrapper = mountWithContexts( @@ -66,7 +65,7 @@ describe('', () => { expect(wrapper.find('Switch').length).toBe(1); expect(wrapper.find('Switch').prop('isChecked')).toBe(false); await act(async () => { - await wrapper.find('Switch').invoke('onChange')(true); + wrapper.find('Switch').invoke('onChange')(true); }); wrapper.update(); @@ -80,7 +79,7 @@ describe('', () => { test('selectAll enables delete button and calls the api to delete properly', async () => { let wrapper; await act(async () => { - wrapper = await mountWithContexts( + wrapper = mountWithContexts( @@ -127,7 +126,7 @@ describe('Survey with no questions', () => { JobTemplatesAPI.readSurvey.mockResolvedValue({}); let wrapper; await act(async () => { - wrapper = await mountWithContexts( + wrapper = mountWithContexts( ); }); diff --git a/awx/ui_next/src/screens/Template/shared/SurveyToolbar.test.jsx b/awx/ui_next/src/screens/Template/shared/SurveyToolbar.test.jsx index 1b708dc56f..0249332aab 100644 --- a/awx/ui_next/src/screens/Template/shared/SurveyToolbar.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/SurveyToolbar.test.jsx @@ -6,11 +6,11 @@ import SurveyToolbar from './SurveyToolbar'; jest.mock('@api/models/JobTemplates'); describe('', () => { - test('delete Button is disabled', async () => { + test('delete Button is disabled', () => { let wrapper; - await act(async () => { - wrapper = await mountWithContexts( + act(() => { + wrapper = mountWithContexts( ', () => { ); }); - test('delete Button is enabled', async () => { + test('delete Button is enabled', () => { let wrapper; - await act(async () => { - wrapper = await mountWithContexts( + act(() => { + wrapper = mountWithContexts( ', () => { ); }); - test('switch is off', async () => { + test('switch is off', () => { let wrapper; - await act(async () => { - wrapper = await mountWithContexts( + act(() => { + wrapper = mountWithContexts( ', () => { expect(wrapper.find('Switch').prop('isChecked')).toBe(false); }); - test('switch is on', async () => { + test('switch is on', () => { let wrapper; - await act(async () => { - wrapper = await mountWithContexts( + act(() => { + wrapper = mountWithContexts(