diff --git a/i18n/en.pot b/i18n/en.pot index 88b4ca9e9..69032a74f 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2020-06-25T09:59:16.015Z\n" -"PO-Revision-Date: 2020-06-25T09:59:16.015Z\n" +"POT-Creation-Date: 2020-07-22T08:26:40.694Z\n" +"PO-Revision-Date: 2020-07-22T08:26:40.694Z\n" msgid "Checking permissions" msgstr "" @@ -32,7 +32,7 @@ msgstr "" msgid "Name" msgstr "" -msgid "Loading job types" +msgid "Loading" msgstr "" msgid "Job type" @@ -41,7 +41,7 @@ msgstr "" msgid "No options available" msgstr "" -msgid "Save job" +msgid "Save" msgstr "" msgid "Cancel" @@ -95,6 +95,9 @@ msgstr "" msgid "About job configuration" msgstr "" +msgid "Job: {{ JOBNAME }}" +msgstr "" + msgid "Edit" msgstr "" diff --git a/package.json b/package.json index cc983ae75..0ac1ddb35 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@dhis2/app-runtime": "^2.2.2", "@dhis2/d2-i18n": "^1.0.6", "@dhis2/prop-types": "2.0.0", - "@dhis2/ui": "^5.0.5", + "@dhis2/ui": "^5.0.7", "cronstrue": "^1.82.0", "history": "^4.9.0", "moment": "^2.24.0", @@ -31,7 +31,6 @@ "@dhis2/cli-style": "^7.1.0-alpha.9", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.14.0", - "enzyme-to-json": "^3.3.5", "eslint-plugin-compat": "^3.1.2", "eslint-plugin-import": "^2.18.0", "eslint-plugin-jsx-a11y": "^6.2.1", @@ -56,13 +55,13 @@ "setupFilesAfterEnv": [ "/src/setupTests.js" ], - "snapshotSerializers": [ - "enzyme-to-json/serializer" - ], "collectCoverageFrom": [ "src/**/*.{js,jsx}", - "!src/{index.js,serviceWorker.js,setupTests.js}", - "!/node_modules/" + "!src/{index.js,serviceWorker.js,setupTests.js}" + ], + "coveragePathIgnorePatterns": [ + "/node_modules/", + "/src/locales/" ], "moduleNameMapper": { "\\.css$": "identity-obj-proxy" diff --git a/src/.eslintrc.js b/src/.eslintrc.js index 3dd03a1e2..eee9c6270 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -24,7 +24,8 @@ module.exports = { '**/hooks/*', '**/pages/*', '**/services/*', - 'cronstrue/*' + 'cronstrue/*', + 'test/*', ], }, ], diff --git a/src/components/App/App.test.js b/src/components/App/App.test.js index 1d88de64e..fc3cf2458 100644 --- a/src/components/App/App.test.js +++ b/src/components/App/App.test.js @@ -3,9 +3,7 @@ import { shallow } from 'enzyme' import App from './App' describe('', () => { - it('renders correctly', () => { - const wrapper = shallow() - - expect(wrapper).toMatchSnapshot() + it('renders without errors', () => { + shallow() }) }) diff --git a/src/components/App/__snapshots__/App.test.js.snap b/src/components/App/__snapshots__/App.test.js.snap deleted file mode 100644 index 857b84edd..000000000 --- a/src/components/App/__snapshots__/App.test.js.snap +++ /dev/null @@ -1,16 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - - - - - - -`; diff --git a/src/components/AuthWall/AuthWall.js b/src/components/AuthWall/AuthWall.js index 914503e27..f9df438be 100644 --- a/src/components/AuthWall/AuthWall.js +++ b/src/components/AuthWall/AuthWall.js @@ -3,10 +3,17 @@ import { PropTypes } from '@dhis2/prop-types' import { Redirect } from 'react-router-dom' import { CircularLoader, Layer, CenteredContent } from '@dhis2/ui' import i18n from '@dhis2/d2-i18n' -import { useGetMe, selectors } from '../../hooks/me' +import { useDataQuery } from '@dhis2/app-runtime' +import { getAuthorized } from './selectors' + +const query = { + me: { + resource: 'me', + }, +} const AuthWall = ({ children }) => { - const { loading, error, data } = useGetMe() + const { loading, error, data } = useDataQuery(query) if (loading) { return ( @@ -27,7 +34,7 @@ const AuthWall = ({ children }) => { throw error } - const isAuthorized = selectors.getAuthorized(data) + const isAuthorized = getAuthorized(data.me) if (!isAuthorized) { return diff --git a/src/components/AuthWall/AuthWall.test.js b/src/components/AuthWall/AuthWall.test.js index 5c55552c8..ccc2dc6bf 100644 --- a/src/components/AuthWall/AuthWall.test.js +++ b/src/components/AuthWall/AuthWall.test.js @@ -1,58 +1,76 @@ import React from 'react' -import { shallow } from 'enzyme' -import { useGetMe, selectors } from '../../hooks/me' +import { shallow, mount } from 'enzyme' +import { useDataQuery } from '@dhis2/app-runtime' +import expectRenderError from '../../../test/expect-render-error' +import { getAuthorized } from './selectors' import AuthWall from './AuthWall' -jest.mock('../../hooks/me', () => ({ - useGetMe: jest.fn(), - selectors: { - getAuthorized: jest.fn(), - }, +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), })) +jest.mock('./selectors', () => ({ + getAuthorized: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + describe('', () => { - it('renders a spinner when loading', () => { - useGetMe.mockImplementationOnce(() => ({ loading: true })) + it('shows a loading message when loading', () => { + useDataQuery.mockImplementation(() => ({ loading: true })) - const wrapper = shallow(Child) + const wrapper = mount(Child) + const loadingIndicator = wrapper.find({ + 'data-test': 'dhis2-uicore-circularloader', + }) - expect(wrapper).toMatchSnapshot() + expect(loadingIndicator).toHaveLength(1) + expect(wrapper.text()).toEqual( + expect.stringContaining('Checking permissions') + ) }) it('throws fetching errors if they occur', () => { const props = { children: 'Child' } - const error = new Error('Something went wrong') - useGetMe.mockImplementationOnce(() => ({ + const message = 'Something went wrong' + const error = new Error(message) + + useDataQuery.mockImplementation(() => ({ loading: false, error, })) - expect(() => AuthWall(props)).toThrow(error) + expectRenderError(, message) }) - it('redirects unauthorized users', () => { - useGetMe.mockImplementationOnce(() => ({ + it('redirects unauthorized users to /notauthorized', () => { + useDataQuery.mockImplementation(() => ({ loading: false, error: undefined, data: {}, })) - selectors.getAuthorized.mockImplementationOnce(() => false) + getAuthorized.mockImplementation(() => false) const wrapper = shallow(Child) + const redirect = wrapper.find('Redirect') + const props = redirect.props() - expect(wrapper).toMatchSnapshot() + expect(redirect).toHaveLength(1) + expect(props).toEqual(expect.objectContaining({ to: '/notauthorized' })) }) it('renders the children for users that are authorized', () => { - useGetMe.mockImplementationOnce(() => ({ + useDataQuery.mockImplementation(() => ({ loading: false, error: undefined, data: {}, })) - selectors.getAuthorized.mockImplementationOnce(() => true) + getAuthorized.mockImplementation(() => true) const wrapper = shallow(Child) - expect(wrapper).toMatchSnapshot() + expect(wrapper.text()).toEqual(expect.stringContaining('Child')) }) }) diff --git a/src/components/AuthWall/__snapshots__/AuthWall.test.js.snap b/src/components/AuthWall/__snapshots__/AuthWall.test.js.snap deleted file mode 100644 index 10ea8da08..000000000 --- a/src/components/AuthWall/__snapshots__/AuthWall.test.js.snap +++ /dev/null @@ -1,32 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` redirects unauthorized users 1`] = ` - -`; - -exports[` renders a spinner when loading 1`] = ` - - - - Checking permissions - - -`; - -exports[` renders the children for users that are authorized 1`] = ` - - Child - -`; diff --git a/src/components/AuthWall/selectors.js b/src/components/AuthWall/selectors.js new file mode 100644 index 000000000..0300140fb --- /dev/null +++ b/src/components/AuthWall/selectors.js @@ -0,0 +1,13 @@ +export const getAuthorized = me => { + const { authorities } = me + + if (!authorities) { + return false + } + + const isAuthorized = + authorities.includes('ALL') || + authorities.includes('F_SCHEDULING_ADMIN') + + return isAuthorized +} diff --git a/src/components/AuthWall/selectors.test.js b/src/components/AuthWall/selectors.test.js new file mode 100644 index 000000000..de84b61ca --- /dev/null +++ b/src/components/AuthWall/selectors.test.js @@ -0,0 +1,23 @@ +import { getAuthorized } from './selectors' + +describe('getAuthorized', () => { + it('should return false if there are no authorities', () => { + expect(getAuthorized({})).toBe(false) + }) + + it('should return true if the authorities include ALL', () => { + const me = { + authorities: ['ALL'], + } + + expect(getAuthorized(me)).toBe(true) + }) + + it('should return true if the authorities include F_SCHEDULING_ADMIN', () => { + const me = { + authorities: ['F_SCHEDULING_ADMIN'], + } + + expect(getAuthorized(me)).toBe(true) + }) +}) diff --git a/src/components/Buttons/CronPresetButton.js b/src/components/Buttons/CronPresetButton.js index 0407227cb..0e112029c 100644 --- a/src/components/Buttons/CronPresetButton.js +++ b/src/components/Buttons/CronPresetButton.js @@ -14,7 +14,10 @@ const CronPresetButton = ({ setCron, small }) => { {showModal && ( setShowModal(false)} + hideModal={ + /* istanbul ignore next */ + () => setShowModal(false) + } setCron={setCron} /> )} diff --git a/src/components/Buttons/CronPresetButton.test.js b/src/components/Buttons/CronPresetButton.test.js index ceefc18e4..3c40bfee5 100644 --- a/src/components/Buttons/CronPresetButton.test.js +++ b/src/components/Buttons/CronPresetButton.test.js @@ -3,16 +3,12 @@ import { shallow, mount } from 'enzyme' import CronPresetButton from './CronPresetButton' describe('', () => { - it('renders correctly', () => { - const wrapper = shallow( {}} />) - - expect(wrapper).toMatchSnapshot() + it('renders without errors', () => { + shallow( {}} />) }) - it('renders small correctly', () => { - const wrapper = shallow( {}} small />) - - expect(wrapper).toMatchSnapshot() + it('renders without errors when small', () => { + shallow( {}} small />) }) it('shows the modal when button is clicked', () => { diff --git a/src/components/Buttons/DeleteJobButton.js b/src/components/Buttons/DeleteJobButton.js index 53a3ca89c..9eee5c0d5 100644 --- a/src/components/Buttons/DeleteJobButton.js +++ b/src/components/Buttons/DeleteJobButton.js @@ -13,7 +13,13 @@ const DeleteJobButton = ({ id }) => { {i18n.t('Delete')} {showModal && ( - setShowModal(false)} /> + setShowModal(false) + } + /> )} ) diff --git a/src/components/Buttons/DeleteJobButton.test.js b/src/components/Buttons/DeleteJobButton.test.js index 892b8991b..7f17c006b 100644 --- a/src/components/Buttons/DeleteJobButton.test.js +++ b/src/components/Buttons/DeleteJobButton.test.js @@ -3,10 +3,8 @@ import { shallow, mount } from 'enzyme' import DeleteJobButton from './DeleteJobButton' describe('', () => { - it('renders correctly', () => { - const wrapper = shallow() - - expect(wrapper).toMatchSnapshot() + it('renders without errors', () => { + shallow() }) it('shows the modal when button is clicked', () => { diff --git a/src/components/Buttons/DiscardFormButton.js b/src/components/Buttons/DiscardFormButton.js index bd24f2e02..3efa4a39b 100644 --- a/src/components/Buttons/DiscardFormButton.js +++ b/src/components/Buttons/DiscardFormButton.js @@ -16,7 +16,12 @@ const DiscardFormButton = ({ shouldConfirm, children, small, className }) => { {children} {showModal && ( - setShowModal(false)} /> + setShowModal(false) + } + /> )} ) diff --git a/src/components/Buttons/DiscardFormButton.test.js b/src/components/Buttons/DiscardFormButton.test.js index eed1e8be5..8a0dd7d2f 100644 --- a/src/components/Buttons/DiscardFormButton.test.js +++ b/src/components/Buttons/DiscardFormButton.test.js @@ -8,32 +8,32 @@ jest.mock('../../services/history', () => ({ })) describe('', () => { - it('renders correctly', () => { - const wrapper = shallow( + it('renders without errors', () => { + shallow( Discard ) - - expect(wrapper).toMatchSnapshot() }) - it('renders small correctly', () => { - const wrapper = shallow( + it('renders without errors when small', () => { + shallow( Discard ) - - expect(wrapper).toMatchSnapshot() }) it('applies className correctly', () => { - const wrapper = shallow( + const wrapper = mount( Discard ) - expect(wrapper).toMatchSnapshot() + const buttonProps = wrapper.find('Button').props() + + expect(buttonProps).toEqual( + expect.objectContaining({ className: 'className' }) + ) }) it('shows the modal when it should confirm and button is clicked', () => { diff --git a/src/components/Buttons/__snapshots__/CronPresetButton.test.js.snap b/src/components/Buttons/__snapshots__/CronPresetButton.test.js.snap deleted file mode 100644 index 2384564b7..000000000 --- a/src/components/Buttons/__snapshots__/CronPresetButton.test.js.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - - - -`; - -exports[` renders small correctly 1`] = ` - - - -`; diff --git a/src/components/Buttons/__snapshots__/DeleteJobButton.test.js.snap b/src/components/Buttons/__snapshots__/DeleteJobButton.test.js.snap deleted file mode 100644 index bd3c38324..000000000 --- a/src/components/Buttons/__snapshots__/DeleteJobButton.test.js.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - - - -`; diff --git a/src/components/Buttons/__snapshots__/DiscardFormButton.test.js.snap b/src/components/Buttons/__snapshots__/DiscardFormButton.test.js.snap deleted file mode 100644 index 6337d90b6..000000000 --- a/src/components/Buttons/__snapshots__/DiscardFormButton.test.js.snap +++ /dev/null @@ -1,43 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` applies className correctly 1`] = ` - - - -`; - -exports[` renders correctly 1`] = ` - - - -`; - -exports[` renders small correctly 1`] = ` - - - -`; diff --git a/src/components/Context/RefetchJobs.js b/src/components/Context/RefetchJobs.js index 6ac38140a..a4b074749 100644 --- a/src/components/Context/RefetchJobs.js +++ b/src/components/Context/RefetchJobs.js @@ -1,6 +1,11 @@ import { createContext } from 'react' -// default is a noop -const RefetchJobsContext = createContext(() => {}) +const message = + 'RefetchJobsContext consumer needs to have a valid Provider as parent' + +// Throws an error if the consumer is not nested in a provider +const RefetchJobsContext = createContext(() => { + throw new Error(message) +}) export default RefetchJobsContext diff --git a/src/components/Context/RefetchJobs.test.js b/src/components/Context/RefetchJobs.test.js index 8d6a259e9..c60bea9e8 100644 --- a/src/components/Context/RefetchJobs.test.js +++ b/src/components/Context/RefetchJobs.test.js @@ -1,8 +1,24 @@ -import RefetchJobs from './RefetchJobs' +import React from 'react' +import expectRenderError from '../../../test/expect-render-error' +import RefetchJobsContext from './RefetchJobs' -describe('RefetchJobs', () => { +describe('RefetchJobsContext', () => { it('exports an object with provider and consumer', () => { - expect('Provider' in RefetchJobs).toBe(true) - expect('Consumer' in RefetchJobs).toBe(true) + expect('Provider' in RefetchJobsContext).toBe(true) + expect('Consumer' in RefetchJobsContext).toBe(true) + }) +}) + +describe('RefetchJobsContext.Consumer', () => { + it('returns a function that throws an error if used outside of the provider', () => { + const message = + 'RefetchJobsContext consumer needs to have a valid Provider as parent' + + expectRenderError( + + {refetch => refetch()} + , + message + ) }) }) diff --git a/src/components/Cron/HumanReadableCron.test.js b/src/components/Cron/HumanReadableCron.test.js index d13ef0728..7d81569e4 100644 --- a/src/components/Cron/HumanReadableCron.test.js +++ b/src/components/Cron/HumanReadableCron.test.js @@ -2,15 +2,19 @@ import { useHumanReadableCron } from '../../hooks/human-readable-cron' import HumanReadableCron from './HumanReadableCron' jest.mock('../../hooks/human-readable-cron', () => ({ - useHumanReadableCron: jest.fn(() => ''), + useHumanReadableCron: jest.fn(), })) +afterEach(() => { + jest.resetAllMocks() +}) + describe('', () => { it('returns a human readable cron', () => { const cronExpression = '0 0 1 ? * *' const humanReadableCron = 'Every day' - useHumanReadableCron.mockImplementationOnce(() => humanReadableCron) + useHumanReadableCron.mockImplementation(() => humanReadableCron) expect(HumanReadableCron({ cronExpression })).toBe(humanReadableCron) }) diff --git a/src/components/FormErrorBox/FormErrorBox.test.js b/src/components/FormErrorBox/FormErrorBox.test.js index c4ab2149d..9205d6e01 100644 --- a/src/components/FormErrorBox/FormErrorBox.test.js +++ b/src/components/FormErrorBox/FormErrorBox.test.js @@ -1,18 +1,19 @@ import React from 'react' -import { shallow } from 'enzyme' +import { shallow, mount } from 'enzyme' import FormErrorBox from './FormErrorBox' describe('', () => { it('returns null if there are no errors', () => { - const props = { submitError: [] } + const wrapper = shallow() - expect(FormErrorBox(props)).toBeNull() + expect(wrapper.isEmptyRender()).toBe(true) }) it('shows errors if there are errors', () => { - const submitError = ['Error'] - const wrapper = shallow() + const message = 'An error message' + const submitError = [message] + const wrapper = mount() - expect(wrapper).toMatchSnapshot() + expect(wrapper.text()).toEqual(expect.stringContaining(message)) }) }) diff --git a/src/components/FormErrorBox/__snapshots__/FormErrorBox.test.js.snap b/src/components/FormErrorBox/__snapshots__/FormErrorBox.test.js.snap deleted file mode 100644 index 6ac225b48..000000000 --- a/src/components/FormErrorBox/__snapshots__/FormErrorBox.test.js.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` shows errors if there are errors 1`] = ` - -
    -
  • - Error -
  • -
-
-`; diff --git a/src/components/FormFields/CronField.js b/src/components/FormFields/CronField.js index 34036e7a6..38ae64762 100644 --- a/src/components/FormFields/CronField.js +++ b/src/components/FormFields/CronField.js @@ -30,7 +30,10 @@ const CronField = () => { /> form.change(FIELD_NAME, cron)} + setCron={ + /* istanbul ignore next */ + cron => form.change(FIELD_NAME, cron) + } small /> diff --git a/src/components/FormFields/CronField.test.js b/src/components/FormFields/CronField.test.js new file mode 100644 index 000000000..b8532f5a6 --- /dev/null +++ b/src/components/FormFields/CronField.test.js @@ -0,0 +1,118 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useHumanReadableCron } from '../../hooks/human-readable-cron' +import CronField from './CronField' + +const { Form } = ReactFinalForm + +jest.mock('../../hooks/human-readable-cron', () => ({ + useHumanReadableCron: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows a human readable schedule if a cron expression exists', () => { + const cronExpression = '0 0 * ? * *' + const makeHumanReadable = value => `Human readable version of ${value}` + const expected = makeHumanReadable(cronExpression) + + useHumanReadableCron.mockImplementation(makeHumanReadable) + + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + wrapper + .find('input[name="cronExpression"]') + .simulate('change', { target: { value: cronExpression } }) + + const actual = wrapper + .find({ 'data-test': 'dhis2-uiwidgets-inputfield-help' }) + .text() + + expect(actual).toEqual(expect.stringContaining(expected)) + }) + + it('does not show an error for valid cron expressions', () => { + const cronExpression = '0 0 * ? * *' + + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + wrapper + .find('input[name="cronExpression"]') + .simulate('change', { target: { value: cronExpression } }) + .simulate('blur') + + const actual = wrapper.find({ + 'data-test': 'dhis2-uiwidgets-inputfield-validation', + }) + + expect(actual).toHaveLength(0) + }) + + it('shows an error for invalid cronExpressions', () => { + const cronExpression = 'not a cron expression' + const expected = 'Please enter a valid CRON expression' + + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + wrapper + .find('input[name="cronExpression"]') + .simulate('change', { target: { value: cronExpression } }) + .simulate('blur') + + const actual = wrapper + .find({ 'data-test': 'dhis2-uiwidgets-inputfield-validation' }) + .text() + + expect(actual).toEqual(expect.stringMatching(expected)) + }) + + it('shows an error that the field is required on empty values', () => { + const expected = 'A CRON expression is required' + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + // Trigger validation + wrapper.find('form').simulate('submit') + + const actual = wrapper + .find({ 'data-test': 'dhis2-uiwidgets-inputfield-validation' }) + .text() + + expect(actual).toEqual(expect.stringMatching(expected)) + }) +}) diff --git a/src/components/FormFields/DelayField.js b/src/components/FormFields/DelayField.js index 02b760213..2111699f9 100644 --- a/src/components/FormFields/DelayField.js +++ b/src/components/FormFields/DelayField.js @@ -8,6 +8,7 @@ import { createNumberRange, } from '@dhis2/ui' import i18n from '@dhis2/d2-i18n' +import { getStringValue } from './selectors' const { Field } = ReactFinalForm @@ -30,6 +31,7 @@ const DelayField = () => ( validate={VALIDATOR} label={i18n.t('Delay')} type="number" + format={getStringValue} helpText={i18n.t( 'Delay in seconds ({{ LOWERBOUND }} - {{ UPPERBOUND }})', { diff --git a/src/components/FormFields/DelayField.test.js b/src/components/FormFields/DelayField.test.js new file mode 100644 index 000000000..c3ef52e99 --- /dev/null +++ b/src/components/FormFields/DelayField.test.js @@ -0,0 +1,123 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import DelayField from './DelayField' + +const { Form } = ReactFinalForm + +describe('', () => { + it('converts a supplied number value to a string', () => { + const initialValues = { + delay: 20, + } + const wrapper = mount( +
{}} initialValues={initialValues}> + {() => ( + + + + )} + + ) + + const actual = wrapper.find(`input[name="delay"]`).props().value + + expect(typeof actual).toBe('string') + }) + + it('shows an error for a delay that is too low', () => { + const expected = 'Number cannot be less than 1 or more than 86400' + const delay = '0' + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + wrapper + .find('input[name="delay"]') + .simulate('change', { target: { value: delay } }) + .simulate('blur') + + const actual = wrapper + .find({ 'data-test': 'dhis2-uiwidgets-inputfield-validation' }) + .text() + + expect(actual).toEqual(expect.stringMatching(expected)) + }) + + it('shows an error for a delay that is too high', () => { + const expected = 'Number cannot be less than 1 or more than 86400' + const delay = '86401' + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + wrapper + .find('input[name="delay"]') + .simulate('change', { target: { value: delay } }) + .simulate('blur') + + const actual = wrapper + .find({ 'data-test': 'dhis2-uiwidgets-inputfield-validation' }) + .text() + + expect(actual).toEqual(expect.stringMatching(expected)) + }) + + it('does not show an error for a valid delay', () => { + const delay = '10' + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + wrapper + .find('input[name="delay"]') + .simulate('change', { target: { value: delay } }) + .simulate('blur') + + const actual = wrapper.find({ + 'data-test': 'dhis2-uiwidgets-inputfield-validation', + }) + + expect(actual).toHaveLength(0) + }) + + it('shows an error that the field is required on empty values', () => { + const expected = 'Please provide a value' + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + // Trigger validation + wrapper.find('form').simulate('submit') + + const actual = wrapper + .find({ 'data-test': 'dhis2-uiwidgets-inputfield-validation' }) + .text() + + expect(actual).toEqual(expect.stringMatching(expected)) + }) +}) diff --git a/src/components/FormFields/JobNameField.test.js b/src/components/FormFields/JobNameField.test.js new file mode 100644 index 000000000..45ac7394f --- /dev/null +++ b/src/components/FormFields/JobNameField.test.js @@ -0,0 +1,30 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import JobNameField from './JobNameField' + +const { Form } = ReactFinalForm + +describe('', () => { + it('shows an error that the field is required on empty values', () => { + const expected = 'Please provide a value' + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + // Trigger validation + wrapper.find('form').simulate('submit') + + const actual = wrapper + .find({ 'data-test': 'dhis2-uiwidgets-inputfield-validation' }) + .text() + + expect(actual).toEqual(expect.stringMatching(expected)) + }) +}) diff --git a/src/components/FormFields/JobTypeField.js b/src/components/FormFields/JobTypeField.js index ed0f1d96f..73a0f7b52 100644 --- a/src/components/FormFields/JobTypeField.js +++ b/src/components/FormFields/JobTypeField.js @@ -8,8 +8,8 @@ import { string, } from '@dhis2/ui' import i18n from '@dhis2/d2-i18n' +import { useDataQuery } from '@dhis2/app-runtime' import { jobTypesMap } from '../../services/server-translations' -import { useGetJobTypes } from '../../hooks/job-types' const { Field } = ReactFinalForm @@ -17,14 +17,20 @@ const { Field } = ReactFinalForm export const FIELD_NAME = 'jobType' const VALIDATOR = composeValidators(string, hasValue) +const query = { + jobTypes: { + resource: 'jobConfigurations/jobTypes', + }, +} + const JobTypeField = () => { - const { loading, error, data } = useGetJobTypes() + const { loading, error, data } = useDataQuery(query) if (loading) { return ( @@ -33,13 +39,13 @@ const JobTypeField = () => { if (error) { /** - * We need the jobtypes, so throw the error if these + * We need the data, so throw the error if it * can't be loaded. */ throw error } - const options = data.map(({ jobType }) => ({ + const options = data.jobTypes.jobTypes.map(({ jobType }) => ({ value: jobType, label: jobTypesMap[jobType], })) diff --git a/src/components/FormFields/JobTypeField.test.js b/src/components/FormFields/JobTypeField.test.js new file mode 100644 index 000000000..383d4a02d --- /dev/null +++ b/src/components/FormFields/JobTypeField.test.js @@ -0,0 +1,115 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useDataQuery } from '@dhis2/app-runtime' +import expectRenderError from '../../../test/expect-render-error' +import JobTypeField from './JobTypeField' + +const { Form } = ReactFinalForm + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows a loading message when loading', () => { + useDataQuery.mockImplementation(() => ({ + loading: true, + error: undefined, + data: null, + })) + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + // Open the menu + wrapper + .find({ 'data-test': 'dhis2-uicore-select-input' }) + .simulate('click') + + const loadingIndicator = wrapper.find({ + 'data-test': 'dhis2-uicore-singleselect-loading', + }) + + expect(loadingIndicator).toHaveLength(1) + expect(loadingIndicator.text()).toEqual( + expect.stringContaining('Loading') + ) + + /** + * Umounting manually here prevents React throwing an act warning. I suspect the warning + * is caused by the popper setting state at a point where it's not wrapped in act. + * See here: https://github.com/popperjs/react-popper/issues/368 + * Neither wrapping mount, nor the click simulation resolves the warning, but unmounting + * manually seems to silence it. It should be ok to do that since the popper changes + * should only affect placement, which we're not testing here. + */ + wrapper.unmount() + }) + + it('throws errors it encounters during fetching', () => { + const message = 'Something went wrong' + + useDataQuery.mockImplementation(() => ({ + loading: false, + error: new Error(message), + data: null, + })) + + expectRenderError( +
{}}> + {() => ( + + + + )} + , + message + ) + }) + + it('shows an error that the field is required on empty values', () => { + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { + jobTypes: { + jobTypes: [{ jobType: 'ANALYTICS_TABLE' }], + }, + }, + })) + + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + // Trigger validation + wrapper.find('form').simulate('submit') + + const actual = wrapper + .find({ + 'data-test': 'dhis2-uiwidgets-singleselectfield-validation', + }) + .text() + + expect(actual).toEqual( + expect.stringContaining('Please provide a value') + ) + }) +}) diff --git a/src/components/FormFields/LabeledOptionsField.js b/src/components/FormFields/LabeledOptionsField.js index b18166854..b089d6811 100644 --- a/src/components/FormFields/LabeledOptionsField.js +++ b/src/components/FormFields/LabeledOptionsField.js @@ -2,10 +2,20 @@ import React from 'react' import { PropTypes } from '@dhis2/prop-types' import { MultiSelectFieldFF, ReactFinalForm, MultiSelectField } from '@dhis2/ui' import i18n from '@dhis2/d2-i18n' -import { useGetLabeledOptions } from '../../hooks/parameter-options' +import { useDataQuery } from '@dhis2/app-runtime' const { Field } = ReactFinalForm +const query = { + options: { + resource: '/', + id: /* istanbul ignore next */ ({ id }) => id, + params: { + paging: false, + }, + }, +} + /** * A labeled options field has options that have both a label and a value, * as opposed to the unlabeled options field, where the options just have @@ -13,9 +23,14 @@ const { Field } = ReactFinalForm */ const LabeledOptionsField = ({ endpoint, label, name, parameterName }) => { - const { loading, error, data } = useGetLabeledOptions({ - endpoint, - parameterName, + /** + * HACK: this is a bit of a hack to allow using the useDataQuery hook with + * a dynamic query. Initially we used a custom hook for this but that + * replicated all of the internal logic of the useDataQuery hook so this + * seems like a better trade-off. + */ + const { loading, error, data } = useDataQuery(query, { + variables: { id: endpoint }, }) if (loading) { @@ -30,7 +45,10 @@ const LabeledOptionsField = ({ endpoint, label, name, parameterName }) => { throw error } - if (data.length === 0) { + if ( + !(parameterName in data.options) || + data.options[parameterName].length === 0 + ) { return ( { ) } - const options = data.map(({ id, displayName }) => ({ + const options = data.options[parameterName].map(({ id, displayName }) => ({ value: id, label: displayName, })) diff --git a/src/components/FormFields/LabeledOptionsField.test.js b/src/components/FormFields/LabeledOptionsField.test.js new file mode 100644 index 000000000..1fff40945 --- /dev/null +++ b/src/components/FormFields/LabeledOptionsField.test.js @@ -0,0 +1,184 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useDataQuery } from '@dhis2/app-runtime' +import expectRenderError from '../../../test/expect-render-error' +import LabeledOptionsField from './LabeledOptionsField' + +const { Form } = ReactFinalForm + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows a loading message when loading', () => { + useDataQuery.mockImplementation(() => ({ + loading: true, + error: undefined, + data: null, + })) + const props = { + endpoint: 'endpoint', + label: 'label', + name: 'name', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + // Open menu + wrapper + .find({ 'data-test': 'dhis2-uicore-select-input' }) + .simulate('click') + + const loadingIndicator = wrapper.find({ + 'data-test': 'dhis2-uicore-multiselect-loading', + }) + + expect(loadingIndicator).toHaveLength(1) + expect(loadingIndicator.text()).toEqual( + expect.stringContaining('Loading') + ) + + /** + * Umounting manually here prevents React throwing an act warning. I suspect the warning + * is caused by the popper setting state at a point where it's not wrapped in act. + * See here: https://github.com/popperjs/react-popper/issues/368 + * Neither wrapping mount, nor the click simulation resolves the warning, but unmounting + * manually seems to silence it. It should be ok to do that since the popper changes + * should only affect placement, which we're not testing here. + */ + wrapper.unmount() + }) + + it('throws errors it encounters during fetching', () => { + const message = 'Something went wrong' + const props = { + endpoint: 'endpoint', + label: 'label', + name: 'name', + parameterName: 'parameterName', + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: new Error(message), + data: null, + })) + + expectRenderError( +
{}}> + {() => ( + + + + )} + , + message + ) + }) + + it('shows a message when there is no parameterName field', () => { + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { options: {} }, + })) + const props = { + endpoint: 'endpoint', + label: 'label', + name: 'name', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper + .find({ + 'data-test': 'dhis2-uiwidgets-multiselectfield-help', + }) + .text() + + expect(actual).toEqual(expect.stringContaining('No options available')) + }) + + it('shows a message when there are no options', () => { + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { options: { parameterName: [] } }, + })) + const props = { + endpoint: 'endpoint', + label: 'label', + name: 'name', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper + .find({ + 'data-test': 'dhis2-uiwidgets-multiselectfield-help', + }) + .text() + + expect(actual).toEqual(expect.stringContaining('No options available')) + }) + + it('renders the field when there are options', () => { + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { + options: { + parameterName: [{ id: 'id', displayName: 'displayName' }], + }, + }, + })) + const props = { + endpoint: 'endpoint', + label: 'label', + name: 'fieldName', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper.find('LabeledOptionsField') + + expect(actual).toHaveLength(1) + }) +}) diff --git a/src/components/FormFields/ParameterFields.js b/src/components/FormFields/ParameterFields.js index c451aa87c..88b5d5a55 100644 --- a/src/components/FormFields/ParameterFields.js +++ b/src/components/FormFields/ParameterFields.js @@ -1,7 +1,12 @@ import React from 'react' import { PropTypes } from '@dhis2/prop-types' import { ReactFinalForm, InputFieldFF, SwitchFieldFF, Box } from '@dhis2/ui' -import { useGetJobTypes, selectors } from '../../hooks/job-types' +import { useDataQuery } from '@dhis2/app-runtime' +import { + getJobTypeParameters, + getParameterEndpoint, + getStringValue, +} from './selectors' import UnlabeledOptionsField from './UnlabeledOptionsField' import LabeledOptionsField from './LabeledOptionsField' import styles from './ParameterFields.module.css' @@ -11,9 +16,15 @@ const { Field } = ReactFinalForm // The key under which the parameters will be sent to the backend const FIELD_NAME = 'jobParameters' +const query = { + jobTypes: { + resource: 'jobConfigurations/jobTypes', + }, +} + // Renders all parameters for a given jobtype const ParameterFields = ({ jobType }) => { - const { loading, error, data } = useGetJobTypes() + const { loading, error, data } = useDataQuery(query) if (loading) { return null @@ -27,7 +38,7 @@ const ParameterFields = ({ jobType }) => { throw error } - const parameters = selectors.getJobTypeParameters(data, jobType) + const parameters = getJobTypeParameters(data.jobTypes.jobTypes, jobType) if (parameters.length === 0) { return null @@ -40,7 +51,7 @@ const ParameterFields = ({ jobType }) => { label: fieldName, name: `${FIELD_NAME}.${name}`, } - const endpoint = selectors.getParameterEndpoint(relativeApiEndpoint) + const endpoint = getParameterEndpoint(relativeApiEndpoint) let parameterComponent = null switch (klass) { @@ -67,6 +78,7 @@ const ParameterFields = ({ jobType }) => { ) diff --git a/src/components/FormFields/ParameterFields.test.js b/src/components/FormFields/ParameterFields.test.js new file mode 100644 index 000000000..5d9dd5165 --- /dev/null +++ b/src/components/FormFields/ParameterFields.test.js @@ -0,0 +1,298 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useDataQuery } from '@dhis2/app-runtime' +import expectRenderError from '../../../test/expect-render-error' +import ParameterFields from './ParameterFields' + +const { Form } = ReactFinalForm + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), +})) + +// Using mocks here to prevent these components from making network requests +jest.mock('./LabeledOptionsField', () => () => 'LabeledOptionsField') +jest.mock('./UnlabeledOptionsField', () => () => 'UnlabeledOptionsField') + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('returns null when loading', () => { + useDataQuery.mockImplementation(() => ({ + loading: true, + error: undefined, + data: null, + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const children = wrapper.find('form').children() + + expect(children.isEmptyRender()).toBe(true) + }) + + it('throws errors it encounters during fetching', () => { + const message = 'Something went wrong' + useDataQuery.mockImplementation(() => ({ + loading: false, + error: new Error(message), + data: null, + })) + const props = { + jobType: 'jobType', + } + expectRenderError( +
{}}> + {() => ( + + + + )} + , + message + ) + }) + + it('returns null if there are no parameters', () => { + const data = { jobTypes: { jobTypes: [{ jobType: 'jobType' }] } } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data, + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const children = wrapper.find('form').children() + + expect(children.isEmptyRender()).toBe(true) + }) + + it('returns the expected component for java.lang.String', () => { + const data = { + jobTypes: { + jobTypes: [ + { + jobType: 'jobType', + jobParameters: [ + { + fieldName: 'fieldName', + name: 'name', + klass: 'java.lang.String', + relativeApiEndpoint: 'relativeApiEndpoint', + }, + ], + }, + ], + }, + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data, + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const component = wrapper.find('InputFieldFF') + + expect(component).toHaveLength(1) + }) + + it('returns the expected component for java.lang.Boolean', () => { + const data = { + jobTypes: { + jobTypes: [ + { + jobType: 'jobType', + jobParameters: [ + { + fieldName: 'fieldName', + name: 'name', + klass: 'java.lang.Boolean', + relativeApiEndpoint: 'relativeApiEndpoint', + }, + ], + }, + ], + }, + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data, + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const component = wrapper.find('SwitchFieldFF') + + expect(component).toHaveLength(1) + }) + + it('returns the expected component for java.lang.Integer', () => { + const data = { + jobTypes: { + jobTypes: [ + { + jobType: 'jobType', + jobParameters: [ + { + fieldName: 'fieldName', + name: 'name', + klass: 'java.lang.Integer', + relativeApiEndpoint: 'relativeApiEndpoint', + }, + ], + }, + ], + }, + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data, + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const component = wrapper.find('InputFieldFF') + + expect(component).toHaveLength(1) + }) + + it('returns the expected component for java.util.Set', () => { + const data = { + jobTypes: { + jobTypes: [ + { + jobType: 'jobType', + jobParameters: [ + { + fieldName: 'fieldName', + name: 'name', + klass: 'java.util.Set', + relativeApiEndpoint: 'relativeApiEndpoint', + }, + ], + }, + ], + }, + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data, + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper.text() + + expect(actual).toEqual(expect.stringContaining('UnlabeledOptionsField')) + }) + + it('returns the expected component for java.util.List', () => { + const data = { + jobTypes: { + jobTypes: [ + { + jobType: 'jobType', + jobParameters: [ + { + fieldName: 'fieldName', + name: 'name', + klass: 'java.util.List', + relativeApiEndpoint: 'relativeApiEndpoint', + }, + ], + }, + ], + }, + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data, + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper.text() + + expect(actual).toEqual(expect.stringContaining('LabeledOptionsField')) + }) +}) diff --git a/src/components/FormFields/ScheduleField.js b/src/components/FormFields/ScheduleField.js index 27c2a25bc..1316840fe 100644 --- a/src/components/FormFields/ScheduleField.js +++ b/src/components/FormFields/ScheduleField.js @@ -1,11 +1,18 @@ import React from 'react' import { PropTypes } from '@dhis2/prop-types' -import { useGetJobTypes, selectors } from '../../hooks/job-types' +import { useDataQuery } from '@dhis2/app-runtime' +import { getJobTypeObject } from './selectors' import CronField from './CronField' import DelayField from './DelayField' +const query = { + jobTypes: { + resource: 'jobConfigurations/jobTypes', + }, +} + const ScheduleField = ({ jobType }) => { - const { loading, error, data } = useGetJobTypes() + const { loading, error, data } = useDataQuery(query) if (loading) { return null @@ -19,7 +26,7 @@ const ScheduleField = ({ jobType }) => { throw error } - const currentJob = selectors.getJobTypeObject(data, jobType) + const currentJob = getJobTypeObject(data.jobTypes.jobTypes, jobType) const schedulingType = currentJob.schedulingType switch (schedulingType) { diff --git a/src/components/FormFields/ScheduleField.test.js b/src/components/FormFields/ScheduleField.test.js new file mode 100644 index 000000000..0e9ae4953 --- /dev/null +++ b/src/components/FormFields/ScheduleField.test.js @@ -0,0 +1,151 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useDataQuery } from '@dhis2/app-runtime' +import expectRenderError from '../../../test/expect-render-error' +import ScheduleField from './ScheduleField' + +const { Form } = ReactFinalForm + +// Mock these components to simplify this test +jest.mock('./CronField', () => () =>
CronField
) +jest.mock('./DelayField', () => () =>
DelayField
) + +// Moch getJobTypeObject, since we're going to test it separately +jest.mock('./selectors', () => ({ + getJobTypeObject: data => data, +})) + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('returns null when loading', () => { + useDataQuery.mockImplementation(() => ({ + loading: true, + error: undefined, + data: null, + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const children = wrapper.find('form').children() + + expect(children.isEmptyRender()).toBe(true) + }) + + it('throws errors it encounters during fetching', () => { + const message = 'Something went wrong' + const props = { + jobType: 'jobType', + } + + useDataQuery.mockImplementation(() => ({ + loading: false, + error: new Error(message), + data: null, + })) + + expectRenderError( +
{}}> + {() => ( + + + + )} + , + message + ) + }) + + it('renders the cron field if the scheduling type is CRON', () => { + const props = { + jobType: 'jobType', + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { jobTypes: { jobTypes: { schedulingType: 'CRON' } } }, + })) + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper.text() + + expect(actual).toEqual(expect.stringContaining('CronField')) + expect(actual).toEqual(expect.not.stringContaining('DelayField')) + }) + + it('renders the delay field if the scheduling type is FIXED_DELAY', () => { + const props = { + jobType: 'jobType', + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { jobTypes: { jobTypes: { schedulingType: 'FIXED_DELAY' } } }, + })) + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper.text() + + expect(actual).toEqual(expect.stringContaining('DelayField')) + expect(actual).toEqual(expect.not.stringContaining('CronField')) + }) + + it('returns null for unrecognised scheduling types', () => { + const props = { + jobType: 'jobType', + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { + jobTypes: { jobTypes: { schedulingType: 'DOES_NOT_EXIST' } }, + }, + })) + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const children = wrapper.find('form').children() + + expect(children.isEmptyRender()).toBe(true) + }) +}) diff --git a/src/components/FormFields/UnlabeledOptionsField.js b/src/components/FormFields/UnlabeledOptionsField.js index b6667fbc7..c372ed16a 100644 --- a/src/components/FormFields/UnlabeledOptionsField.js +++ b/src/components/FormFields/UnlabeledOptionsField.js @@ -1,11 +1,21 @@ import React from 'react' import { PropTypes } from '@dhis2/prop-types' import i18n from '@dhis2/d2-i18n' +import { useDataQuery } from '@dhis2/app-runtime' import { MultiSelectField, ReactFinalForm, MultiSelectFieldFF } from '@dhis2/ui' -import { useGetUnlabeledOptions } from '../../hooks/parameter-options' const { Field } = ReactFinalForm +const query = { + options: { + resource: '/', + id: /* istanbul ignore next */ ({ id }) => id, + params: { + paging: false, + }, + }, +} + /** * An unlabeled options field has options that are just values, as opposed * to the labeled options field, where the options have labels as well as @@ -13,8 +23,14 @@ const { Field } = ReactFinalForm */ const UnlabeledOptionsField = ({ endpoint, label, name }) => { - const { loading, error, data } = useGetUnlabeledOptions({ - endpoint, + /** + * HACK: this is a bit of a hack to allow using the useDataQuery hook with + * a dynamic query. Initially we used a custom hook for this but that + * replicated all of the internal logic of the useDataQuery hook so this + * seems like a better trade-off. + */ + const { loading, error, data } = useDataQuery(query, { + variables: { id: endpoint }, }) if (loading) { @@ -29,7 +45,7 @@ const UnlabeledOptionsField = ({ endpoint, label, name }) => { throw error } - if (data.length === 0) { + if (data.options.length === 0) { return ( { ) } - const options = data.map(option => ({ + const options = data.options.map(option => ({ value: option, label: option, })) diff --git a/src/components/FormFields/UnlabeledOptionsField.test.js b/src/components/FormFields/UnlabeledOptionsField.test.js new file mode 100644 index 000000000..814607e95 --- /dev/null +++ b/src/components/FormFields/UnlabeledOptionsField.test.js @@ -0,0 +1,149 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useDataQuery } from '@dhis2/app-runtime' +import expectRenderError from '../../../test/expect-render-error' +import UnlabeledOptionsField from './UnlabeledOptionsField' + +const { Form } = ReactFinalForm + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows a loading message when loading', () => { + useDataQuery.mockImplementation(() => ({ + loading: true, + error: undefined, + data: null, + })) + const props = { + endpoint: 'endpoint', + label: 'label', + name: 'name', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + // Open menu + wrapper + .find({ 'data-test': 'dhis2-uicore-select-input' }) + .simulate('click') + + const loadingIndicator = wrapper.find({ + 'data-test': 'dhis2-uicore-multiselect-loading', + }) + + expect(loadingIndicator.length).toBe(1) + expect(loadingIndicator.text()).toEqual( + expect.stringContaining('Loading') + ) + + /** + * Umounting manually here prevents React throwing an act warning. I suspect the warning + * is caused by the popper setting state at a point where it's not wrapped in act. + * See here: https://github.com/popperjs/react-popper/issues/368 + * Neither wrapping mount, nor the click simulation resolves the warning, but unmounting + * manually seems to silence it. It should be ok to do that since the popper changes + * should only affect placement, which we're not testing here. + */ + wrapper.unmount() + }) + + it('throws errors it encounters during fetching', () => { + const message = 'Something went wrong' + const props = { + endpoint: 'endpoint', + label: 'label', + name: 'name', + parameterName: 'parameterName', + } + useDataQuery.mockImplementation(() => ({ + loading: false, + error: new Error(message), + data: null, + })) + + expectRenderError( +
{}}> + {() => ( + + + + )} + , + message + ) + }) + + it('shows a message when there are no options', () => { + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { options: [] }, + })) + const props = { + endpoint: 'endpoint', + label: 'label', + name: 'name', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper + .find({ + 'data-test': 'dhis2-uiwidgets-multiselectfield-help', + }) + .text() + + expect(actual).toEqual(expect.stringContaining('No options available')) + }) + + it('renders the field when there are options', () => { + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { options: ['one', 'two'] }, + })) + const props = { + endpoint: 'endpoint', + label: 'label', + name: 'fieldName', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper.find('UnlabeledOptionsField') + + expect(actual).toHaveLength(1) + }) +}) diff --git a/src/hooks/job-types/use-get-job-types.js b/src/components/FormFields/selectors.js similarity index 63% rename from src/hooks/job-types/use-get-job-types.js rename to src/components/FormFields/selectors.js index 59c13833e..2d8fdf070 100644 --- a/src/hooks/job-types/use-get-job-types.js +++ b/src/components/FormFields/selectors.js @@ -1,27 +1,3 @@ -import { useDataQuery } from '@dhis2/app-runtime' - -const query = { - jobTypes: { - resource: 'jobConfigurations/jobTypes', - }, -} - -const useGetJobTypes = () => { - const { loading, error, data, refetch } = useDataQuery(query) - - if (data && data.jobTypes && data.jobTypes.jobTypes) { - return { loading, error, refetch, data: data.jobTypes.jobTypes } - } - - return { loading, error, refetch, data } -} - -export default useGetJobTypes - -/** - * Selectors - */ - /** * Cleans up the endpoint for use with the data engine */ @@ -57,3 +33,16 @@ export const getJobTypeParameters = (jobTypes, jobType) => { return selectedJobType.jobParameters } + +/** + * Our backend returns certain values as a number, but our + * inputs expect and return a string, so we're formatting them to strings + */ + +export const getStringValue = value => { + if (typeof value === 'number') { + return value.toString() + } + + return value +} diff --git a/src/components/FormFields/selectors.test.js b/src/components/FormFields/selectors.test.js new file mode 100644 index 000000000..f8a22d995 --- /dev/null +++ b/src/components/FormFields/selectors.test.js @@ -0,0 +1,95 @@ +import { + getParameterEndpoint, + getJobTypeObject, + getJobTypeParameters, + getStringValue, +} from './selectors' + +describe('getParameterEndpoint', () => { + it('removes /api/ from the start of the endpoint', () => { + const endpoint = '/api/resource' + const expected = 'resource' + const actual = getParameterEndpoint(endpoint) + + expect(actual).toBe(expected) + }) + + it('returns empty strings as is', () => { + const endpoint = '' + const expected = '' + const actual = getParameterEndpoint(endpoint) + + expect(actual).toBe(expected) + }) + + it('returns endpoints that are not preceded by /api/ as is', () => { + const endpoint = 'resource' + const expected = 'resource' + const actual = getParameterEndpoint(endpoint) + + expect(actual).toBe(expected) + }) +}) + +describe('getJobTypeObject', () => { + it('returns the requested job type object', () => { + const jobTypes = [ + { jobType: 'one' }, + { jobType: 'two' }, + { jobType: 'three' }, + ] + const jobType = 'one' + const expected = { jobType: 'one' } + const actual = getJobTypeObject(jobTypes, jobType) + + expect(actual).toEqual(expected) + }) +}) + +describe('getJobTypeParameters', () => { + it('returns an array with all parameters for the requested job type', () => { + const jobTypes = [ + { jobType: 'one', jobParameters: ['parameter one'] }, + { jobType: 'two', jobParameters: ['parameter two'] }, + { jobType: 'three', jobParameters: ['parameter three'] }, + ] + const jobType = 'one' + const expected = ['parameter one'] + const actual = getJobTypeParameters(jobTypes, jobType) + + expect(actual).toEqual(expected) + }) + + it('returns an empty array for a job type that has no job parameters', () => { + const jobTypes = [ + { jobType: 'one' }, + { jobType: 'two' }, + { jobType: 'three' }, + ] + const jobType = 'one' + const expected = [] + const actual = getJobTypeParameters(jobTypes, jobType) + + expect(actual).toEqual(expected) + }) +}) + +describe('getStringValue', () => { + it('returns numbers as a string', () => { + const number = 1 + const expected = '1' + const actual = getStringValue(number) + + expect(actual).toBe(expected) + }) + + it('returns values that are not numbers as is', () => { + const str = '1' + const arr = [] + const obj = {} + + expect(getStringValue(str)).toBe(str) + expect(getStringValue(arr)).toBe(arr) + expect(getStringValue(obj)).toBe(obj) + }) +}) diff --git a/src/components/Forms/JobFormContainer.js b/src/components/Forms/JobAddFormContainer.js similarity index 85% rename from src/components/Forms/JobFormContainer.js rename to src/components/Forms/JobAddFormContainer.js index 6af643ad1..3ee51fa4a 100644 --- a/src/components/Forms/JobFormContainer.js +++ b/src/components/Forms/JobAddFormContainer.js @@ -6,7 +6,7 @@ import JobForm from './JobForm' const { Form } = ReactFinalForm -const JobFormContainer = ({ setIsPristine }) => { +const JobAddFormContainer = ({ setIsPristine }) => { const [submitJob] = useSubmitJob() /** @@ -25,8 +25,8 @@ const JobFormContainer = ({ setIsPristine }) => { const { func } = PropTypes -JobFormContainer.propTypes = { +JobAddFormContainer.propTypes = { setIsPristine: func.isRequired, } -export default JobFormContainer +export default JobAddFormContainer diff --git a/src/components/Forms/JobAddFormContainer.test.js b/src/components/Forms/JobAddFormContainer.test.js new file mode 100644 index 000000000..6d9794f25 --- /dev/null +++ b/src/components/Forms/JobAddFormContainer.test.js @@ -0,0 +1,13 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobAddFormContainer from './JobAddFormContainer' + +describe('', () => { + it('renders without errors', () => { + const props = { + setIsPristine: () => {}, + } + + shallow() + }) +}) diff --git a/src/components/Forms/JobEditFormContainer.js b/src/components/Forms/JobEditFormContainer.js new file mode 100644 index 000000000..9edfe5454 --- /dev/null +++ b/src/components/Forms/JobEditFormContainer.js @@ -0,0 +1,77 @@ +import React from 'react' +import { PropTypes } from '@dhis2/prop-types' +import { ReactFinalForm } from '@dhis2/ui' +import { useParams } from 'react-router-dom' +import { useDataQuery } from '@dhis2/app-runtime' +import { useUpdateJob } from '../../hooks/jobs' +import JobForm from './JobForm' + +const { Form } = ReactFinalForm + +/** + * The fields we need for the initialValues for our form fields. Since we use + * these values to set the initial values in final-form, if we wouldn't filter + * them we'd end up submitting way more data than we intend to. + */ + +const whitelistedFields = [ + 'cronExpression', + 'delay', + 'jobParameters', + 'jobType', + 'name', + 'schedulingType', +] + +const query = { + job: { + resource: 'jobConfigurations', + id: /* istanbul ignore next */ ({ id }) => id, + params: { + paging: false, + fields: whitelistedFields.join(','), + }, + }, +} + +const JobEditFormContainer = ({ setIsPristine }) => { + const { id } = useParams() + const { loading, error, data } = useDataQuery(query, { variables: { id } }) + const [updateJob] = useUpdateJob({ id }) + + if (loading) { + return null + } + + /* istanbul ignore next: we're testing this section, but coverage reporting seems to disagree */ + if (error) { + /** + * We need the data, so throw the error if it + * can't be loaded. + */ + throw error + } + + /** + * destroyOnUnregister is enabled so that dynamic fields will be unregistered + * when they're removed from the form, for instance when the jobType changes. + */ + /* istanbul ignore next: we're testing this section, but coverage reporting seems to disagree */ + return ( +
+ ) +} + +const { func } = PropTypes + +JobEditFormContainer.propTypes = { + setIsPristine: func.isRequired, +} + +export default JobEditFormContainer diff --git a/src/components/Forms/JobEditFormContainer.test.js b/src/components/Forms/JobEditFormContainer.test.js new file mode 100644 index 000000000..c0b0c5b17 --- /dev/null +++ b/src/components/Forms/JobEditFormContainer.test.js @@ -0,0 +1,61 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { useDataQuery } from '@dhis2/app-runtime' +import expectRenderError from '../../../test/expect-render-error' +import JobEditFormContainer from './JobEditFormContainer' + +jest.mock('react-router-dom', () => ({ + useParams: () => ({ id: 'id' }), +})) + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), + useDataEngine: () => {}, +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('returns null when loading', () => { + useDataQuery.mockImplementation(() => ({ + loading: true, + })) + const props = { + setIsPristine: () => {}, + } + + const wrapper = shallow() + + expect(wrapper.isEmptyRender()).toBe(true) + }) + + it('throws errors it encounters during fetching', () => { + const message = 'Something went wrong' + const props = { + setIsPristine: () => {}, + } + + useDataQuery.mockImplementation(() => ({ + loading: false, + error: new Error(message), + data: null, + })) + + expectRenderError(, message) + }) + + it('renders without errors if there is data', () => { + useDataQuery.mockImplementation(() => ({ + loading: true, + error: undefined, + data: { job: {} }, + })) + const props = { + setIsPristine: () => {}, + } + + shallow() + }) +}) diff --git a/src/components/Forms/JobForm.js b/src/components/Forms/JobForm.js index 5f47407f3..f6a394452 100644 --- a/src/components/Forms/JobForm.js +++ b/src/components/Forms/JobForm.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import { PropTypes } from '@dhis2/prop-types' import i18n from '@dhis2/d2-i18n' import { Button, CircularLoader, Box, ReactFinalForm } from '@dhis2/ui' @@ -24,9 +24,15 @@ const JobForm = ({ values, setIsPristine, }) => { - // Lift pristine state up on changes const { subscribe } = useForm() - subscribe(({ pristine }) => setIsPristine(pristine), { pristine: true }) + + /** + * Lift pristine state up on changes, wrapped in useEffect because calls to setState + * outside of the component that owns the setState should not happen synchronously. + */ + useEffect(() => { + subscribe(({ pristine }) => setIsPristine(pristine), { pristine: true }) + }) // Check if there's currently a selected job type const jobType = values[fieldNames.JOB_TYPE] @@ -65,7 +71,7 @@ const JobForm = ({ icon={Spinner} className={styles.saveButton} > - {i18n.t('Save job')} + {i18n.t('Save')} {i18n.t('Cancel')} diff --git a/src/components/Forms/JobForm.test.js b/src/components/Forms/JobForm.test.js new file mode 100644 index 000000000..85bebd0fc --- /dev/null +++ b/src/components/Forms/JobForm.test.js @@ -0,0 +1,171 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { fieldNames } from '../FormFields' +import JobForm from './JobForm' + +const { Form } = ReactFinalForm + +// Mock components that make network requests +jest.mock('../FormFields/JobTypeField', () => () => ( +
JobTypeField
+)) +jest.mock('../FormFields/ScheduleField', () => () => ( +
ScheduleField
+)) +jest.mock('../FormFields/ParameterFields', () => () => ( +
ParameterFields
+)) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows submit errors if there are any', () => { + const message = 'Generic submit error' + const props = { + handleSubmit: () => {}, + pristine: false, + submitting: false, + submitError: [message], + hasSubmitErrors: true, + values: {}, + setIsPristine: () => {}, + } + + const wrapper = mount( + {}}>{() => } + ) + const actual = wrapper.find({ + 'data-test': 'dhis2-uicore-noticebox-message', + }) + + expect(actual).toHaveLength(1) + expect(actual.text()).toEqual(expect.stringContaining(message)) + }) + + it('calls setIsPristine on form changes', () => { + const spy = jest.fn() + const wrapper = mount( +
{}} setIsPristine={spy} component={JobForm} /> + ) + + wrapper + .find({ id: 'name' }) + .simulate('change', { target: { value: 'A change' } }) + + expect(spy).toHaveBeenCalledWith(false) + }) + + it('shows a spinner when submitting', () => { + const props = { + handleSubmit: () => {}, + pristine: false, + submitting: true, + submitError: [], + hasSubmitErrors: false, + values: {}, + setIsPristine: () => {}, + } + + const wrapper = mount( + {}}>{() => } + ) + + const submitButton = wrapper.find({ + 'data-test': 'dhis2-uicore-button', + type: 'submit', + }) + + const circularLoader = submitButton.find({ + 'data-test': 'dhis2-uicore-circularloader', + }) + const progressBar = submitButton.find({ role: 'progressbar' }) + + expect(circularLoader).toHaveLength(1) + expect(progressBar).toHaveLength(1) + }) + + it('shows the schedule field when a jobtype is selected', () => { + const wrapper = mount( +
{}} + setIsPristine={() => {}} + component={JobForm} + initialValues={{ + [fieldNames.JOB_TYPE]: 'jobType', + }} + /> + ) + + const actual = wrapper.find({ 'data-test': 'schedule-field' }) + + expect(actual).toHaveLength(1) + }) + + it('shows the parameter fields when a jobtype is selected', () => { + const wrapper = mount( + {}} + setIsPristine={() => {}} + component={JobForm} + initialValues={{ + [fieldNames.JOB_TYPE]: 'jobType', + }} + /> + ) + + const actual = wrapper.find({ 'data-test': 'parameter-fields' }) + + expect(actual).toHaveLength(1) + }) + + it('disables the submit button when pristine', () => { + const props = { + handleSubmit: () => {}, + pristine: true, + submitting: false, + submitError: [], + hasSubmitErrors: false, + values: {}, + setIsPristine: () => {}, + } + + const wrapper = mount( + {}}>{() => } + ) + + const actual = wrapper.find('button').find({ + 'data-test': 'dhis2-uicore-button', + type: 'submit', + disabled: true, + }) + + expect(actual).toHaveLength(1) + }) + + it('disables the submit button when submitting', () => { + const props = { + handleSubmit: () => {}, + pristine: false, + submitting: true, + submitError: [], + hasSubmitErrors: false, + values: {}, + setIsPristine: () => {}, + } + + const wrapper = mount( +
{}}>{() => } + ) + + const actual = wrapper.find('button').find({ + 'data-test': 'dhis2-uicore-button', + type: 'submit', + disabled: true, + }) + + expect(actual).toHaveLength(1) + }) +}) diff --git a/src/components/Forms/index.js b/src/components/Forms/index.js index 4cdcdc21a..f86d53b0f 100644 --- a/src/components/Forms/index.js +++ b/src/components/Forms/index.js @@ -1,3 +1,4 @@ -import JobFormContainer from './JobFormContainer' +import JobAddFormContainer from './JobAddFormContainer' +import JobEditFormContainer from './JobEditFormContainer' -export { JobFormContainer } +export { JobAddFormContainer, JobEditFormContainer } diff --git a/src/components/Icons/InfoIcon.test.js b/src/components/Icons/InfoIcon.test.js index 0caff4d5b..37e86ffcc 100644 --- a/src/components/Icons/InfoIcon.test.js +++ b/src/components/Icons/InfoIcon.test.js @@ -3,9 +3,7 @@ import { shallow } from 'enzyme' import InfoIcon from './InfoIcon' describe('', () => { - it('renders correctly', () => { - const wrapper = shallow() - - expect(wrapper).toMatchSnapshot() + it('renders without errors', () => { + shallow() }) }) diff --git a/src/components/Icons/__snapshots__/InfoIcon.test.js.snap b/src/components/Icons/__snapshots__/InfoIcon.test.js.snap deleted file mode 100644 index 6c706c6e6..000000000 --- a/src/components/Icons/__snapshots__/InfoIcon.test.js.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - - - - -`; diff --git a/src/components/Modal/CronPresetModal.test.js b/src/components/Modal/CronPresetModal.test.js index 508424174..907bd4ebf 100644 --- a/src/components/Modal/CronPresetModal.test.js +++ b/src/components/Modal/CronPresetModal.test.js @@ -3,14 +3,13 @@ import { shallow, mount } from 'enzyme' import CronPresetModal from './CronPresetModal' describe('', () => { - it('renders correctly', () => { + it('renders without errors', () => { const props = { hideModal: () => {}, setCron: () => {}, } - const wrapper = shallow() - expect(wrapper).toMatchSnapshot() + shallow() }) it('calls hideModal when cancel button is clicked', () => { @@ -20,7 +19,10 @@ describe('', () => { } const wrapper = mount() - wrapper.find('button[name="hide-modal"]').simulate('click') + wrapper + .find('button') + .find({ name: 'hide-modal' }) + .simulate('click') expect(props.hideModal).toHaveBeenCalled() }) @@ -35,9 +37,14 @@ describe('', () => { const wrapper = mount() wrapper - .find(`input[value="${value}"]`) + .find('input') + .find({ value }) .simulate('change', { target: { value } }) - wrapper.find('button[name="insert-preset"]').simulate('click') + + wrapper + .find('button') + .find({ name: 'insert-preset' }) + .simulate('click') expect(props.setCron).toHaveBeenCalledWith(value) expect(props.hideModal).toHaveBeenCalled() @@ -50,7 +57,10 @@ describe('', () => { } const wrapper = mount() - wrapper.find('div[data-test="dhis2-uicore-layer"]').simulate('click') + wrapper + .find('div') + .find({ 'data-test': 'dhis2-uicore-layer' }) + .simulate('click') expect(props.hideModal).toHaveBeenCalled() }) diff --git a/src/components/Modal/DeleteJobModal.js b/src/components/Modal/DeleteJobModal.js index 02354bd68..f69a9450e 100644 --- a/src/components/Modal/DeleteJobModal.js +++ b/src/components/Modal/DeleteJobModal.js @@ -8,11 +8,17 @@ import { ButtonStrip, } from '@dhis2/ui' import i18n from '@dhis2/d2-i18n' +import { useDataMutation } from '@dhis2/app-runtime' import { RefetchJobsContext } from '../Context' -import { useDeleteJob } from '../../hooks/jobs' + +const mutation = { + resource: 'jobConfigurations', + id: /* istanbul ignore next */ ({ id }) => id, + type: 'delete', +} const DeleteJobModal = ({ id, hideModal }) => { - const [deleteJob] = useDeleteJob() + const [deleteJob] = useDataMutation(mutation) const refetch = useContext(RefetchJobsContext) return ( diff --git a/src/components/Modal/DeleteJobModal.test.js b/src/components/Modal/DeleteJobModal.test.js index dc5fae6a6..6faabecfe 100644 --- a/src/components/Modal/DeleteJobModal.test.js +++ b/src/components/Modal/DeleteJobModal.test.js @@ -1,32 +1,42 @@ import React from 'react' import { shallow, mount } from 'enzyme' -import { useDeleteJob } from '../../hooks/jobs' +import { useDataMutation } from '@dhis2/app-runtime' import { RefetchJobsContext } from '../Context' import DeleteJobModal from './DeleteJobModal' -jest.mock('../../hooks/jobs', () => ({ - useDeleteJob: jest.fn(() => [() => {}]), +jest.mock('@dhis2/app-runtime', () => ({ + useDataMutation: jest.fn(), })) +afterEach(() => { + jest.resetAllMocks() +}) + describe('', () => { - it('renders correctly', () => { + it('renders without errors', () => { + useDataMutation.mockImplementation(() => [() => {}]) + const props = { id: 'id', hideModal: () => {}, } - const wrapper = shallow() - expect(wrapper).toMatchSnapshot() + shallow() }) it('calls hideModal when cancel button is clicked', () => { + useDataMutation.mockImplementation(() => [() => {}]) + const props = { id: 'id', hideModal: jest.fn(), } const wrapper = mount() - wrapper.find('button[name="hide-modal"]').simulate('click') + wrapper + .find('button') + .find({ name: 'hide-modal' }) + .simulate('click') expect(props.hideModal).toHaveBeenCalled() }) @@ -41,7 +51,7 @@ describe('', () => { hideModal: hideModalSpy, } - useDeleteJob.mockImplementationOnce(() => [deleteJobSpy]) + useDataMutation.mockImplementation(() => [deleteJobSpy]) const wrapper = mount( @@ -49,7 +59,10 @@ describe('', () => { ) - wrapper.find('button[name="delete-job-id"]').simulate('click') + wrapper + .find('button') + .find({ name: 'delete-job-id' }) + .simulate('click') await deletion @@ -59,13 +72,18 @@ describe('', () => { }) it('calls hideModal when cover is clicked', () => { + useDataMutation.mockImplementation(() => [() => {}]) + const props = { id: 'id', hideModal: jest.fn(), } const wrapper = mount() - wrapper.find('div[data-test="dhis2-uicore-layer"]').simulate('click') + wrapper + .find('div') + .find({ 'data-test': 'dhis2-uicore-layer' }) + .simulate('click') expect(props.hideModal).toHaveBeenCalled() }) diff --git a/src/components/Modal/DiscardFormModal.test.js b/src/components/Modal/DiscardFormModal.test.js index 4f3eda862..6d6c89d56 100644 --- a/src/components/Modal/DiscardFormModal.test.js +++ b/src/components/Modal/DiscardFormModal.test.js @@ -7,18 +7,23 @@ jest.mock('../../services/history', () => ({ push: jest.fn(), })) -describe('', () => { - it('renders correctly', () => { - const wrapper = shallow( {}} />) +afterEach(() => { + jest.resetAllMocks() +}) - expect(wrapper).toMatchSnapshot() +describe('', () => { + it('renders without errors', () => { + shallow( {}} />) }) it('calls hideModal when cancel button is clicked', () => { const spy = jest.fn() const wrapper = mount() - wrapper.find('button[name="cancel-discard-form"]').simulate('click') + wrapper + .find('button') + .find({ name: 'cancel-discard-form' }) + .simulate('click') expect(spy).toHaveBeenCalled() }) @@ -27,7 +32,10 @@ describe('', () => { const spy = jest.fn() const wrapper = mount() - wrapper.find('button[name="discard-form"]').simulate('click') + wrapper + .find('button') + .find({ name: 'discard-form' }) + .simulate('click') expect(history.push).toHaveBeenCalledWith('/') expect(spy).toHaveBeenCalled() @@ -37,7 +45,10 @@ describe('', () => { const spy = jest.fn() const wrapper = mount() - wrapper.find('div[data-test="dhis2-uicore-layer"]').simulate('click') + wrapper + .find('div') + .find({ 'data-test': 'dhis2-uicore-layer' }) + .simulate('click') expect(spy).toHaveBeenCalled() }) diff --git a/src/components/Modal/RunJobModal.js b/src/components/Modal/RunJobModal.js index 58e54b0ab..d48d6c055 100644 --- a/src/components/Modal/RunJobModal.js +++ b/src/components/Modal/RunJobModal.js @@ -35,7 +35,7 @@ const RunJobModal = ({ id, hideModal }) => { name={`run-job-${id}`} primary onClick={() => { - runJob({ id }).then(() => { + runJob().then(() => { hideModal() refetch() }) diff --git a/src/components/Modal/RunJobModal.test.js b/src/components/Modal/RunJobModal.test.js index 53784cdce..73a64bff0 100644 --- a/src/components/Modal/RunJobModal.test.js +++ b/src/components/Modal/RunJobModal.test.js @@ -1,42 +1,95 @@ import React from 'react' import { shallow, mount } from 'enzyme' +import { useDataEngine } from '@dhis2/app-runtime' +import { RefetchJobsContext } from '../Context' import RunJobModal from './RunJobModal' +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + describe('', () => { - it('renders correctly', () => { + it('renders without errors', () => { + useDataEngine.mockImplementation(() => ({ + query: () => () => Promise.resolve(), + })) + const props = { id: 'id', hideModal: () => {}, - runJob: () => {}, } - const wrapper = shallow() - expect(wrapper).toMatchSnapshot() + shallow() }) it('calls hideModal when cancel button is clicked', () => { + useDataEngine.mockImplementation(() => ({ + query: () => () => Promise.resolve(), + })) + const props = { id: 'id', hideModal: jest.fn(), - runJob: () => {}, } const wrapper = mount() - wrapper.find('button[name="hide-modal"]').simulate('click') + wrapper + .find('button') + .find({ name: 'hide-modal' }) + .simulate('click') expect(props.hideModal).toHaveBeenCalled() }) it('calls hideModal when cover is clicked', () => { + useDataEngine.mockImplementation(() => ({ + query: () => () => Promise.resolve(), + })) + const props = { id: 'id', hideModal: jest.fn(), - runJob: () => {}, } const wrapper = mount() - wrapper.find('div[data-test="dhis2-uicore-layer"]').simulate('click') + wrapper.find({ 'data-test': 'dhis2-uicore-layer' }).simulate('click') expect(props.hideModal).toHaveBeenCalled() }) + + it('runs the expected tasks after a click on run job', async () => { + const resolvedPromise = Promise.resolve() + const querySpy = jest.fn(() => resolvedPromise) + const refetchSpy = jest.fn() + const hideModalSpy = jest.fn() + const engineMock = { + query: querySpy, + } + useDataEngine.mockImplementation(() => engineMock) + + const props = { + id: 'id', + hideModal: hideModalSpy, + } + const wrapper = mount( + + + + ) + + wrapper + .find('button') + .find({ name: 'run-job-id', type: 'button' }) + .simulate('click') + + await resolvedPromise + + expect(querySpy).toHaveBeenCalled() + expect(hideModalSpy).toHaveBeenCalled() + expect(refetchSpy).toHaveBeenCalled() + }) }) diff --git a/src/components/Modal/__snapshots__/CronPresetModal.test.js.snap b/src/components/Modal/__snapshots__/CronPresetModal.test.js.snap deleted file mode 100644 index 7b3f84c25..000000000 --- a/src/components/Modal/__snapshots__/CronPresetModal.test.js.snap +++ /dev/null @@ -1,89 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - - - Choose a preset time/interval - - - - - - - - - - - - - - - -`; diff --git a/src/components/Modal/__snapshots__/DeleteJobModal.test.js.snap b/src/components/Modal/__snapshots__/DeleteJobModal.test.js.snap deleted file mode 100644 index 20bef1edd..000000000 --- a/src/components/Modal/__snapshots__/DeleteJobModal.test.js.snap +++ /dev/null @@ -1,44 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - - - Are you sure you want to delete this job? - - - - - - - - -`; diff --git a/src/components/Modal/__snapshots__/DiscardFormModal.test.js.snap b/src/components/Modal/__snapshots__/DiscardFormModal.test.js.snap deleted file mode 100644 index 33accc88d..000000000 --- a/src/components/Modal/__snapshots__/DiscardFormModal.test.js.snap +++ /dev/null @@ -1,44 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - - - Are you sure you want to discard this form? - - - - - - - - -`; diff --git a/src/components/Modal/__snapshots__/RunJobModal.test.js.snap b/src/components/Modal/__snapshots__/RunJobModal.test.js.snap deleted file mode 100644 index ff2af549c..000000000 --- a/src/components/Modal/__snapshots__/RunJobModal.test.js.snap +++ /dev/null @@ -1,44 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - - - Are you sure you want to run this job? - - - - - - - - -`; diff --git a/src/components/PageWrapper/PageWrapper.test.js b/src/components/PageWrapper/PageWrapper.test.js index eb20db362..8306924f7 100644 --- a/src/components/PageWrapper/PageWrapper.test.js +++ b/src/components/PageWrapper/PageWrapper.test.js @@ -3,9 +3,7 @@ import { shallow } from 'enzyme' import PageWrapper from './PageWrapper' describe('', () => { - it('renders correctly', () => { - const wrapper = shallow(Text) - - expect(wrapper).toMatchSnapshot() + it('renders without errors', () => { + shallow(Text) }) }) diff --git a/src/components/PageWrapper/__snapshots__/PageWrapper.test.js.snap b/src/components/PageWrapper/__snapshots__/PageWrapper.test.js.snap deleted file mode 100644 index dc76c1dec..000000000 --- a/src/components/PageWrapper/__snapshots__/PageWrapper.test.js.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` -
- Text -
-`; diff --git a/src/components/Routes/Routes.js b/src/components/Routes/Routes.js index f72989120..e56707b0b 100644 --- a/src/components/Routes/Routes.js +++ b/src/components/Routes/Routes.js @@ -3,7 +3,7 @@ import { Route } from 'react-router-dom' import { Router } from 'react-router' import { AuthWall } from '../AuthWall' import { JobListContainer } from '../../pages/JobList' -import { JobEdit } from '../../pages/JobEdit' +import { JobEditContainer } from '../../pages/JobEdit' import { JobAddContainer } from '../../pages/JobAdd' import { NotAuthorized } from '../../pages/NotAuthorized' import history from '../../services/history' @@ -12,7 +12,7 @@ const Routes = () => ( - + diff --git a/src/components/Routes/Routes.test.js b/src/components/Routes/Routes.test.js index b654c13a7..a3447d411 100644 --- a/src/components/Routes/Routes.test.js +++ b/src/components/Routes/Routes.test.js @@ -3,9 +3,7 @@ import { shallow } from 'enzyme' import Routes from './Routes' describe('', () => { - it('renders correctly', () => { - const wrapper = shallow() - - expect(wrapper).toMatchSnapshot() + it('renders without errors', () => { + shallow() }) }) diff --git a/src/components/Routes/__snapshots__/Routes.test.js.snap b/src/components/Routes/__snapshots__/Routes.test.js.snap deleted file mode 100644 index e1526ff61..000000000 --- a/src/components/Routes/__snapshots__/Routes.test.js.snap +++ /dev/null @@ -1,46 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - - - - - - - - -`; diff --git a/src/components/Switches/ToggleJobSwitch.js b/src/components/Switches/ToggleJobSwitch.js index 2ee308da7..e2df7888d 100644 --- a/src/components/Switches/ToggleJobSwitch.js +++ b/src/components/Switches/ToggleJobSwitch.js @@ -1,11 +1,20 @@ import React, { useContext } from 'react' import { PropTypes } from '@dhis2/prop-types' import { Switch } from '@dhis2/ui' -import { useToggleJob } from '../../hooks/jobs' +import { useDataMutation } from '@dhis2/app-runtime' import { RefetchJobsContext } from '../Context' +/* istanbul ignore next */ +const mutation = { + resource: 'jobConfigurations', + id: ({ id }) => id, + type: 'update', + partial: true, + data: ({ enabled }) => ({ enabled }), +} + const ToggleJobSwitch = ({ id, checked }) => { - const [toggleJob, { loading }] = useToggleJob() + const [toggleJob, { loading }] = useDataMutation(mutation) const refetch = useContext(RefetchJobsContext) const enabled = !checked diff --git a/src/components/Switches/ToggleJobSwitch.test.js b/src/components/Switches/ToggleJobSwitch.test.js index 7f4deff8c..10da74978 100644 --- a/src/components/Switches/ToggleJobSwitch.test.js +++ b/src/components/Switches/ToggleJobSwitch.test.js @@ -1,18 +1,22 @@ import React from 'react' import { shallow, mount } from 'enzyme' -import { useToggleJob } from '../../hooks/jobs' +import { useDataMutation } from '@dhis2/app-runtime' import { RefetchJobsContext } from '../Context' import ToggleJobSwitch from './ToggleJobSwitch' -jest.mock('../../hooks/jobs', () => ({ - useToggleJob: jest.fn(() => [() => {}, {}]), +jest.mock('@dhis2/app-runtime', () => ({ + useDataMutation: jest.fn(), })) +afterEach(() => { + jest.resetAllMocks() +}) + describe('', () => { - it('renders correctly', () => { - const wrapper = shallow() + it('renders without errors', () => { + useDataMutation.mockImplementation(() => [() => {}, {}]) - expect(wrapper).toMatchSnapshot() + shallow() }) it('calls toggleJob and refetches when toggle is clicked', async () => { @@ -25,7 +29,7 @@ describe('', () => { checked, } - useToggleJob.mockImplementationOnce(() => [toggleJobSpy, {}]) + useDataMutation.mockImplementation(() => [toggleJobSpy, {}]) const wrapper = mount( @@ -34,7 +38,8 @@ describe('', () => { ) wrapper - .find('input[name="toggle-job-id"]') + .find('input') + .find({ name: 'toggle-job-id' }) .simulate('change', { target: { checked: !checked } }) await toggle diff --git a/src/components/Switches/__snapshots__/ToggleJobSwitch.test.js.snap b/src/components/Switches/__snapshots__/ToggleJobSwitch.test.js.snap deleted file mode 100644 index 162eb0442..000000000 --- a/src/components/Switches/__snapshots__/ToggleJobSwitch.test.js.snap +++ /dev/null @@ -1,10 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly 1`] = ` - -`; diff --git a/src/hooks/human-readable-cron/selectors.js b/src/hooks/human-readable-cron/selectors.js new file mode 100644 index 000000000..c3ad5170f --- /dev/null +++ b/src/hooks/human-readable-cron/selectors.js @@ -0,0 +1,9 @@ +export const getLocale = data => { + const { keyUiLocale } = data + + if (!keyUiLocale) { + return '' + } + + return keyUiLocale +} diff --git a/src/hooks/user-settings/use-get-user-settings.test.js b/src/hooks/human-readable-cron/selectors.test.js similarity index 63% rename from src/hooks/user-settings/use-get-user-settings.test.js rename to src/hooks/human-readable-cron/selectors.test.js index 35475f951..457a8fcba 100644 --- a/src/hooks/user-settings/use-get-user-settings.test.js +++ b/src/hooks/human-readable-cron/selectors.test.js @@ -1,4 +1,4 @@ -import { getLocale } from './use-get-user-settings' +import { getLocale } from './selectors' describe('getLocale', () => { it('should return empty string if there is no keyUiLocale', () => { @@ -8,6 +8,8 @@ describe('getLocale', () => { it('should return the keyUiLocale if it is in the data', () => { const keyUiLocale = 'en' - expect(getLocale({ keyUiLocale })).toBe('en') + expect(getLocale({ keyUiLocale })).toEqual( + expect.stringMatching(keyUiLocale) + ) }) }) diff --git a/src/hooks/human-readable-cron/use-human-readable-cron.js b/src/hooks/human-readable-cron/use-human-readable-cron.js index e6a186f79..eaec4055d 100644 --- a/src/hooks/human-readable-cron/use-human-readable-cron.js +++ b/src/hooks/human-readable-cron/use-human-readable-cron.js @@ -1,9 +1,16 @@ import cronstrue from 'cronstrue/i18n' -import { useGetUserSettings, selectors } from '../user-settings' +import { useDataQuery } from '@dhis2/app-runtime' import { validateCron } from '../../services/validators' +import { getLocale } from './selectors' + +const query = { + userSettings: { + resource: 'userSettings', + }, +} const useHumanReadableCron = cron => { - const { loading, error, data } = useGetUserSettings() + const { loading, error, data } = useDataQuery(query) const isValid = cron && validateCron(cron) if (loading || !isValid) { @@ -15,7 +22,7 @@ const useHumanReadableCron = cron => { return cronstrue.toString(cron) } - const locale = selectors.getLocale(data) + const locale = getLocale(data.userSettings) return cronstrue.toString(cron, { locale }) } diff --git a/src/hooks/human-readable-cron/use-human-readable-cron.test.js b/src/hooks/human-readable-cron/use-human-readable-cron.test.js index fe33b2dd1..875b7604c 100644 --- a/src/hooks/human-readable-cron/use-human-readable-cron.test.js +++ b/src/hooks/human-readable-cron/use-human-readable-cron.test.js @@ -1,16 +1,22 @@ -import { useGetUserSettings, selectors } from '../user-settings' +import { useDataQuery } from '@dhis2/app-runtime' +import { getLocale } from './selectors' import useHumanReadableCron from './use-human-readable-cron' -jest.mock('../user-settings', () => ({ - useGetUserSettings: jest.fn(), - selectors: { - getLocale: jest.fn(), - }, +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), })) +jest.mock('./selectors', () => ({ + getLocale: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + describe('useHumanReadableCron', () => { it('should return an empty string when loading', () => { - useGetUserSettings.mockImplementationOnce(() => ({ + useDataQuery.mockImplementation(() => ({ loading: true, error: undefined, data: null, @@ -23,7 +29,7 @@ describe('useHumanReadableCron', () => { }) it('should return an empty string when invalid', () => { - useGetUserSettings.mockImplementationOnce(() => ({ + useDataQuery.mockImplementation(() => ({ loading: false, error: undefined, data: null, @@ -36,7 +42,7 @@ describe('useHumanReadableCron', () => { }) it('should return an english translation if there is an error', () => { - useGetUserSettings.mockImplementationOnce(() => ({ + useDataQuery.mockImplementation(() => ({ loading: false, error: new Error(''), data: null, @@ -45,20 +51,20 @@ describe('useHumanReadableCron', () => { const cron = '0 0 * ? * *' const actual = useHumanReadableCron(cron) - expect(actual).toBe('Every hour') + expect(actual).toEqual(expect.stringMatching('Every hour')) }) it('should return a translated cron if there is a locale', () => { - useGetUserSettings.mockImplementationOnce(() => ({ + useDataQuery.mockImplementation(() => ({ loading: false, error: undefined, data: {}, })) - selectors.getLocale.mockImplementationOnce(() => 'fr') + getLocale.mockImplementation(() => 'fr') const cron = '0 0 * ? * *' const actual = useHumanReadableCron(cron) - expect(actual).toBe('Toutes les heures') + expect(actual).toEqual(expect.stringMatching('Toutes les heures')) }) }) diff --git a/src/hooks/job-types/index.js b/src/hooks/job-types/index.js deleted file mode 100644 index 2219ecd2d..000000000 --- a/src/hooks/job-types/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import * as selectors from './use-get-job-types' -import useGetJobTypes from './use-get-job-types' - -export { selectors, useGetJobTypes } diff --git a/src/hooks/jobs/index.js b/src/hooks/jobs/index.js index d17fa2ab4..74dd5115e 100644 --- a/src/hooks/jobs/index.js +++ b/src/hooks/jobs/index.js @@ -1,7 +1,4 @@ -import * as selectors from './use-get-jobs' -import useGetJobs from './use-get-jobs' -import useToggleJob from './use-toggle-job' -import useDeleteJob from './use-delete-job' import useSubmitJob from './use-submit-job' +import useUpdateJob from './use-update-job' -export { selectors, useGetJobs, useToggleJob, useDeleteJob, useSubmitJob } +export { useSubmitJob, useUpdateJob } diff --git a/src/hooks/jobs/use-delete-job.js b/src/hooks/jobs/use-delete-job.js deleted file mode 100644 index 838ea73db..000000000 --- a/src/hooks/jobs/use-delete-job.js +++ /dev/null @@ -1,13 +0,0 @@ -import { useDataMutation } from '@dhis2/app-runtime' - -const mutation = { - resource: 'jobConfigurations', - id: ({ id }) => id, - type: 'delete', -} - -const useDeleteJob = () => { - return useDataMutation(mutation) -} - -export default useDeleteJob diff --git a/src/hooks/jobs/use-get-jobs.js b/src/hooks/jobs/use-get-jobs.js deleted file mode 100644 index 66d06a2c1..000000000 --- a/src/hooks/jobs/use-get-jobs.js +++ /dev/null @@ -1,44 +0,0 @@ -import { useDataQuery } from '@dhis2/app-runtime' - -const query = { - jobs: { - resource: 'jobConfigurations', - params: { - fields: '*', - paging: false, - }, - }, -} - -const useGetJobs = () => { - const { loading, error, data, refetch } = useDataQuery(query) - - if (data && data.jobs && data.jobs.jobConfigurations) { - return { loading, error, refetch, data: data.jobs.jobConfigurations } - } - - return { loading, error, refetch, data } -} - -export default useGetJobs - -/** - * Selectors - */ - -export const getEntities = jobs => { - return jobs.reduce((entities, job) => { - const id = job.id - entities[id] = job - - return entities - }, {}) -} - -export const getIds = jobs => { - return jobs.map(job => job.id) -} - -export const getUserJobIds = jobs => { - return jobs.filter(job => job.configurable).map(job => job.id) -} diff --git a/src/hooks/jobs/use-submit-job.js b/src/hooks/jobs/use-submit-job.js index 918952950..1bdee05a2 100644 --- a/src/hooks/jobs/use-submit-job.js +++ b/src/hooks/jobs/use-submit-job.js @@ -5,7 +5,7 @@ import formatError from '../../services/format-error' const mutation = { resource: 'jobConfigurations', type: 'create', - data: ({ job }) => job, + data: /* istanbul ignore next */ ({ job }) => job, } const useSubmitJob = () => { diff --git a/src/hooks/jobs/use-submit-job.test.js b/src/hooks/jobs/use-submit-job.test.js index 4292d160c..e99a34fbf 100644 --- a/src/hooks/jobs/use-submit-job.test.js +++ b/src/hooks/jobs/use-submit-job.test.js @@ -18,7 +18,7 @@ describe('useSubmitJob', () => { const engine = { mutate: () => Promise.resolve(), } - useDataEngine.mockImplementationOnce(() => engine) + useDataEngine.mockImplementation(() => engine) const [submitJob] = useSubmitJob() expect.assertions(1) @@ -36,16 +36,14 @@ describe('useSubmitJob', () => { const engine = { mutate: () => Promise.reject(error), } - useDataEngine.mockImplementationOnce(() => engine) - formatError.mockImplementationOnce(error => error) + useDataEngine.mockImplementation(() => engine) + formatError.mockImplementation(error => error) const [submitJob] = useSubmitJob() expect.assertions(1) - submitJob().then(error => { - expect(error).toBe(error) - }) + return expect(submitJob()).resolves.toBe(error) }) it('should reject with an error on any other errors', () => { @@ -54,14 +52,12 @@ describe('useSubmitJob', () => { const engine = { mutate: () => Promise.reject(error), } - useDataEngine.mockImplementationOnce(() => engine) + useDataEngine.mockImplementation(() => engine) const [submitJob] = useSubmitJob() expect.assertions(1) - submitJob().catch(error => { - expect(error).toBe(error) - }) + return expect(submitJob()).rejects.toBe(error) }) }) diff --git a/src/hooks/jobs/use-toggle-job.js b/src/hooks/jobs/use-toggle-job.js deleted file mode 100644 index 4231e2145..000000000 --- a/src/hooks/jobs/use-toggle-job.js +++ /dev/null @@ -1,15 +0,0 @@ -import { useDataMutation } from '@dhis2/app-runtime' - -const mutation = { - resource: 'jobConfigurations', - id: ({ id }) => id, - type: 'update', - partial: true, - data: ({ enabled }) => ({ enabled }), -} - -const useToggleJob = () => { - return useDataMutation(mutation) -} - -export default useToggleJob diff --git a/src/hooks/jobs/use-update-job.js b/src/hooks/jobs/use-update-job.js new file mode 100644 index 000000000..80a48c6e0 --- /dev/null +++ b/src/hooks/jobs/use-update-job.js @@ -0,0 +1,40 @@ +import { useDataEngine } from '@dhis2/app-runtime' +import history from '../../services/history' +import formatError from '../../services/format-error' + +/** + * A partial mutation, or PATCH, because PUT isn't allowed for this endpoint + */ + +const mutation = { + resource: 'jobConfigurations', + type: 'update', + partial: true, + id: /* istanbul ignore next */ ({ id }) => id, + data: /* istanbul ignore next */ ({ job }) => job, +} + +const useUpdateJob = ({ id }) => { + const engine = useDataEngine() + const updateJob = job => + engine + .mutate(mutation, { variables: { job, id } }) + .then(() => { + history.push('/') + }) + .catch(error => { + const isValidationError = error.type === 'access' + + // Potential validation error, return it in a format final-form can handle + if (isValidationError) { + return formatError(error) + } + + // Throw any unexpected errors + throw error + }) + + return [updateJob] +} + +export default useUpdateJob diff --git a/src/hooks/jobs/use-update-job.test.js b/src/hooks/jobs/use-update-job.test.js new file mode 100644 index 000000000..671ffc280 --- /dev/null +++ b/src/hooks/jobs/use-update-job.test.js @@ -0,0 +1,67 @@ +import { useDataEngine } from '@dhis2/app-runtime' +import history from '../../services/history' +import formatError from '../../services/format-error' +import useUpdateJob from './use-update-job' + +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: jest.fn(), +})) + +jest.mock('../../services/history', () => ({ + push: jest.fn(), +})) + +jest.mock('../../services/format-error', () => jest.fn()) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('useUpdateJob', () => { + it('should redirect to root after a successful update', () => { + const engine = { + mutate: () => Promise.resolve(), + } + useDataEngine.mockImplementation(() => engine) + const [updateJob] = useUpdateJob({ id: 'id' }) + + expect.assertions(1) + + return updateJob().then(() => { + expect(history.push).toHaveBeenCalledWith('/') + }) + }) + + it('should resolve with errors on validation errors', () => { + // Validation errors are detected by type + const error = new Error('Validation error') + error.type = 'access' + + const engine = { + mutate: () => Promise.reject(error), + } + useDataEngine.mockImplementation(() => engine) + formatError.mockImplementation(error => error) + + const [updateJob] = useUpdateJob({ id: 'id' }) + + expect.assertions(1) + + return expect(updateJob()).resolves.toBe(error) + }) + + it('should reject with an error on any other errors', () => { + const error = new Error('Network error') + + const engine = { + mutate: () => Promise.reject(error), + } + useDataEngine.mockImplementation(() => engine) + + const [updateJob] = useUpdateJob({ id: 'id' }) + + expect.assertions(1) + + return expect(updateJob()).rejects.toBe(error) + }) +}) diff --git a/src/hooks/me/index.js b/src/hooks/me/index.js deleted file mode 100644 index 46d8ac9e2..000000000 --- a/src/hooks/me/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import * as selectors from './use-get-me' -import useGetMe from './use-get-me' - -export { selectors, useGetMe } diff --git a/src/hooks/me/use-get-me.js b/src/hooks/me/use-get-me.js deleted file mode 100644 index e680b2f57..000000000 --- a/src/hooks/me/use-get-me.js +++ /dev/null @@ -1,37 +0,0 @@ -import { useDataQuery } from '@dhis2/app-runtime' - -const query = { - me: { - resource: 'me', - }, -} - -const useGetMe = () => { - const { loading, error, data, refetch } = useDataQuery(query) - - if (data && data.me) { - return { loading, error, refetch, data: data.me } - } - - return { loading, error, refetch, data } -} - -export default useGetMe - -/** - * Selectors - */ - -export const getAuthorized = me => { - const { authorities } = me - - if (!authorities) { - return false - } - - const isAuthorized = - authorities.includes('ALL') || - authorities.includes('F_SCHEDULING_ADMIN') - - return isAuthorized -} diff --git a/src/hooks/me/use-get-me.test.js b/src/hooks/me/use-get-me.test.js deleted file mode 100644 index c87eb7b6f..000000000 --- a/src/hooks/me/use-get-me.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import { useDataQuery } from '@dhis2/app-runtime' -import useGetMe, { getAuthorized } from './use-get-me' - -jest.mock('@dhis2/app-runtime', () => ({ - useDataQuery: jest.fn(), -})) - -describe('useGetMe', () => { - it('should return the expected data', () => { - const response = { - loading: false, - error: undefined, - data: undefined, - refetch: () => {}, - } - useDataQuery.mockImplementationOnce(() => response) - const expected = { - loading: response.loading, - error: response.error, - data: undefined, - refetch: response.refetch, - } - - expect(useGetMe()).toEqual(expected) - }) - - it('should return me if present in the response data', () => { - const response = { - loading: false, - error: undefined, - data: { me: {} }, - refetch: () => {}, - } - useDataQuery.mockImplementationOnce(() => response) - const expected = { - loading: response.loading, - error: response.error, - data: response.data.me, - refetch: response.refetch, - } - - expect(useGetMe()).toEqual(expected) - }) -}) - -describe('getAuthorized', () => { - it('should return false if there are no authorities', () => { - expect(getAuthorized({})).toBe(false) - }) - - it('should return true if the authorities include ALL', () => { - const me = { - authorities: ['ALL'], - } - - expect(getAuthorized(me)).toBe(true) - }) - - it('should return true if the authorities include F_SCHEDULING_ADMIN', () => { - const me = { - authorities: ['F_SCHEDULING_ADMIN'], - } - - expect(getAuthorized(me)).toBe(true) - }) -}) diff --git a/src/hooks/parameter-options/index.js b/src/hooks/parameter-options/index.js deleted file mode 100644 index 907cfd054..000000000 --- a/src/hooks/parameter-options/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import useGetLabeledOptions from './use-get-labeled-options' -import useGetUnlabeledOptions from './use-get-unlabeled-options' - -export { useGetLabeledOptions, useGetUnlabeledOptions } diff --git a/src/hooks/parameter-options/use-get-labeled-options.js b/src/hooks/parameter-options/use-get-labeled-options.js deleted file mode 100644 index 66801caa3..000000000 --- a/src/hooks/parameter-options/use-get-labeled-options.js +++ /dev/null @@ -1,59 +0,0 @@ -import { useDataEngine } from '@dhis2/app-runtime' -import { useState, useEffect } from 'react' - -const useGetLabeledOptions = ({ endpoint, parameterName }) => { - const engine = useDataEngine() - const [response, setResponse] = useState({ - loading: true, - error: undefined, - data: undefined, - }) - - useEffect(() => { - const query = { - options: { - resource: endpoint, - params: { - paging: false, - }, - }, - } - const abortController = new AbortController() - const signal = abortController.signal - - engine - .query(query, { signal }) - .then(({ options }) => { - if (!(parameterName in options)) { - throw new Error('Invalid response') - } - - setResponse({ - loading: false, - error: undefined, - data: options[parameterName], - }) - }) - .catch(error => { - const isAbortError = error.name === 'AbortError' - - // Only set the error if it's not an abort error - if (!isAbortError) { - setResponse({ - loading: false, - error, - data: undefined, - }) - } - }) - - // Return a cleanup function - return () => { - abortController.abort() - } - }, [engine, endpoint, parameterName]) - - return response -} - -export default useGetLabeledOptions diff --git a/src/hooks/parameter-options/use-get-unlabeled-options.js b/src/hooks/parameter-options/use-get-unlabeled-options.js deleted file mode 100644 index c45f08907..000000000 --- a/src/hooks/parameter-options/use-get-unlabeled-options.js +++ /dev/null @@ -1,50 +0,0 @@ -import { useDataEngine } from '@dhis2/app-runtime' -import { useState, useEffect } from 'react' - -const useGetUnlabeledOptions = ({ endpoint }) => { - const engine = useDataEngine() - const [response, setResponse] = useState({ - loading: true, - error: undefined, - data: undefined, - }) - - useEffect(() => { - const query = { - options: { - resource: endpoint, - params: { - paging: false, - }, - }, - } - const abortController = new AbortController() - const signal = abortController.signal - - engine - .query(query, { signal }) - .then(({ options }) => { - setResponse({ - loading: false, - error: undefined, - data: options, - }) - }) - .catch(error => { - const isAbortError = error.name === 'AbortError' - - // Only set the error if it's not an abort error - if (!isAbortError) { - setResponse({ - loading: false, - error, - data: undefined, - }) - } - }) - }, [engine, endpoint]) - - return response -} - -export default useGetUnlabeledOptions diff --git a/src/hooks/user-settings/index.js b/src/hooks/user-settings/index.js deleted file mode 100644 index 0955b5162..000000000 --- a/src/hooks/user-settings/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import * as selectors from './use-get-user-settings' -import useGetUserSettings from './use-get-user-settings' - -export { selectors, useGetUserSettings } diff --git a/src/hooks/user-settings/use-get-user-settings.js b/src/hooks/user-settings/use-get-user-settings.js deleted file mode 100644 index 11b6807ec..000000000 --- a/src/hooks/user-settings/use-get-user-settings.js +++ /dev/null @@ -1,33 +0,0 @@ -import { useDataQuery } from '@dhis2/app-runtime' - -const query = { - userSettings: { - resource: 'userSettings', - }, -} - -const useGetUserSettings = () => { - const { loading, error, data } = useDataQuery(query) - - if (data && data.userSettings) { - return { loading, error, data: data.userSettings } - } - - return { loading, error, data } -} - -export default useGetUserSettings - -/** - * Selectors - */ - -export const getLocale = data => { - const { keyUiLocale } = data - - if (!keyUiLocale) { - return '' - } - - return keyUiLocale -} diff --git a/src/pages/JobAdd/JobAdd.js b/src/pages/JobAdd/JobAdd.js index 50c9b93b6..e5c583d4e 100644 --- a/src/pages/JobAdd/JobAdd.js +++ b/src/pages/JobAdd/JobAdd.js @@ -4,7 +4,7 @@ import { Card } from '@dhis2/ui' import i18n from '@dhis2/d2-i18n' import { DiscardFormButton } from '../../components/Buttons' import { InfoIcon } from '../../components/Icons' -import { JobFormContainer } from '../../components/Forms' +import { JobAddFormContainer } from '../../components/Forms' import styles from './JobAdd.module.css' const infoLink = @@ -37,7 +37,7 @@ const JobAdd = ({ isPristine, setIsPristine }) => ( {i18n.t('About job configuration')} - + ) diff --git a/src/pages/JobAdd/JobAdd.test.js b/src/pages/JobAdd/JobAdd.test.js new file mode 100644 index 000000000..2863b9ff3 --- /dev/null +++ b/src/pages/JobAdd/JobAdd.test.js @@ -0,0 +1,13 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobAdd from './JobAdd' + +describe('', () => { + it('renders without errors', () => { + const props = { + isPristine: false, + setIsPristine: () => {}, + } + shallow() + }) +}) diff --git a/src/pages/JobAdd/JobAddContainer.test.js b/src/pages/JobAdd/JobAddContainer.test.js new file mode 100644 index 000000000..a1d732560 --- /dev/null +++ b/src/pages/JobAdd/JobAddContainer.test.js @@ -0,0 +1,9 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobAddContainer from './JobAddContainer' + +describe('', () => { + it('renders without errors', () => { + shallow() + }) +}) diff --git a/src/pages/JobEdit/JobEdit.js b/src/pages/JobEdit/JobEdit.js index 4c2db637d..308eedbef 100644 --- a/src/pages/JobEdit/JobEdit.js +++ b/src/pages/JobEdit/JobEdit.js @@ -1,5 +1,53 @@ import React from 'react' +import { PropTypes } from '@dhis2/prop-types' +import { Card } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { DiscardFormButton } from '../../components/Buttons' +import { InfoIcon } from '../../components/Icons' +import { JobEditFormContainer } from '../../components/Forms' +import styles from './JobEdit.module.css' -const JobEdit = () =>
Edit job
+const infoLink = + 'https://docs.dhis2.org/master/en/user/html/dataAdmin_scheduling.html#dataAdmin_scheduling_config' + +const JobEdit = ({ isPristine, setIsPristine, name: JOBNAME }) => ( + +
+ + {i18n.t('Back to all jobs')} + +

+ {i18n.t('Job: {{ JOBNAME }}', { + JOBNAME, + nsSeparator: '>', + })} +

+
+ +
+

+ {i18n.t('Configuration')} +

+ + + {i18n.t('About job configuration')} + +
+ +
+
+) + +const { bool, func, string } = PropTypes + +JobEdit.propTypes = { + isPristine: bool.isRequired, + name: string.isRequired, + setIsPristine: func.isRequired, +} export default JobEdit diff --git a/src/pages/JobEdit/JobEdit.module.css b/src/pages/JobEdit/JobEdit.module.css new file mode 100644 index 000000000..8512f2c06 --- /dev/null +++ b/src/pages/JobEdit/JobEdit.module.css @@ -0,0 +1,44 @@ +.pageHeader { + margin-bottom: var(--spacers-dp32); +} + +.pageHeaderButton { + margin-bottom: var(--spacers-dp16); +} + +.pageHeaderTitle { + font-size: 18px; + font-weight: 400; + line-height: 1; + margin: 0; +} + +.card { + padding: var(--spacers-dp16); +} + +.cardHeader { + align-items: center; + display: flex; + margin-bottom: var(--spacers-dp16); +} + +.cardHeaderTitle { + font-size: 16px; + font-weight: 500; + margin: 0 var(--spacers-dp8) 0 0; +} + +.cardHeaderInfo { + width: 16px; + height: 16px; + margin-right: var(--spacers-dp4); +} + +.cardHeaderLink { + align-items: center; + display: flex; + font-size: 12px; + color: var(--colors-grey600); + fill: var(--colors-grey600); +} diff --git a/src/pages/JobEdit/JobEdit.test.js b/src/pages/JobEdit/JobEdit.test.js new file mode 100644 index 000000000..22d26e13a --- /dev/null +++ b/src/pages/JobEdit/JobEdit.test.js @@ -0,0 +1,14 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobEdit from './JobEdit' + +describe('', () => { + it('renders without errors', () => { + const props = { + isPristine: false, + setIsPristine: () => {}, + name: 'name', + } + shallow() + }) +}) diff --git a/src/pages/JobEdit/JobEditContainer.js b/src/pages/JobEdit/JobEditContainer.js new file mode 100644 index 000000000..9e50cdbb3 --- /dev/null +++ b/src/pages/JobEdit/JobEditContainer.js @@ -0,0 +1,49 @@ +import React, { useState } from 'react' +import { CircularLoader, Layer, CenteredContent } from '@dhis2/ui' +import { useParams } from 'react-router-dom' +import { useDataQuery } from '@dhis2/app-runtime' +import JobEdit from './JobEdit' + +const query = { + job: { + resource: 'jobConfigurations', + id: /* istanbul ignore next */ ({ id }) => id, + params: { + paging: false, + }, + }, +} + +const JobEditContainer = () => { + const [isPristine, setIsPristine] = useState(true) + const { id } = useParams() + const { loading, error, data } = useDataQuery(query, { variables: { id } }) + + if (loading) { + return ( + + + + + + ) + } + + if (error) { + /** + * The app can't continue if this fails, because it doesn't + * have the job data, so throw the error. + */ + throw error + } + + return ( + + ) +} + +export default JobEditContainer diff --git a/src/pages/JobEdit/JobEditContainer.test.js b/src/pages/JobEdit/JobEditContainer.test.js new file mode 100644 index 000000000..f0338f503 --- /dev/null +++ b/src/pages/JobEdit/JobEditContainer.test.js @@ -0,0 +1,58 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import { useDataQuery } from '@dhis2/app-runtime' +import expectRenderError from '../../../test/expect-render-error' +import JobEditContainer from './JobEditContainer' + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), +})) + +jest.mock('react-router-dom', () => ({ + useParams: () => ({ id: 'id' }), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('renders a spinner when loading', () => { + useDataQuery.mockImplementation(() => ({ + loading: true, + error: undefined, + data: null, + })) + + const wrapper = mount() + const loader = wrapper.find('CircularLoader') + + expect(loader).toHaveLength(1) + }) + + it('throws errors it encounters during fetching', () => { + const message = 'Something went wrong' + + useDataQuery.mockImplementation(() => ({ + loading: false, + error: new Error(message), + data: null, + })) + + expectRenderError(, message) + }) + + it('renders without errors when there is data', () => { + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { + job: { + name: 'name', + }, + }, + })) + + shallow() + }) +}) diff --git a/src/pages/JobEdit/index.js b/src/pages/JobEdit/index.js index bced269ae..172022b32 100644 --- a/src/pages/JobEdit/index.js +++ b/src/pages/JobEdit/index.js @@ -1,3 +1,3 @@ -import JobEdit from './JobEdit' +import JobEditContainer from './JobEditContainer' -export { JobEdit } +export { JobEditContainer } diff --git a/src/pages/JobList/DeleteJobMenuItem.js b/src/pages/JobList/DeleteJobMenuItem.js index 950a6150b..317f49c3d 100644 --- a/src/pages/JobList/DeleteJobMenuItem.js +++ b/src/pages/JobList/DeleteJobMenuItem.js @@ -18,7 +18,13 @@ const DeleteJobMenuItem = ({ id }) => { label={i18n.t('Delete')} /> {showModal && ( - setShowModal(false)} /> + setShowModal(false) + } + /> )} ) diff --git a/src/pages/JobList/DeleteJobMenuItem.test.js b/src/pages/JobList/DeleteJobMenuItem.test.js index 469982a39..2eaa74ab1 100644 --- a/src/pages/JobList/DeleteJobMenuItem.test.js +++ b/src/pages/JobList/DeleteJobMenuItem.test.js @@ -3,10 +3,8 @@ import { shallow, mount } from 'enzyme' import DeleteJobMenuItem from './DeleteJobMenuItem' describe('', () => { - it('renders correctly', () => { - const wrapper = shallow() - - expect(wrapper).toMatchSnapshot() + it('renders without errors', () => { + shallow() }) it('shows the modal when MenuItem is clicked', () => { diff --git a/src/pages/JobList/EditJobMenuItem.test.js b/src/pages/JobList/EditJobMenuItem.test.js index be56ebe0a..04f36c0b6 100644 --- a/src/pages/JobList/EditJobMenuItem.test.js +++ b/src/pages/JobList/EditJobMenuItem.test.js @@ -8,10 +8,8 @@ jest.mock('../../services/history', () => ({ })) describe('', () => { - it('renders correctly', () => { - const wrapper = shallow() - - expect(wrapper).toMatchSnapshot() + it('renders without errors', () => { + shallow() }) it('calls history.push correctly when MenuItem is clicked', () => { diff --git a/src/pages/JobList/JobList.js b/src/pages/JobList/JobList.js index 8ed413998..6a4ce9093 100644 --- a/src/pages/JobList/JobList.js +++ b/src/pages/JobList/JobList.js @@ -27,6 +27,7 @@ const JobList = ({
{ setJobFilter(value) @@ -35,6 +36,7 @@ const JobList = ({ />