Merge pull request #6268 from keithjgrant/survey-list-sort

Add survey list sorting

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-03-13 21:59:34 +00:00
committed by GitHub
10 changed files with 565 additions and 361 deletions

View File

@@ -54,7 +54,7 @@ class ErrorDetail extends Component {
const { response } = error; const { response } = error;
let message = ''; let message = '';
if (response.data) { if (response?.data) {
message = message =
typeof response.data === 'string' typeof response.data === 'string'
? response.data ? response.data
@@ -64,8 +64,8 @@ class ErrorDetail extends Component {
return ( return (
<Fragment> <Fragment>
<CardBody> <CardBody>
{response.config.method.toUpperCase()} {response.config.url}{' '} {response?.config?.method.toUpperCase()} {response?.config?.url}{' '}
<strong>{response.status}</strong> <strong>{response?.status}</strong>
</CardBody> </CardBody>
<CardBody>{message}</CardBody> <CardBody>{message}</CardBody>
</Fragment> </Fragment>

View File

@@ -15,7 +15,7 @@ import { ResourceAccessList } from '@components/ResourceAccessList';
import JobTemplateDetail from './JobTemplateDetail'; import JobTemplateDetail from './JobTemplateDetail';
import JobTemplateEdit from './JobTemplateEdit'; import JobTemplateEdit from './JobTemplateEdit';
import { JobTemplatesAPI, OrganizationsAPI } from '@api'; import { JobTemplatesAPI, OrganizationsAPI } from '@api';
import SurveyList from './shared/SurveyList'; import TemplateSurvey from './TemplateSurvey';
class Template extends Component { class Template extends Component {
constructor(props) { constructor(props) {
@@ -246,10 +246,9 @@ class Template extends Component {
</Route> </Route>
)} )}
{template && ( {template && (
<Route <Route path="/templates/:templateType/:id/survey">
path="/templates/:templateType/:id/survey" <TemplateSurvey template={template} />
render={() => <SurveyList template={template} />} </Route>
/>
)} )}
<Route <Route
key="not-found" key="not-found"

View File

@@ -0,0 +1,93 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Switch, Route } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { JobTemplatesAPI } from '@api';
import ContentError from '@components/ContentError';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import useRequest, { useDismissableError } from '@util/useRequest';
import SurveyList from './shared/SurveyList';
function TemplateSurvey({ template, i18n }) {
const [surveyEnabled, setSurveyEnabled] = useState(template.survey_enabled);
const {
result: survey,
request: fetchSurvey,
isLoading,
error: loadingError,
setValue: setSurvey,
} = useRequest(
useCallback(async () => {
const { data } = await JobTemplatesAPI.readSurvey(template.id);
return data;
}, [template.id])
);
useEffect(() => {
fetchSurvey();
}, [fetchSurvey]);
const { request: updateSurvey, error: updateError } = useRequest(
useCallback(
async updatedSurvey => {
await JobTemplatesAPI.updateSurvey(template.id, updatedSurvey);
setSurvey(updatedSurvey);
},
[template.id, setSurvey]
)
);
const { request: deleteSurvey, error: deleteError } = useRequest(
useCallback(async () => {
await JobTemplatesAPI.destroySurvey(template.id);
setSurvey(null);
}, [template.id, setSurvey])
);
const { request: toggleSurvey, error: toggleError } = useRequest(
useCallback(async () => {
await JobTemplatesAPI.update(template.id, {
survey_enabled: !surveyEnabled,
});
setSurveyEnabled(!surveyEnabled);
}, [template.id, surveyEnabled])
);
const { error, dismissError } = useDismissableError(
updateError || deleteError || toggleError
);
if (loadingError) {
return <ContentError error={loadingError} />;
}
return (
<>
<Switch>
<Route path="/templates/:templateType/:id/survey">
<SurveyList
isLoading={isLoading}
survey={survey}
surveyEnabled={surveyEnabled}
toggleSurvey={toggleSurvey}
updateSurvey={spec => updateSurvey({ ...survey, spec })}
deleteSurvey={deleteSurvey}
/>
</Route>
</Switch>
{error && (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissError}
>
{i18n._(t`Failed to update survey.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>
);
}
export default withI18n()(TemplateSurvey);

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import TemplateSurvey from './TemplateSurvey';
import { JobTemplatesAPI } from '@api';
import mockJobTemplateData from './shared/data.job_template.json';
jest.mock('@api/models/JobTemplates');
const surveyData = {
name: 'Survey',
description: 'description for survey',
spec: [
{ question_name: 'Foo', type: 'text', default: 'Bar', variable: 'foo' },
],
};
describe('<TemplateSurvey />', () => {
beforeEach(() => {
JobTemplatesAPI.readSurvey.mockResolvedValue({
data: surveyData,
});
});
test('should fetch survey from API', async () => {
const history = createMemoryHistory({
initialEntries: ['/templates/job_template/1/survey'],
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<TemplateSurvey template={mockJobTemplateData} />,
{
context: { router: { history } },
}
);
});
wrapper.update();
expect(JobTemplatesAPI.readSurvey).toBeCalledWith(7);
expect(wrapper.find('SurveyList').prop('survey')).toEqual(surveyData);
});
test('should display error in retrieving survey', async () => {
JobTemplatesAPI.readSurvey.mockRejectedValue(new Error());
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<TemplateSurvey template={{ ...mockJobTemplateData, id: 'a' }} />
);
});
wrapper.update();
expect(wrapper.find('ContentError').length).toBe(1);
});
test('should update API with survey changes', async () => {
const history = createMemoryHistory({
initialEntries: ['/templates/job_template/1/survey'],
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<TemplateSurvey template={mockJobTemplateData} />,
{
context: { router: { history } },
}
);
});
wrapper.update();
await act(async () => {
await wrapper.find('SurveyList').invoke('updateSurvey')([
{ question_name: 'Foo', type: 'text', default: 'One', variable: 'foo' },
{ question_name: 'Bar', type: 'text', default: 'Two', variable: 'bar' },
]);
});
expect(JobTemplatesAPI.updateSurvey).toHaveBeenCalledWith(7, {
name: 'Survey',
description: 'description for survey',
spec: [
{ question_name: 'Foo', type: 'text', default: 'One', variable: 'foo' },
{ question_name: 'Bar', type: 'text', default: 'Two', variable: 'bar' },
],
});
});
});

View File

@@ -1,129 +1,75 @@
import React, { useEffect, useCallback, useState } from 'react'; import React, { useState } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { DataList, Button } from '@patternfly/react-core';
import useRequest, { useDeleteItems } from '@util/useRequest';
import { Button } from '@patternfly/react-core';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading'; import ContentLoading from '@components/ContentLoading';
import ErrorDetail from '@components/ErrorDetail';
import { JobTemplatesAPI } from '@api';
import ContentEmpty from '@components/ContentEmpty'; import ContentEmpty from '@components/ContentEmpty';
import { getQSConfig } from '@util/qs';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import SurveyListItem from './SurveyListItem'; import SurveyListItem from './SurveyListItem';
import SurveyToolbar from './SurveyToolbar'; import SurveyToolbar from './SurveyToolbar';
const QS_CONFIG = getQSConfig('survey', { function SurveyList({
page: 1, isLoading,
}); survey,
surveyEnabled,
function SurveyList({ template, i18n }) { toggleSurvey,
updateSurvey,
deleteSurvey,
i18n,
}) {
const questions = survey?.spec || [];
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [surveyEnabled, setSurveyEnabled] = useState(template.survey_enabled);
const [showToggleError, setShowToggleError] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const {
result: { questions, name, description },
error: contentError,
isLoading,
request: fetchSurvey,
} = useRequest(
useCallback(async () => {
const {
data: { spec = [], description: surveyDescription, name: surveyName },
} = await JobTemplatesAPI.readSurvey(template.id);
return {
questions: spec.map((s, index) => ({ ...s, id: index })),
description: surveyDescription,
name: surveyName,
};
}, [template.id]),
{ questions: [], name: '', description: '' }
);
useEffect(() => {
fetchSurvey();
}, [fetchSurvey]);
const isAllSelected = const isAllSelected =
selected.length === questions?.length && selected.length > 0; 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 => { const handleSelectAll = isSelected => {
setSelected(isSelected ? [...questions] : []); setSelected(isSelected ? [...questions] : []);
}; };
const handleSelect = item => { const handleSelect = item => {
if (selected.some(s => s.id === item.id)) { if (selected.some(q => q.variable === item.variable)) {
setSelected(selected.filter(s => s.id !== item.id)); setSelected(selected.filter(q => q.variable !== item.variable));
} else { } else {
setSelected(selected.concat(item)); setSelected(selected.concat(item));
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
await deleteQuestions(); if (isAllSelected) {
await deleteSurvey();
} else {
await updateSurvey(questions.filter(q => !selected.includes(q)));
}
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setSelected([]); setSelected([]);
}; };
const canEdit = template.summary_fields.user_capabilities.edit;
const canDelete = template.summary_fields.user_capabilities.delete; const moveUp = question => {
const index = questions.indexOf(question);
if (index < 1) {
return;
}
const beginning = questions.slice(0, index - 1);
const swapWith = questions[index - 1];
const end = questions.slice(index + 1);
updateSurvey([...beginning, question, swapWith, ...end]);
};
const moveDown = question => {
const index = questions.indexOf(question);
if (index === -1 || index > questions.length - 1) {
return;
}
const beginning = questions.slice(0, index);
const swapWith = questions[index + 1];
const end = questions.slice(index + 2);
updateSurvey([...beginning, swapWith, question, ...end]);
};
let content; let content;
if (isLoading || isToggleLoading || isDeleteLoading) { if (isLoading) {
content = <ContentLoading />; content = <ContentLoading />;
} else if (contentError) {
content = <ContentError error={contentError} />;
} else if (!questions || questions?.length <= 0) { } else if (!questions || questions?.length <= 0) {
content = ( content = (
<ContentEmpty <ContentEmpty
@@ -132,17 +78,24 @@ function SurveyList({ template, i18n }) {
/> />
); );
} else { } else {
content = questions?.map((question, index) => ( content = (
<SurveyListItem <DataList aria-label={i18n._(t`Survey List`)}>
key={question.id} {questions?.map((question, index) => (
isLast={index === questions.length - 1} <SurveyListItem
isFirst={index === 0} key={question.variable}
question={question} isLast={index === questions.length - 1}
isChecked={selected.some(s => s.id === question.id)} isFirst={index === 0}
onSelect={() => handleSelect(question)} question={question}
/> isChecked={selected.some(q => q.variable === question.variable)}
)); onSelect={() => handleSelect(question)}
onMoveUp={moveUp}
onMoveDown={moveDown}
/>
))}
</DataList>
);
} }
return ( return (
<> <>
<SurveyToolbar <SurveyToolbar
@@ -150,7 +103,7 @@ function SurveyList({ template, i18n }) {
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
surveyEnabled={surveyEnabled} surveyEnabled={surveyEnabled}
onToggleSurvey={toggleSurvey} onToggleSurvey={toggleSurvey}
isDeleteDisabled={selected?.length === 0 || !canEdit || !canDelete} isDeleteDisabled={selected?.length === 0}
onToggleDeleteModal={() => setIsDeleteModalOpen(true)} onToggleDeleteModal={() => setIsDeleteModalOpen(true)}
/> />
{content} {content}
@@ -165,7 +118,6 @@ function SurveyList({ template, i18n }) {
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
onClose={() => { onClose={() => {
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setSelected([]);
}} }}
actions={[ actions={[
<Button <Button
@@ -182,7 +134,6 @@ function SurveyList({ template, i18n }) {
aria-label={i18n._(t`cancel delete`)} aria-label={i18n._(t`cancel delete`)}
onClick={() => { onClick={() => {
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setSelected([]);
}} }}
> >
{i18n._(t`Cancel`)} {i18n._(t`Cancel`)}
@@ -190,34 +141,13 @@ function SurveyList({ template, i18n }) {
]} ]}
> >
<div>{i18n._(t`This action will delete the following:`)}</div> <div>{i18n._(t`This action will delete the following:`)}</div>
{selected.map(question => ( <ul>
<span key={question.id}> {selected.map(question => (
<strong>{question.question_name}</strong> <li key={question.variable}>
<br /> <strong>{question.question_name}</strong>
</span> </li>
))} ))}
</AlertModal> </ul>
)}
{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> </AlertModal>
)} )}
</> </>

View File

@@ -1,91 +1,62 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import SurveyList from './SurveyList'; import SurveyList from './SurveyList';
import { JobTemplatesAPI } from '@api'; import { JobTemplatesAPI } from '@api';
import mockJobTemplateData from './data.job_template.json'; import mockJobTemplateData from './data.job_template.json';
jest.mock('@api/models/JobTemplates'); jest.mock('@api/models/JobTemplates');
const surveyData = {
name: 'Survey',
description: 'description for survey',
spec: [
{ question_name: 'Foo', type: 'text', default: 'Bar', variable: 'foo' },
],
};
describe('<SurveyList />', () => { describe('<SurveyList />', () => {
beforeEach(() => {
JobTemplatesAPI.readSurvey.mockResolvedValue({
data: {
name: 'Survey',
description: 'description for survey',
spec: [{ question_name: 'Foo', type: 'text', default: 'Bar' }],
},
});
});
test('expect component to mount successfully', async () => { test('expect component to mount successfully', async () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(<SurveyList survey={surveyData} />);
<SurveyList template={mockJobTemplateData} />
);
}); });
expect(wrapper.length).toBe(1); expect(wrapper.length).toBe(1);
}); });
test('expect api to be called to get survey', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<SurveyList template={mockJobTemplateData} />
);
});
expect(JobTemplatesAPI.readSurvey).toBeCalledWith(7);
wrapper.update(); test('should toggle survey', async () => {
expect(wrapper.find('SurveyListItem').length).toBe(1); const toggleSurvey = jest.fn();
});
test('error in retrieving the survey throws an error', async () => {
JobTemplatesAPI.readSurvey.mockRejectedValue(new Error());
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<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(); JobTemplatesAPI.update.mockResolvedValue();
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<SurveyList <SurveyList
template={{ ...mockJobTemplateData, survey_enabled: false }} survey={surveyData}
surveyEnabled
toggleSurvey={toggleSurvey}
/> />
); );
}); });
expect(wrapper.find('Switch').length).toBe(1); expect(wrapper.find('Switch').length).toBe(1);
expect(wrapper.find('Switch').prop('isChecked')).toBe(false); expect(wrapper.find('Switch').prop('isChecked')).toBe(true);
await act(async () => { await act(async () => {
wrapper.find('Switch').invoke('onChange')(true); wrapper.find('Switch').invoke('onChange')(true);
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('Switch').prop('isChecked')).toBe(true); expect(toggleSurvey).toHaveBeenCalled();
expect(JobTemplatesAPI.update).toBeCalledWith(7, {
survey_enabled: true,
});
}); });
test('selectAll enables delete button and calls the api to delete properly', async () => { test('should select all and delete', async () => {
const deleteSurvey = jest.fn();
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<SurveyList <SurveyList survey={surveyData} deleteSurvey={deleteSurvey} />
template={{ ...mockJobTemplateData, survey_enabled: false }}
/>
); );
}); });
await waitForElement(wrapper, 'SurveyListItem'); wrapper.update();
expect(wrapper.find('Button[variant="danger"]').prop('isDisabled')).toBe( expect(wrapper.find('Button[variant="danger"]').prop('isDisabled')).toBe(
true true
); );
@@ -99,28 +70,26 @@ describe('<SurveyList />', () => {
true true
); );
}); });
wrapper.update(); wrapper.update();
expect( expect(
wrapper.find('Checkbox[aria-label="Select all"]').prop('isChecked') wrapper.find('Checkbox[aria-label="Select all"]').prop('isChecked')
).toBe(true); ).toBe(true);
expect(wrapper.find('Button[variant="danger"]').prop('isDisabled')).toBe( expect(wrapper.find('Button[variant="danger"]').prop('isDisabled')).toBe(
false false
); );
act(() => { act(() => {
wrapper.find('Button[variant="danger"]').invoke('onClick')(); wrapper.find('Button[variant="danger"]').invoke('onClick')();
}); });
wrapper.update(); wrapper.update();
await act(() => await act(() =>
wrapper.find('Button[aria-label="confirm delete"]').invoke('onClick')() wrapper.find('Button[aria-label="confirm delete"]').invoke('onClick')()
); );
expect(JobTemplatesAPI.destroySurvey).toBeCalledWith(7); expect(deleteSurvey).toHaveBeenCalled();
}); });
}); });
describe('Survey with no questions', () => { describe('Survey with no questions', () => {
test('Survey with no questions renders empty state', async () => { test('Survey with no questions renders empty state', async () => {
JobTemplatesAPI.readSurvey.mockResolvedValue({}); JobTemplatesAPI.readSurvey.mockResolvedValue({});

View File

@@ -1,10 +1,8 @@
import React from 'react'; import React from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { import {
Button as _Button, Button as _Button,
DataList,
DataListAction as _DataListAction, DataListAction as _DataListAction,
DataListCheck, DataListCheck,
DataListItemCells, DataListItemCells,
@@ -28,6 +26,7 @@ const Button = styled(_Button)`
padding-bottom: 0; padding-bottom: 0;
padding-left: 0; padding-left: 0;
`; `;
function SurveyListItem({ function SurveyListItem({
question, question,
i18n, i18n,
@@ -35,56 +34,58 @@ function SurveyListItem({
isFirst, isFirst,
isChecked, isChecked,
onSelect, onSelect,
onMoveUp,
onMoveDown,
}) { }) {
return ( return (
<DataList aria-label={i18n._(t`Survey List`)}> <DataListItem aria-labelledby={i18n._(t`Survey questions`)}>
<DataListItem aria-labelledby={i18n._(t`Survey questions`)}> <DataListItemRow css="padding-left:16px">
<DataListItemRow css="padding-left:16px"> <DataListAction
<DataListAction id="sortQuestions"
id="sortQuestions" aria-labelledby={i18n._(t`Sort question order`)}
aria-labelledby={i18n._(t`Sort question order`)} aria-label={i18n._(t`Sort question order`)}
aria-label={i18n._(t`Sort question order`)} >
> <Stack>
<Stack> <StackItem>
<StackItem> <Button
<Button variant="plain"
variant="plain" aria-label={i18n._(t`move up`)}
aria-label={i18n._(t`move up`)} isDisabled={isFirst}
isDisabled={isFirst} onClick={() => onMoveUp(question)}
> >
<CaretUpIcon /> <CaretUpIcon />
</Button> </Button>
</StackItem> </StackItem>
<StackItem> <StackItem>
<Button <Button
variant="plain" variant="plain"
aria-label={i18n._(t`move down`)} aria-label={i18n._(t`move down`)}
isDisabled={isLast} isDisabled={isLast}
> onClick={() => onMoveDown(question)}
<CaretDownIcon /> >
</Button> <CaretDownIcon />
</StackItem> </Button>
</Stack> </StackItem>
</DataListAction> </Stack>
<DataListCheck </DataListAction>
checked={isChecked} <DataListCheck
onChange={onSelect} checked={isChecked}
aria-labelledby="survey check" onChange={onSelect}
/> aria-labelledby="survey check"
<DataListItemCells />
dataListCells={[ <DataListItemCells
<DataListCell key={question.question_name}> dataListCells={[
{question.question_name} <DataListCell key={question.question_name}>
</DataListCell>, {question.question_name}
<DataListCell key={question.type}>{question.type}</DataListCell>, </DataListCell>,
<DataListCell key={question.default}> <DataListCell key={question.type}>{question.type}</DataListCell>,
{question.default} <DataListCell key={question.default}>
</DataListCell>, {question.default}
]} </DataListCell>,
/> ]}
</DataListItemRow> />
</DataListItem> </DataListItemRow>
</DataList> </DataListItem>
); );
} }

View File

@@ -34,7 +34,7 @@ export function getQSConfig(
*/ */
export function parseQueryString(config, queryString) { export function parseQueryString(config, queryString) {
if (!queryString) { if (!queryString) {
return config.defaultParams; return config.defaultParams || {};
} }
const params = stringToObject(config, queryString); const params = stringToObject(config, queryString);
return addDefaultsToObject(config, params); return addDefaultsToObject(config, params);

View File

@@ -8,11 +8,12 @@ import {
/* /*
* The useRequest hook accepts a request function and returns an object with * The useRequest hook accepts a request function and returns an object with
* four values: * five values:
* request: a function to call to invoke the request * request: a function to call to invoke the request
* result: the value returned from the request function (once invoked) * result: the value returned from the request function (once invoked)
* isLoading: boolean state indicating whether the request is in active/in flight * isLoading: boolean state indicating whether the request is in active/in flight
* error: any caught error resulting from the request * error: any caught error resulting from the request
* setValue: setter to explicitly set the result value
* *
* The hook also accepts an optional second parameter which is a default * The hook also accepts an optional second parameter which is a default
* value to set as result before the first time the request is made. * value to set as result before the first time the request is made.
@@ -34,34 +35,41 @@ export default function useRequest(makeRequest, initialValue) {
result, result,
error, error,
isLoading, isLoading,
request: useCallback(async () => { request: useCallback(
setIsLoading(true); async (...args) => {
try { setIsLoading(true);
const response = await makeRequest(); try {
if (isMounted.current) { const response = await makeRequest(...args);
setResult(response); if (isMounted.current) {
setResult(response);
}
} catch (err) {
if (isMounted.current) {
setError(err);
}
} finally {
if (isMounted.current) {
setIsLoading(false);
}
} }
} catch (err) { },
if (isMounted.current) { [makeRequest]
setError(err); ),
} setValue: setResult,
} finally {
if (isMounted.current) {
setIsLoading(false);
}
}
}, [makeRequest]),
}; };
} }
export function useDeleteItems( /*
makeRequest, * Provides controls for "dismissing" an error message
{ qsConfig, allItemsSelected, fetchItems } *
) { * Params: an error object
const location = useLocation(); * Returns: { error, dismissError }
const history = useHistory(); * The returned error object is the same object passed in via the paremeter,
* until the dismissError function is called, at which point the returned
* error will be set to null on the subsequent render.
*/
export function useDismissableError(error) {
const [showError, setShowError] = useState(false); const [showError, setShowError] = useState(false);
const { error, isLoading, request } = useRequest(makeRequest, null);
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@@ -69,13 +77,48 @@ export function useDeleteItems(
} }
}, [error]); }, [error]);
return {
error: showError ? error : null,
dismissError: () => {
setShowError(false);
},
};
}
/*
* Hook to assist with deletion of items from a paginated item list. The page
* url will be navigated back one page on a paginated list if needed to prevent
* the UI from re-loading an empty set and displaying a "No items found"
* message.
*
* Params: a callback function that will be invoked in order to delete items,
* and an object with structure { qsConfig, allItemsSelected, fetchItems }
* Returns: { isLoading, deleteItems, deletionError, clearDeletionError }
*/
export function useDeleteItems(
makeRequest,
{ qsConfig = null, allItemsSelected = false, fetchItems = null } = {}
) {
const location = useLocation();
const history = useHistory();
const { error: requestError, isLoading, request } = useRequest(
makeRequest,
null
);
const { error, dismissError } = useDismissableError(requestError);
const deleteItems = async () => { const deleteItems = async () => {
await request(); await request();
if (!qsConfig) {
return;
}
const params = parseQueryString(qsConfig, location.search); const params = parseQueryString(qsConfig, location.search);
if (params.page > 1 && allItemsSelected) { if (params.page > 1 && allItemsSelected) {
const newParams = encodeNonDefaultQueryString( const newParams = encodeNonDefaultQueryString(
qsConfig, qsConfig,
replaceParams(params, { page: params.page - 1 }) replaceParams(params, {
page: params.page - 1,
})
); );
history.push(`${location.pathname}?${newParams}`); history.push(`${location.pathname}?${newParams}`);
} else { } else {
@@ -86,7 +129,7 @@ export function useDeleteItems(
return { return {
isLoading, isLoading,
deleteItems, deleteItems,
deletionError: showError && error, deletionError: error,
clearDeletionError: () => setShowError(false), clearDeletionError: dismissError,
}; };
} }

View File

@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import useRequest from './useRequest'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import useRequest, { useDeleteItems } from './useRequest';
function TestInner() { function TestInner() {
return <div />; return <div />;
@@ -10,100 +11,179 @@ function Test({ makeRequest, initialValue = {} }) {
const request = useRequest(makeRequest, initialValue); const request = useRequest(makeRequest, initialValue);
return <TestInner {...request} />; return <TestInner {...request} />;
} }
function DeleteTest({ makeRequest, args = {} }) {
const request = useDeleteItems(makeRequest, args);
return <TestInner {...request} />;
}
describe('useRequest', () => { describe('useRequest hooks', () => {
test('should return initial value as result', async () => { describe('useRequest', () => {
const makeRequest = jest.fn(); test('should return initial value as result', async () => {
makeRequest.mockResolvedValue({ data: 'foo' }); const makeRequest = jest.fn();
const wrapper = mount( makeRequest.mockResolvedValue({ data: 'foo' });
<Test const wrapper = mount(
makeRequest={makeRequest} <Test
initialValue={{ makeRequest={makeRequest}
initial: true, initialValue={{
}} initial: true,
/> }}
); />
);
expect(wrapper.find('TestInner').prop('result')).toEqual({ initial: true }); expect(wrapper.find('TestInner').prop('result')).toEqual({
initial: true,
});
});
test('should return result', async () => {
const makeRequest = jest.fn();
makeRequest.mockResolvedValue({ data: 'foo' });
const wrapper = mount(<Test makeRequest={makeRequest} />);
await act(async () => {
wrapper.find('TestInner').invoke('request')();
});
wrapper.update();
expect(wrapper.find('TestInner').prop('result')).toEqual({ data: 'foo' });
});
test('should is isLoading flag', async () => {
const makeRequest = jest.fn();
let resolve;
const promise = new Promise(r => {
resolve = r;
});
makeRequest.mockReturnValue(promise);
const wrapper = mount(<Test makeRequest={makeRequest} />);
await act(async () => {
wrapper.find('TestInner').invoke('request')();
});
wrapper.update();
expect(wrapper.find('TestInner').prop('isLoading')).toEqual(true);
await act(async () => {
resolve({ data: 'foo' });
});
wrapper.update();
expect(wrapper.find('TestInner').prop('isLoading')).toEqual(false);
expect(wrapper.find('TestInner').prop('result')).toEqual({ data: 'foo' });
});
test('should invoke request function', async () => {
const makeRequest = jest.fn();
makeRequest.mockResolvedValue({ data: 'foo' });
const wrapper = mount(<Test makeRequest={makeRequest} />);
expect(makeRequest).not.toHaveBeenCalled();
await act(async () => {
wrapper.find('TestInner').invoke('request')();
});
wrapper.update();
expect(makeRequest).toHaveBeenCalledTimes(1);
});
test('should return error thrown from request function', async () => {
const error = new Error('error');
const makeRequest = () => {
throw error;
};
const wrapper = mount(<Test makeRequest={makeRequest} />);
await act(async () => {
wrapper.find('TestInner').invoke('request')();
});
wrapper.update();
expect(wrapper.find('TestInner').prop('error')).toEqual(error);
});
test('should not update state after unmount', async () => {
const makeRequest = jest.fn();
let resolve;
const promise = new Promise(r => {
resolve = r;
});
makeRequest.mockReturnValue(promise);
const wrapper = mount(<Test makeRequest={makeRequest} />);
expect(makeRequest).not.toHaveBeenCalled();
await act(async () => {
wrapper.find('TestInner').invoke('request')();
});
wrapper.unmount();
await act(async () => {
resolve({ data: 'foo' });
});
});
}); });
test('should return result', async () => { describe('useDeleteItems', () => {
const makeRequest = jest.fn(); test('should invoke delete function', async () => {
makeRequest.mockResolvedValue({ data: 'foo' }); const makeRequest = jest.fn();
const wrapper = mount(<Test makeRequest={makeRequest} />); makeRequest.mockResolvedValue({ data: 'foo' });
const wrapper = mountWithContexts(
<DeleteTest
makeRequest={makeRequest}
args={{
qsConfig: {},
fetchItems: () => {},
}}
/>
);
await act(async () => { expect(makeRequest).not.toHaveBeenCalled();
wrapper.find('TestInner').invoke('request')(); await act(async () => {
wrapper.find('TestInner').invoke('deleteItems')();
});
wrapper.update();
expect(makeRequest).toHaveBeenCalledTimes(1);
}); });
wrapper.update();
expect(wrapper.find('TestInner').prop('result')).toEqual({ data: 'foo' });
});
test('should is isLoading flag', async () => { test('should return error object thrown by function', async () => {
const makeRequest = jest.fn(); const error = new Error('error');
let resolve; const makeRequest = () => {
const promise = new Promise(r => { throw error;
resolve = r; };
const wrapper = mountWithContexts(
<DeleteTest
makeRequest={makeRequest}
args={{
qsConfig: {},
fetchItems: () => {},
}}
/>
);
await act(async () => {
wrapper.find('TestInner').invoke('deleteItems')();
});
wrapper.update();
expect(wrapper.find('TestInner').prop('deletionError')).toEqual(error);
}); });
makeRequest.mockReturnValue(promise);
const wrapper = mount(<Test makeRequest={makeRequest} />);
await act(async () => { test('should dismiss error', async () => {
wrapper.find('TestInner').invoke('request')(); const error = new Error('error');
}); const makeRequest = () => {
wrapper.update(); throw error;
expect(wrapper.find('TestInner').prop('isLoading')).toEqual(true); };
await act(async () => { const wrapper = mountWithContexts(
resolve({ data: 'foo' }); <DeleteTest
}); makeRequest={makeRequest}
wrapper.update(); args={{
expect(wrapper.find('TestInner').prop('isLoading')).toEqual(false); qsConfig: {},
expect(wrapper.find('TestInner').prop('result')).toEqual({ data: 'foo' }); fetchItems: () => {},
}); }}
/>
);
test('should invoke request function', async () => { await act(async () => {
const makeRequest = jest.fn(); wrapper.find('TestInner').invoke('deleteItems')();
makeRequest.mockResolvedValue({ data: 'foo' }); });
const wrapper = mount(<Test makeRequest={makeRequest} />); wrapper.update();
await act(async () => {
expect(makeRequest).not.toHaveBeenCalled(); wrapper.find('TestInner').invoke('clearDeletionError')();
await act(async () => { });
wrapper.find('TestInner').invoke('request')(); wrapper.update();
}); expect(wrapper.find('TestInner').prop('deletionError')).toEqual(null);
wrapper.update();
expect(makeRequest).toHaveBeenCalledTimes(1);
});
test('should return error thrown from request function', async () => {
const error = new Error('error');
const makeRequest = () => {
throw error;
};
const wrapper = mount(<Test makeRequest={makeRequest} />);
await act(async () => {
wrapper.find('TestInner').invoke('request')();
});
wrapper.update();
expect(wrapper.find('TestInner').prop('error')).toEqual(error);
});
test('should not update state after unmount', async () => {
const makeRequest = jest.fn();
let resolve;
const promise = new Promise(r => {
resolve = r;
});
makeRequest.mockReturnValue(promise);
const wrapper = mount(<Test makeRequest={makeRequest} />);
expect(makeRequest).not.toHaveBeenCalled();
await act(async () => {
wrapper.find('TestInner').invoke('request')();
});
wrapper.unmount();
await act(async () => {
resolve({ data: 'foo' });
}); });
}); });
}); });