Merge pull request #6229 from AlexSCorey/5895-SurveyListToolbar

Adds SurveyList tool bar

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-03-10 17:13:46 +00:00 committed by GitHub
commit ecb7147614
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 464 additions and 25 deletions

View File

@ -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;

View File

@ -1,48 +1,227 @@
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 <ContentError error={contentError} />;
}
if (isLoading) {
return <ContentLoading />;
}
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) {
content = <ContentLoading />;
} else if (contentError) {
content = <ContentError error={contentError} />;
} else if (!questions || questions?.length <= 0) {
content = (
<ContentEmpty
title={i18n._(t`No Survey Questions Found`)}
message={i18n._(t`Please add survey questions.`)}
/>
);
} else {
content = questions?.map((question, index) => (
<SurveyListItem
key={question.id}
isLast={index === questions.length - 1}
isFirst={index === 0}
question={question}
isChecked={selected.some(s => s.id === question.id)}
onSelect={() => handleSelect(question)}
/>
))
));
}
return (
<>
<SurveyToolbar
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
surveyEnabled={surveyEnabled}
onToggleSurvey={toggleSurvey}
isDeleteDisabled={selected?.length === 0 || !canEdit || !canDelete}
onToggleDeleteModal={() => setIsDeleteModalOpen(true)}
/>
{content}
{isDeleteModalOpen && (
<AlertModal
variant="danger"
title={
isAllSelected
? i18n._(t`Delete Survey`)
: i18n._(t`Delete Questions`)
}
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setSelected([]);
}}
actions={[
<Button
key="delete"
variant="danger"
aria-label={i18n._(t`confirm delete`)}
onClick={handleDelete}
>
{i18n._(t`Delete`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`cancel delete`)}
onClick={() => {
setIsDeleteModalOpen(false);
setSelected([]);
}}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<div>{i18n._(t`This action will delete the following:`)}</div>
{selected.map(question => (
<span key={question.id}>
<strong>{question.question_name}</strong>
<br />
</span>
))}
</AlertModal>
)}
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more jobs.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
{toggleError && (
<AlertModal
variant="error"
title={i18n._(t`Error!`)}
isOpen={showToggleError && !isLoading}
onClose={() => setShowToggleError(false)}
>
{i18n._(t`Failed to toggle host.`)}
<ErrorDetail error={toggleError} />
</AlertModal>
)}
</>
);
}
export default withI18n()(SurveyList);

View File

@ -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('<SurveyList />', () => {
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,6 +34,7 @@ describe('<SurveyList />', () => {
);
});
expect(JobTemplatesAPI.readSurvey).toBeCalledWith(7);
wrapper.update();
expect(wrapper.find('SurveyListItem').length).toBe(1);
});
@ -41,7 +46,91 @@ describe('<SurveyList />', () => {
<SurveyList template={{ ...mockJobTemplateData, id: 'a' }} />
);
});
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 = mountWithContexts(
<SurveyList
template={{ ...mockJobTemplateData, survey_enabled: false }}
/>
);
});
expect(wrapper.find('Switch').length).toBe(1);
expect(wrapper.find('Switch').prop('isChecked')).toBe(false);
await act(async () => {
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 = mountWithContexts(
<SurveyList
template={{ ...mockJobTemplateData, survey_enabled: false }}
/>
);
});
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 = mountWithContexts(
<SurveyList template={mockJobTemplateData} />
);
});
expect(wrapper.find('ContentEmpty').length).toBe(1);
expect(wrapper.find('SurveyListItem').length).toBe(0);
});
});

View File

@ -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 (
<DataList aria-label={i18n._(t`Survey List`)}>
<DataListItem aria-labelledby={i18n._(t`Survey questions`)}>
<DataListItemRow>
<DataListItemRow css="padding-left:16px">
<DataListAction
id="sortQuestions"
aria-labelledby={i18n._(t`Sort question order`)}
@ -59,7 +66,11 @@ function SurveyListItem({ question, i18n, isLast, isFirst }) {
</StackItem>
</Stack>
</DataListAction>
<DataListCheck checked={false} aria-labelledby="survey check" />
<DataListCheck
checked={isChecked}
onChange={onSelect}
aria-labelledby="survey check"
/>
<DataListItemCells
dataListCells={[
<DataListCell key={question.question_name}>

View File

@ -4,7 +4,7 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers';
import SurveyListItem from './SurveyListItem';
describe('<SurveyListItem />', () => {
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('<SurveyListItem />', () => {
let wrapper;
act(() => {
wrapper = mountWithContexts(
<SurveyListItem question={item} isFirst isLast />
<SurveyListItem question={item} isChecked={false} isFirst isLast />
);
});
const moveUp = wrapper

View File

@ -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 (
<DataToolbar id="survey-toolbar">
<DataToolbarContent>
<DataToolbarItem>
<Checkbox
isChecked={isAllSelected}
onChange={isChecked => {
onSelectAll(isChecked);
}}
aria-label={i18n._(t`Select all`)}
id="select-all"
/>
</DataToolbarItem>
<DataToolbarItem>
<Switch
aria-label={i18n._(t`Survey Toggle`)}
id="survey-toggle"
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={surveyEnabled}
onChange={() => onToggleSurvey(!surveyEnabled)}
/>
</DataToolbarItem>
<DataToolbarGroup>
<DataToolbarItem>
<ToolbarAddButton linkTo="/" />
</DataToolbarItem>
<DataToolbarItem>
<Button
variant="danger"
isDisabled={isDeleteDisabled}
onClick={() => onToggleDeleteModal(true)}
>
{i18n._(t`Delete`)}
</Button>
</DataToolbarItem>
</DataToolbarGroup>
</DataToolbarContent>
</DataToolbar>
);
}
export default withI18n()(SurveyToolbar);

View File

@ -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('<SurveyToolbar />', () => {
test('delete Button is disabled', () => {
let wrapper;
act(() => {
wrapper = mountWithContexts(
<SurveyToolbar
isDeleteDisabled
onSelectAll={jest.fn()}
isAllSelected
onToggleDeleteModal={jest.fn()}
onToggleSurvey={jest.fn()}
/>
);
});
expect(wrapper.find('Button[variant="danger"]').prop('isDisabled')).toBe(
true
);
});
test('delete Button is enabled', () => {
let wrapper;
act(() => {
wrapper = mountWithContexts(
<SurveyToolbar
isDeleteDisabled={false}
onSelectAll={jest.fn()}
isAllSelected
onToggleDeleteModal={jest.fn()}
onToggleSurvey={jest.fn()}
/>
);
});
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', () => {
let wrapper;
act(() => {
wrapper = mountWithContexts(
<SurveyToolbar
surveyEnabled={false}
isDeleteDisabled={false}
onSelectAll={jest.fn()}
isAllSelected
onToggleDelete={jest.fn()}
onToggleSurvey={jest.fn()}
/>
);
});
expect(wrapper.find('Switch').length).toBe(1);
expect(wrapper.find('Switch').prop('isChecked')).toBe(false);
});
test('switch is on', () => {
let wrapper;
act(() => {
wrapper = mountWithContexts(
<SurveyToolbar
surveyEnabled
isDeleteDisabled={false}
onSelectAll={jest.fn()}
isAllSelected
onToggleDelete={jest.fn()}
onToggleSurvey={jest.fn()}
/>
);
});
expect(wrapper.find('Switch').length).toBe(1);
expect(wrapper.find('Switch').prop('isChecked')).toBe(true);
});
});