diff --git a/README.md b/README.md index 406bc309f..897c1fb50 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +![React 18](https://img.shields.io/badge/react-18-blue) # Scheduler ## Cypress env settings diff --git a/package.json b/package.json index ce7792e4a..5497fadf0 100644 --- a/package.json +++ b/package.json @@ -1,63 +1,63 @@ { - "name": "scheduler-app", - "private": true, - "scripts": { - "build": "d2-app-scripts build", - "start": "d2-app-scripts start", - "start:nobrowser": "BROWSER=none d2-app-scripts start", - "test": "d2-app-scripts test --coverage", - "test:watch": "d2-app-scripts test --watch", - "lint": "d2-style check", - "format": "d2-style apply", - "cypress": "start-server-and-test 'yarn start:nobrowser' 3000 'yarn exec cypress open'" - }, - "dependencies": { - "@dhis2/app-runtime": "^3.8.0", - "@dhis2/d2-i18n": "^1.1.0", - "@dhis2/prop-types": "2.0.3", - "@dhis2/ui": "^9.11.8", - "@testing-library/react": "^16.0.1", - "classnames": "^2.3.1", - "cronstrue": "^1.114.0", - "history": "^4.9.0", - "moment": "^2.29.1", - "package.json": "^2.0.1", - "prop-types": "^15.8.1", - "react-router": "^5.0.1", - "react-router-dom": "^5.2.0", - "styled-jsx": "^4.0.1" - }, - "devDependencies": { - "@badeball/cypress-cucumber-preprocessor": "^20.0.3", - "@cfaester/enzyme-adapter-react-18": "^0.8.0", - "@cypress/webpack-preprocessor": "^6.0.1", - "@dhis2/cli-app-scripts": "^12.0.0-alpha.19", - "@dhis2/cli-style": "^10.7.4", - "@testing-library/cypress": "^10.0.1", - "cypress": "^13.7.2", - "enzyme": "^3.10.0", - "eslint-plugin-compat": "^3.9.0", - "eslint-plugin-i18next": "^5.1.1", - "eslint-plugin-import": "^2.23.4", - "eslint-plugin-jsx-a11y": "^6.4.1", - "identity-obj-proxy": "^3.0.0", - "start-server-and-test": "^2.0.3" - }, - "jest": { - "setupFilesAfterEnv": [ - "/src/setupTests.js" - ], - "collectCoverageFrom": [ - "src/**/*.{js,jsx}", - "!src/{index.js,serviceWorker.js,setupTests.js}" - ], - "coveragePathIgnorePatterns": [ - "/node_modules/", - "/src/locales/" - ], - "moduleNameMapper": { - "\\.css$": "identity-obj-proxy" - } - }, - "version": "101.6.12" + "name": "scheduler-app", + "private": true, + "scripts": { + "build": "d2-app-scripts build", + "start": "d2-app-scripts start", + "start:nobrowser": "BROWSER=none d2-app-scripts start", + "test": "d2-app-scripts test --coverage", + "test:watch": "d2-app-scripts test --watch", + "lint": "d2-style check", + "format": "d2-style apply", + "cypress": "start-server-and-test 'yarn start:nobrowser' 3000 'yarn exec cypress open'" + }, + "dependencies": { + "@dhis2/app-runtime": "^3.8.0", + "@dhis2/d2-i18n": "^1.1.0", + "@dhis2/prop-types": "2.0.3", + "@dhis2/ui": "^9.11.8", + "@testing-library/react": "^16.0.1", + "classnames": "^2.3.1", + "cronstrue": "^1.114.0", + "history": "^4.9.0", + "moment": "^2.29.1", + "package.json": "^2.0.1", + "prop-types": "^15.8.1", + "react-router": "^5.0.1", + "react-router-dom": "^5.2.0", + "styled-jsx": "^4.0.1" + }, + "devDependencies": { + "@badeball/cypress-cucumber-preprocessor": "^20.0.3", + "@cfaester/enzyme-adapter-react-18": "^0.8.0", + "@cypress/webpack-preprocessor": "^6.0.1", + "@dhis2/cli-app-scripts": "^12.0.0-alpha.19", + "@dhis2/cli-style": "^10.7.4", + "@testing-library/cypress": "^10.0.1", + "cypress": "^13.7.2", + "enzyme": "^3.10.0", + "eslint-plugin-compat": "^3.9.0", + "eslint-plugin-i18next": "^5.1.1", + "eslint-plugin-import": "^2.23.4", + "eslint-plugin-jsx-a11y": "^6.4.1", + "identity-obj-proxy": "^3.0.0", + "start-server-and-test": "^2.0.3" + }, + "jest": { + "setupFilesAfterEnv": [ + "/src/setupTests.js" + ], + "collectCoverageFrom": [ + "src/**/*.{js,jsx}", + "!src/{index.js,serviceWorker.js,setupTests.js}" + ], + "coveragePathIgnorePatterns": [ + "/node_modules/", + "/src/locales/" + ], + "moduleNameMapper": { + "\\.css$": "identity-obj-proxy" + } + }, + "version": "101.6.12" } diff --git a/src/components/App/App.jsx b/src/components/App/App.jsx new file mode 100644 index 000000000..3fe50ca97 --- /dev/null +++ b/src/components/App/App.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import { CssVariables } from '@dhis2/ui' +import { Routes } from '../Routes' +import { AuthWall } from '../AuthWall' +import { Store } from '../Store' +import { PageWrapper } from '../PageWrapper' +import './App.css' + +/* eslint-disable-next-line import/no-unassigned-import -- Necessary for translations to work */ +import '../../locales' + +const App = () => ( + + + + + + + + + + +) + +export default App diff --git a/src/components/App/App.test.jsx b/src/components/App/App.test.jsx new file mode 100644 index 000000000..f5e234ad2 --- /dev/null +++ b/src/components/App/App.test.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import { shallow } from 'enzyme' +import App from './App.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow() + }) +}) diff --git a/src/components/AuthWall/AuthWall.jsx b/src/components/AuthWall/AuthWall.jsx new file mode 100644 index 000000000..27ddbc17e --- /dev/null +++ b/src/components/AuthWall/AuthWall.jsx @@ -0,0 +1,58 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { NoticeBox } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { useDataQuery } from '@dhis2/app-runtime' +import { Spinner } from '../Spinner' +import { getAuthorized } from './selectors' +import styles from './AuthWall.module.css' + +const query = { + me: { + resource: 'me', + }, +} + +const AuthWall = ({ children }) => { + const { loading, error, data } = useDataQuery(query) + + if (loading) { + return + } + + if (error) { + return ( +
+ + {i18n.t( + 'Something went wrong whilst retrieving user permissions.' + )} + +
+ ) + } + + const isAuthorized = getAuthorized(data.me) + + if (!isAuthorized) { + return ( +
+ + {i18n.t( + "You don't have access to the Job Scheduler. Contact a system administrator to request access." + )} + +
+ ) + } + + return {children} +} + +const { node } = PropTypes + +AuthWall.propTypes = { + children: node.isRequired, +} + +export default AuthWall diff --git a/src/components/AuthWall/AuthWall.test.jsx b/src/components/AuthWall/AuthWall.test.jsx new file mode 100644 index 000000000..316b47063 --- /dev/null +++ b/src/components/AuthWall/AuthWall.test.jsx @@ -0,0 +1,72 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import { useDataQuery } from '@dhis2/app-runtime' +import { getAuthorized } from './selectors' +import AuthWall from './AuthWall.jsx' + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), +})) + +jest.mock('./selectors', () => ({ + getAuthorized: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows a spinner when loading', () => { + useDataQuery.mockImplementation(() => ({ loading: true })) + + const wrapper = mount(Child) + const loadingIndicator = wrapper.find({ + 'data-test': 'dhis2-uicore-circularloader', + }) + + expect(loadingIndicator).toHaveLength(1) + }) + + it('shows a noticebox for fetching errors', () => { + const message = 'Something went wrong' + const error = new Error(message) + + useDataQuery.mockImplementation(() => ({ + loading: false, + error, + })) + + const wrapper = shallow(Child) + const noticebox = wrapper.find('NoticeBox') + + expect(noticebox).toHaveLength(1) + }) + + it('shows a noticebox for unauthorized users', () => { + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: {}, + })) + getAuthorized.mockImplementation(() => false) + + const wrapper = shallow(Child) + const noticebox = wrapper.find('NoticeBox') + + expect(noticebox).toHaveLength(1) + }) + + it('renders the children for users that are authorized', () => { + useDataQuery.mockImplementation(() => ({ + loading: false, + error: undefined, + data: {}, + })) + getAuthorized.mockImplementation(() => true) + + const wrapper = shallow(Child) + + expect(wrapper.text()).toEqual(expect.stringContaining('Child')) + }) +}) diff --git a/src/components/Buttons/CronPresetButton.jsx b/src/components/Buttons/CronPresetButton.jsx new file mode 100644 index 000000000..6d789af74 --- /dev/null +++ b/src/components/Buttons/CronPresetButton.jsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { Button } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { CronPresetModal } from '../Modal' + +const CronPresetButton = ({ setCron, small }) => { + const [showModal, setShowModal] = useState(false) + + return ( + + + {showModal && ( + setShowModal(false) + } + setCron={setCron} + /> + )} + + ) +} + +CronPresetButton.defaultProps = { + small: false, +} + +const { func, bool } = PropTypes + +CronPresetButton.propTypes = { + setCron: func.isRequired, + small: bool, +} + +export default CronPresetButton diff --git a/src/components/Buttons/CronPresetButton.test.jsx b/src/components/Buttons/CronPresetButton.test.jsx new file mode 100644 index 000000000..41c531744 --- /dev/null +++ b/src/components/Buttons/CronPresetButton.test.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import CronPresetButton from './CronPresetButton.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow( {}} />) + }) + + it('renders without errors when small', () => { + shallow( {}} small />) + }) + + it('shows the modal when button is clicked', () => { + const wrapper = mount( {}} />) + + expect(wrapper.find('CronPresetModal')).toHaveLength(0) + + wrapper.find('button').simulate('click') + + expect(wrapper.find('CronPresetModal')).toHaveLength(1) + }) +}) diff --git a/src/components/Buttons/DeleteJobButton.jsx b/src/components/Buttons/DeleteJobButton.jsx new file mode 100644 index 000000000..5fc966b62 --- /dev/null +++ b/src/components/Buttons/DeleteJobButton.jsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { Button } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { DeleteJobModal } from '../Modal' + +const DeleteJobButton = ({ id, onSuccess }) => { + const [showModal, setShowModal] = useState(false) + + return ( + + + {showModal && ( + setShowModal(false) + } + onSuccess={onSuccess} + /> + )} + + ) +} + +const { string, func } = PropTypes + +DeleteJobButton.propTypes = { + id: string.isRequired, + onSuccess: func.isRequired, +} + +export default DeleteJobButton diff --git a/src/components/Buttons/DeleteJobButton.test.jsx b/src/components/Buttons/DeleteJobButton.test.jsx new file mode 100644 index 000000000..b209b200c --- /dev/null +++ b/src/components/Buttons/DeleteJobButton.test.jsx @@ -0,0 +1,19 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import DeleteJobButton from './DeleteJobButton.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow( {}} />) + }) + + it('shows the modal when button is clicked', () => { + const wrapper = mount( {}} />) + + expect(wrapper.find('DeleteJobModal')).toHaveLength(0) + + wrapper.find('button').simulate('click') + + expect(wrapper.find('DeleteJobModal')).toHaveLength(1) + }) +}) diff --git a/src/components/Buttons/DeleteQueueButton.jsx b/src/components/Buttons/DeleteQueueButton.jsx new file mode 100644 index 000000000..4d75af8d5 --- /dev/null +++ b/src/components/Buttons/DeleteQueueButton.jsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { Button } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { DeleteQueueModal } from '../Modal' + +const DeleteQueueButton = ({ name, onSuccess }) => { + const [showModal, setShowModal] = useState(false) + + return ( + + + {showModal && ( + setShowModal(false)} + onSuccess={onSuccess} + /> + )} + + ) +} + +const { string, func } = PropTypes + +DeleteQueueButton.propTypes = { + name: string.isRequired, + onSuccess: func.isRequired, +} + +export default DeleteQueueButton diff --git a/src/components/Buttons/DeleteQueueButton.test.jsx b/src/components/Buttons/DeleteQueueButton.test.jsx new file mode 100644 index 000000000..71346a7cb --- /dev/null +++ b/src/components/Buttons/DeleteQueueButton.test.jsx @@ -0,0 +1,21 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import DeleteQueueButton from './DeleteQueueButton.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow( {}} />) + }) + + it('shows the modal when button is clicked', () => { + const wrapper = mount( + {}} /> + ) + + expect(wrapper.find('DeleteQueueModal')).toHaveLength(0) + + wrapper.find('button').simulate('click') + + expect(wrapper.find('DeleteQueueModal')).toHaveLength(1) + }) +}) diff --git a/src/components/Buttons/DiscardFormButton.jsx b/src/components/Buttons/DiscardFormButton.jsx new file mode 100644 index 000000000..a0c428ea7 --- /dev/null +++ b/src/components/Buttons/DiscardFormButton.jsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { Button } from '@dhis2/ui' +import history from '../../services/history' +import { DiscardFormModal } from '../Modal' + +const DiscardFormButton = ({ shouldConfirm, children, small, className }) => { + const [showModal, setShowModal] = useState(false) + const onClick = shouldConfirm + ? () => setShowModal(true) + : () => history.push('/') + + return ( + + + {showModal && ( + setShowModal(false) + } + /> + )} + + ) +} + +DiscardFormButton.defaultProps = { + className: '', + shouldConfirm: false, + small: false, +} + +const { string, bool } = PropTypes + +DiscardFormButton.propTypes = { + children: string.isRequired, + className: string, + shouldConfirm: bool, + small: bool, +} + +export default DiscardFormButton diff --git a/src/components/Buttons/DiscardFormButton.test.jsx b/src/components/Buttons/DiscardFormButton.test.jsx new file mode 100644 index 000000000..b3d797f6a --- /dev/null +++ b/src/components/Buttons/DiscardFormButton.test.jsx @@ -0,0 +1,58 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import history from '../../services/history' +import DiscardFormButton from './DiscardFormButton.jsx' + +jest.mock('../../services/history', () => ({ + push: jest.fn(), +})) + +describe('', () => { + it('renders without errors', () => { + shallow( + Discard + ) + }) + + it('renders without errors when small', () => { + shallow( + + Discard + + ) + }) + + it('applies className correctly', () => { + const wrapper = mount( + + Discard + + ) + + 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', () => { + const wrapper = mount( + Discard + ) + + expect(wrapper.find('DiscardFormModal')).toHaveLength(0) + + wrapper.find('button').simulate('click') + + expect(wrapper.find('DiscardFormModal')).toHaveLength(1) + }) + + it('changes route when it should not confirm and button is clicked', () => { + const wrapper = mount(Discard) + + wrapper.find('button').simulate('click') + + expect(history.push).toHaveBeenCalledWith('/') + }) +}) diff --git a/src/components/FormErrorBox/FormErrorBox.jsx b/src/components/FormErrorBox/FormErrorBox.jsx new file mode 100644 index 000000000..b1224adc0 --- /dev/null +++ b/src/components/FormErrorBox/FormErrorBox.jsx @@ -0,0 +1,31 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { NoticeBox } from '@dhis2/ui' +import styles from './FormErrorBox.module.css' + +const FormErrorBox = ({ submitError, title }) => { + const hasGenericSubmitErrors = submitError.length > 0 + + if (!hasGenericSubmitErrors) { + return null + } + + return ( + +
    + {submitError.map((error) => ( +
  • {error}
  • + ))} +
+
+ ) +} + +const { array, string } = PropTypes + +FormErrorBox.propTypes = { + submitError: array.isRequired, + title: string.isRequired, +} + +export default FormErrorBox diff --git a/src/components/FormErrorBox/FormErrorBox.test.jsx b/src/components/FormErrorBox/FormErrorBox.test.jsx new file mode 100644 index 000000000..81e372e1e --- /dev/null +++ b/src/components/FormErrorBox/FormErrorBox.test.jsx @@ -0,0 +1,21 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import FormErrorBox from './FormErrorBox.jsx' + +describe('', () => { + it('returns null if there are no errors', () => { + const wrapper = shallow() + + expect(wrapper.isEmptyRender()).toBe(true) + }) + + it('shows errors if there are errors', () => { + const message = 'An error message' + const submitError = [message] + const wrapper = mount( + + ) + + expect(wrapper.text()).toEqual(expect.stringContaining(message)) + }) +}) diff --git a/src/components/FormFields/CronField.jsx b/src/components/FormFields/CronField.jsx new file mode 100644 index 000000000..8f8e7d067 --- /dev/null +++ b/src/components/FormFields/CronField.jsx @@ -0,0 +1,44 @@ +import React from 'react' +import { Box, ReactFinalForm, InputFieldFF } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { requiredCron } from '../../services/validators' +import { CronPresetButton } from '../Buttons' +import translateCron from '../../services/translate-cron' + +const { Field, useFormState, useForm } = ReactFinalForm + +// The key under which this field will be sent to the backend +const FIELD_NAME = 'cronExpression' +const VALIDATOR = requiredCron + +const CronField = () => { + const form = useForm() + const { values } = useFormState({ subscription: { values: true } }) + const cronExpression = values[FIELD_NAME] + const helpText = translateCron(cronExpression) + + return ( + + + + form.change(FIELD_NAME, cron) + } + small + /> + + + ) +} + +export default CronField diff --git a/src/components/FormFields/CronField.test.jsx b/src/components/FormFields/CronField.test.jsx new file mode 100644 index 000000000..766d34340 --- /dev/null +++ b/src/components/FormFields/CronField.test.jsx @@ -0,0 +1,105 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import CronField from './CronField.jsx' + +const { Form } = ReactFinalForm + +describe('', () => { + it('shows a human readable schedule if a cron expression exists', () => { + const cronExpression = '0 0 * ? * *' + + 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('Every hour')) + }) + + 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/Custom/AggregatedDataExchangeField.jsx b/src/components/FormFields/Custom/AggregatedDataExchangeField.jsx new file mode 100644 index 000000000..410724465 --- /dev/null +++ b/src/components/FormFields/Custom/AggregatedDataExchangeField.jsx @@ -0,0 +1,105 @@ +import i18n from '@dhis2/d2-i18n' +import { + CircularLoader, + NoticeBox, + ReactFinalForm, + Transfer, + Field, +} from '@dhis2/ui' +import React, { useCallback } from 'react' +import PropTypes from 'prop-types' +import { useDataQuery } from '@dhis2/app-runtime' +import styles from './AggregatedDataExchangeField.module.css' + +const { useField } = ReactFinalForm + +const query = { + dataExchangeIds: { + resource: 'aggregateDataExchanges', + params: { + fields: ['id', 'displayName'], + paging: true, + }, + }, +} + +const validate = (value) => { + if (!value || (value && value.length < 1)) { + return i18n.t('Please select data exchange ids.') + } +} + +const SelectedEmptyComponent = () => ( +

+ {i18n.t('Select data exchange ids')} +

+) + +const AggregatedDataExchangeField = ({ label, name }) => { + const { loading, error, data } = useDataQuery(query) + const { input, meta } = useField(name, { + beforeSubmit: () => !loading || !error, + validate, + }) + const handleChange = useCallback( + ({ selected }) => { + input?.onChange(selected) + }, + [input] + ) + + if (loading) { + return + } + + if (error) { + return ( + +
+ {`${i18n.t('error type')} - ${ + error.type + }`} + {error.details?.message &&

{error.details?.message}

} +
+
+ ) + } + + const options = + data.dataExchangeIds?.aggregateDataExchanges.map((exchangeIds) => ({ + label: exchangeIds.displayName, + value: exchangeIds.id, + })) ?? [] + + return ( + + } + /> + + ) +} + +AggregatedDataExchangeField.propTypes = { + label: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, +} + +export default AggregatedDataExchangeField diff --git a/src/components/FormFields/Custom/AggregatedDataExchangeField.test.jsx b/src/components/FormFields/Custom/AggregatedDataExchangeField.test.jsx new file mode 100644 index 000000000..e9c3e8735 --- /dev/null +++ b/src/components/FormFields/Custom/AggregatedDataExchangeField.test.jsx @@ -0,0 +1,128 @@ +import { mount } from 'enzyme' +import React from 'react' +import { CircularLoader, ReactFinalForm } from '@dhis2/ui' +import { useDataQuery } from '@dhis2/app-runtime' +import AggregatedDataExchangeField from './AggregatedDataExchangeField.jsx' + +const { Form } = ReactFinalForm + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), +})) + +describe('', () => { + describe('When loading', () => { + it('should display the ', () => { + useDataQuery.mockReturnValue({ + loading: true, + error: undefined, + data: undefined, + }) + + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + expect(wrapper.find(CircularLoader).exists()).toBe(true) + }) + + it('should prevent form submission', () => { + const submitHandler = jest.fn() + useDataQuery.mockReturnValue({ + loading: true, + error: undefined, + data: undefined, + }) + + const wrapper = mount( +
+ {({ handleSubmit }) => ( + + + + )} + + ) + + wrapper.find('form').simulate('submit') + + expect(submitHandler).not.toHaveBeenCalled() + }) + }) + + describe('When response is error', () => { + const error = { + type: 'network', + details: { + message: 'Here is the error message', + }, + } + + it('should display the Error message', () => { + useDataQuery.mockReturnValue({ + loading: false, + error, + data: undefined, + }) + + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + expect( + wrapper.contains( + 'There was a problem fetching data exchange ids' + ) + ).toBe(true) + expect(wrapper.contains('error type - network')).toBe(true) + expect(wrapper.contains('Here is the error message')).toBe(true) + }) + + it('should prevent form submission', () => { + const submitHandler = jest.fn() + useDataQuery.mockReturnValue({ + loading: false, + error, + data: undefined, + }) + + const wrapper = mount( +
+ {({ handleSubmit }) => ( + + + + )} + + ) + + wrapper.find('form').simulate('submit') + + expect(submitHandler).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/components/FormFields/Custom/DataIntegrityChecksField.jsx b/src/components/FormFields/Custom/DataIntegrityChecksField.jsx new file mode 100644 index 000000000..942c9bb77 --- /dev/null +++ b/src/components/FormFields/Custom/DataIntegrityChecksField.jsx @@ -0,0 +1,196 @@ +import React, { useCallback, useState } from 'react' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { + FieldGroup, + CircularLoader, + NoticeBox, + Radio, + Transfer, + TransferOption, + Tooltip, + ReactFinalForm, + InputFieldFF, + Help, + Tag, +} from '@dhis2/ui' +import cx from 'classnames' +import { useParameterOption } from '../../../hooks/parameter-options' +import { severityMap } from '../../../services/server-translations/dataIntegrityChecks' +import styles from './DataIntegrityChecksField.module.css' + +const { Field, useField } = ReactFinalForm + +const VALIDATOR = (value) => { + // should not validate when null or undefined + // means "Run all" is selected + if (!value == null) { + return undefined + } + + if (value && value.length < 1) { + return i18n.t('Please select checks to run.') + } +} + +const DataIntegrityChecksField = ({ label, name }) => { + const { loading, error, data } = useParameterOption('dataIntegrityChecks') + const { + input: { value, onChange }, + } = useField(name) + + const hasValue = !!value && value.length > 0 + const [runSelected, setRunSelected] = useState(hasValue) + + if (loading) { + return + } + + if (error) { + return ( + + ) + } + + const translatedOptions = data + .map((option) => ({ + ...option, + value: option.name, + label: option.displayName, + severity: severityMap[option.severity], + })) + .sort((a, b) => a.label.localeCompare(b.label)) + + const toggle = ({ value }) => { + const runSelectedChecked = value === 'true' + + if (!runSelectedChecked) { + // clear checks when "Run all" is selected + // null means all checks will be run + onChange(null) + } else { + // set to empty array explicitly, + // this is to allow to differentiate between "selected checks" but empty + // and "run all"-empty for validation + onChange([]) + } + setRunSelected(runSelectedChecked) + } + + return ( + + + + + ) +} + +const LabelComponent = ({ label, severity, highlighted, disabled, isSlow }) => ( +
+
{label}
+
+ {`${i18n.t('Severity')}: ${severity}`} + {isSlow && ( + + {i18n.t('Slow')} + + )} +
+
+) + +LabelComponent.propTypes = TransferOption.propTypes + +const renderOption = (option) => ( + } /> +) + +const ChecksTransfer = ({ input, meta, options = [], hidden }) => { + const { onChange } = input + + const handleChange = useCallback( + ({ selected }) => { + onChange(selected) + }, + [onChange] + ) + + if (hidden) { + return null + } + + const isErr = meta.touched && meta.invalid + + return ( + <> + } + className={styles.transfer} + /> + {isErr && {meta.error}} + + ) +} + +ChecksTransfer.propTypes = InputFieldFF.propTypes + +const SelectedEmptyComponent = () => ( +

+ {i18n.t('Select checks to run.')} +

+) + +const { string } = PropTypes + +DataIntegrityChecksField.propTypes = { + label: string.isRequired, + name: string.isRequired, +} + +export default DataIntegrityChecksField diff --git a/src/components/FormFields/Custom/DataIntegrityReportTypeField.jsx b/src/components/FormFields/Custom/DataIntegrityReportTypeField.jsx new file mode 100644 index 000000000..351376661 --- /dev/null +++ b/src/components/FormFields/Custom/DataIntegrityReportTypeField.jsx @@ -0,0 +1,40 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { SingleSelectFieldFF, ReactFinalForm } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { getReportTypeLabel } from '../../../services/server-translations/dataIntegrityChecks' + +const { Field } = ReactFinalForm + +const DEFAULT_VALUE = 'SUMMARY' + +const DataIntegrityReportTypeField = ({ name, constants }) => { + if (!constants) { + // shouldn't really happen, but backend defaults to "report" if no value + return null + } + + const labeledOptions = constants.map((type) => ({ + value: type, + label: getReportTypeLabel(type), + })) + + return ( + + ) +} + +const { string, arrayOf } = PropTypes + +DataIntegrityReportTypeField.propTypes = { + name: string.isRequired, + constants: arrayOf(string), +} + +export default DataIntegrityReportTypeField diff --git a/src/components/FormFields/Custom/PushAnalyticsModeField.jsx b/src/components/FormFields/Custom/PushAnalyticsModeField.jsx new file mode 100644 index 000000000..1ec6ced11 --- /dev/null +++ b/src/components/FormFields/Custom/PushAnalyticsModeField.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { + SingleSelectField, + ReactFinalForm, + SingleSelectFieldFF, +} from '@dhis2/ui' +import { pushAnalyticsModes } from '../../../services/server-translations' + +const { Field } = ReactFinalForm + +const PushAnalyticsModeField = ({ label, name, constants }) => { + if (constants.length === 0) { + return ( + + ) + } + + const options = constants.map((option) => ({ + value: option, + label: pushAnalyticsModes[option] || option, + })) + + return ( + + ) +} + +const { string, arrayOf } = PropTypes + +PushAnalyticsModeField.propTypes = { + constants: arrayOf(string).isRequired, + label: string.isRequired, + name: string.isRequired, +} + +export default PushAnalyticsModeField diff --git a/src/components/FormFields/Custom/SkipTableTypesField.jsx b/src/components/FormFields/Custom/SkipTableTypesField.jsx new file mode 100644 index 000000000..0bc8a59d1 --- /dev/null +++ b/src/components/FormFields/Custom/SkipTableTypesField.jsx @@ -0,0 +1,64 @@ +import React from 'react' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { MultiSelectField, ReactFinalForm, MultiSelectFieldFF } from '@dhis2/ui' +import { analyticsTableTypes } from '../../../services/server-translations' +import { useParameterOption } from '../../../hooks/parameter-options' + +const { Field } = ReactFinalForm + +const SkipTableTypesField = ({ label, name, parameterName }) => { + const { loading, error, data } = useParameterOption(parameterName) + const disabledProps = { disabled: true, label } + + if (loading) { + return ( + + ) + } + + if (error) { + return ( + + ) + } + + if (data.length === 0) { + return ( + + ) + } + + const translatedOptions = data.map((option) => ({ + value: option, + label: analyticsTableTypes[option] || option, + })) + + return ( + + ) +} + +const { string } = PropTypes + +SkipTableTypesField.propTypes = { + label: string.isRequired, + name: string.isRequired, + parameterName: string.isRequired, +} + +export default SkipTableTypesField diff --git a/src/components/FormFields/Custom/SkipTableTypesField.test.jsx b/src/components/FormFields/Custom/SkipTableTypesField.test.jsx new file mode 100644 index 000000000..d858b6806 --- /dev/null +++ b/src/components/FormFields/Custom/SkipTableTypesField.test.jsx @@ -0,0 +1,74 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useParameterOption } from '../../../hooks/parameter-options' +import SkipTableTypesField from './SkipTableTypesField.jsx' + +const { Form } = ReactFinalForm + +jest.mock('../../../hooks/parameter-options', () => ({ + useParameterOption: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows a message when there are no options', () => { + useParameterOption.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [], + })) + const props = { + 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', () => { + useParameterOption.mockImplementation(() => ({ + loading: false, + error: undefined, + data: ['one', 'two'], + })) + const props = { + label: 'label', + name: 'fieldName', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper.find('SkipTableTypesField') + + expect(actual).toHaveLength(1) + }) +}) diff --git a/src/components/FormFields/Custom/TestPolicyField.jsx b/src/components/FormFields/Custom/TestPolicyField.jsx new file mode 100644 index 000000000..fb8ad115d --- /dev/null +++ b/src/components/FormFields/Custom/TestPolicyField.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { + SingleSelectField, + ReactFinalForm, + SingleSelectFieldFF, +} from '@dhis2/ui' +import { testPolicies } from '../../../services/server-translations' + +const { Field } = ReactFinalForm + +const TestPolicyField = ({ label, name, constants }) => { + if (constants.length === 0) { + return ( + + ) + } + + const options = constants.map((option) => ({ + value: option, + label: testPolicies[option] || option, + })) + + return ( + + ) +} + +const { string, arrayOf } = PropTypes + +TestPolicyField.propTypes = { + constants: arrayOf(string).isRequired, + label: string.isRequired, + name: string.isRequired, +} + +export default TestPolicyField diff --git a/src/components/FormFields/DelayField.jsx b/src/components/FormFields/DelayField.jsx new file mode 100644 index 000000000..3ce9073a5 --- /dev/null +++ b/src/components/FormFields/DelayField.jsx @@ -0,0 +1,46 @@ +import React from 'react' +import { + ReactFinalForm, + InputFieldFF, + composeValidators, + hasValue, + integer, + createNumberRange, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { formatToString } from './formatters' + +const { Field } = ReactFinalForm + +// Omitting the underscore here since it messes up i18n +const lowerbound = 1 +const upperbound = 86400 + +// The key under which this field will be sent to the backend +const FIELD_NAME = 'delay' +const VALIDATOR = composeValidators( + integer, + hasValue, + createNumberRange(lowerbound, upperbound) +) + +const DelayField = () => ( + +) + +export default DelayField diff --git a/src/components/FormFields/DelayField.test.jsx b/src/components/FormFields/DelayField.test.jsx new file mode 100644 index 000000000..61b88cce8 --- /dev/null +++ b/src/components/FormFields/DelayField.test.jsx @@ -0,0 +1,123 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import DelayField from './DelayField.jsx' + +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/JobTypeField.jsx b/src/components/FormFields/JobTypeField.jsx new file mode 100644 index 000000000..b926d2ea1 --- /dev/null +++ b/src/components/FormFields/JobTypeField.jsx @@ -0,0 +1,65 @@ +import React from 'react' +import { + ReactFinalForm, + SingleSelectFieldFF, + SingleSelectField, + composeValidators, + hasValue, + string, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { jobTypesMap } from '../../services/server-translations' +import { useJobTypes } from '../../hooks/job-types' + +const { Field } = ReactFinalForm + +// The key under which this field will be sent to the backend +export const FIELD_NAME = 'jobType' +const VALIDATOR = composeValidators(string, hasValue) + +const JobTypeField = () => { + const { loading, error, data } = useJobTypes() + const label = i18n.t('Job type') + const disabledProps = { disabled: true, label } + + if (loading) { + return ( + + ) + } + + if (error) { + return ( + + ) + } + + const options = data + .map(({ jobType }) => ({ + value: jobType, + label: jobTypesMap[jobType], + })) + .filter((job) => !!job.label) + .sort((job1, job2) => job1.label.localeCompare(job2.label)) + + return ( + + ) +} + +export default JobTypeField diff --git a/src/components/FormFields/JobTypeField.test.jsx b/src/components/FormFields/JobTypeField.test.jsx new file mode 100644 index 000000000..d484210bb --- /dev/null +++ b/src/components/FormFields/JobTypeField.test.jsx @@ -0,0 +1,48 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useJobTypes } from '../../hooks/job-types' +import JobTypeField from './JobTypeField.jsx' + +const { Form } = ReactFinalForm + +jest.mock('../../hooks/job-types', () => ({ + useJobTypes: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows an error that the field is required on empty values', () => { + useJobTypes.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [{ 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/ListFieldMulti.jsx b/src/components/FormFields/ListFieldMulti.jsx new file mode 100644 index 000000000..862b91321 --- /dev/null +++ b/src/components/FormFields/ListFieldMulti.jsx @@ -0,0 +1,64 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { MultiSelectFieldFF, ReactFinalForm, MultiSelectField } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { useParameterOption } from '../../hooks/parameter-options' + +const { Field } = ReactFinalForm + +// This field has options that have both an id and a label. +const ListFieldMulti = ({ label, name, parameterName }) => { + const { loading, error, data } = useParameterOption(parameterName) + const disabledProps = { disabled: true, label } + + if (loading) { + return ( + + ) + } + + if (error) { + return ( + + ) + } + + if (data.length === 0) { + return ( + + ) + } + + const labeledOptions = data.map(({ id, displayName }) => ({ + value: id, + label: displayName, + })) + + return ( + + ) +} + +const { string } = PropTypes + +ListFieldMulti.propTypes = { + label: string.isRequired, + name: string.isRequired, + parameterName: string.isRequired, +} + +export default ListFieldMulti diff --git a/src/components/FormFields/ListFieldMulti.test.jsx b/src/components/FormFields/ListFieldMulti.test.jsx new file mode 100644 index 000000000..8c36a2eab --- /dev/null +++ b/src/components/FormFields/ListFieldMulti.test.jsx @@ -0,0 +1,73 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useParameterOption } from '../../hooks/parameter-options' +import ListFieldMulti from './ListFieldMulti.jsx' + +const { Form } = ReactFinalForm + +jest.mock('../../hooks/parameter-options', () => ({ + useParameterOption: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows a message when there are no options', () => { + useParameterOption.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [], + })) + const props = { + 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', () => { + useParameterOption.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [{ id: 'id', displayName: 'displayName' }], + })) + const props = { + label: 'label', + name: 'fieldName', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper.find('ListFieldMulti') + + expect(actual).toHaveLength(1) + }) +}) diff --git a/src/components/FormFields/ListFieldSingle.jsx b/src/components/FormFields/ListFieldSingle.jsx new file mode 100644 index 000000000..5098e0bae --- /dev/null +++ b/src/components/FormFields/ListFieldSingle.jsx @@ -0,0 +1,68 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { + SingleSelectFieldFF, + ReactFinalForm, + SingleSelectField, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { useParameterOption } from '../../hooks/parameter-options' + +const { Field } = ReactFinalForm + +// This field has options that have both an id and a label. +const ListFieldSingle = ({ label, name, parameterName }) => { + const { loading, error, data } = useParameterOption(parameterName) + const disabledProps = { disabled: true, label } + + if (loading) { + return ( + + ) + } + + if (error) { + return ( + + ) + } + + if (data.length === 0) { + return ( + + ) + } + + const labeledOptions = data.map(({ id, displayName }) => ({ + value: id, + label: displayName, + })) + + return ( + + ) +} + +const { string } = PropTypes + +ListFieldSingle.propTypes = { + label: string.isRequired, + name: string.isRequired, + parameterName: string.isRequired, +} + +export default ListFieldSingle diff --git a/src/components/FormFields/ListFieldSingle.test.jsx b/src/components/FormFields/ListFieldSingle.test.jsx new file mode 100644 index 000000000..c9534a715 --- /dev/null +++ b/src/components/FormFields/ListFieldSingle.test.jsx @@ -0,0 +1,73 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useParameterOption } from '../../hooks/parameter-options' +import ListFieldSingle from './ListFieldSingle.jsx' + +const { Form } = ReactFinalForm + +jest.mock('../../hooks/parameter-options', () => ({ + useParameterOption: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows a message when there are no options', () => { + useParameterOption.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [], + })) + const props = { + label: 'label', + name: 'name', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper + .find({ + 'data-test': 'dhis2-uiwidgets-singleselectfield-help', + }) + .text() + + expect(actual).toEqual(expect.stringContaining('No options available')) + }) + + it('renders the field when there are options', () => { + useParameterOption.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [{ id: 'id', displayName: 'displayName' }], + })) + const props = { + label: 'label', + name: 'fieldName', + parameterName: 'parameterName', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const actual = wrapper.find('ListFieldSingle') + + expect(actual).toHaveLength(1) + }) +}) diff --git a/src/components/FormFields/NameField.jsx b/src/components/FormFields/NameField.jsx new file mode 100644 index 000000000..76e16c54e --- /dev/null +++ b/src/components/FormFields/NameField.jsx @@ -0,0 +1,55 @@ +import React from 'react' +import { + ReactFinalForm, + InputFieldFF, + composeValidators, + hasValue, + string, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' + +const { Field } = ReactFinalForm + +// The key under which this field will be sent to the backend +const FIELD_NAME = 'name' + +// Validation +const restrictedNames = (value) => { + if (typeof value !== 'string') { + return + } + + return value.toLowerCase() === 'add' + ? i18n.t('Queues can\'t be named "Add" or "add"') + : undefined +} +const defaultValidators = [string, hasValue] +const queueValidators = [...defaultValidators, restrictedNames] + +const NameField = ({ isQueue }) => { + const validators = isQueue ? queueValidators : defaultValidators + + return ( + + ) +} + +NameField.defaultProps = { + isQueue: false, +} + +const { bool } = PropTypes + +NameField.propTypes = { + isQueue: bool, +} + +export default NameField diff --git a/src/components/FormFields/NameField.test.jsx b/src/components/FormFields/NameField.test.jsx new file mode 100644 index 000000000..deae911e5 --- /dev/null +++ b/src/components/FormFields/NameField.test.jsx @@ -0,0 +1,82 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import NameField from './NameField.jsx' + +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)) + }) + + it('does not allow naming a queue "Add"', () => { + const expected = 'Queues can\'t be named "Add" or "add"' + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + wrapper + .find('input[name="name"]') + .simulate('change', { target: { value: 'Add' } }) + + // Trigger validation + wrapper.find('form').simulate('submit') + + const actual = wrapper + .find({ 'data-test': 'dhis2-uiwidgets-inputfield-validation' }) + .text() + + expect(actual).toEqual(expect.stringMatching(expected)) + }) + + it('does not allow naming a queue "add"', () => { + const expected = 'Queues can\'t be named "Add" or "add"' + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + wrapper + .find('input[name="name"]') + .simulate('change', { target: { value: 'add' } }) + + // 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/ParameterFields.jsx b/src/components/FormFields/ParameterFields.jsx new file mode 100644 index 000000000..d2f83a647 --- /dev/null +++ b/src/components/FormFields/ParameterFields.jsx @@ -0,0 +1,196 @@ +import React from 'react' +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import { + NoticeBox, + ReactFinalForm, + InputFieldFF, + Box, + SwitchFieldFF, +} from '@dhis2/ui' +import { useJobTypeParameters } from '../../hooks/job-types' +import SkipTableTypesField from './Custom/SkipTableTypesField.jsx' +import DataIntegrityChecksField from './Custom/DataIntegrityChecksField.jsx' +import DataIntegrityReportTypeField from './Custom/DataIntegrityReportTypeField.jsx' +import AggregatedDataExchangeField from './Custom/AggregatedDataExchangeField.jsx' +import PushAnalyticsModeField from './Custom/PushAnalyticsModeField.jsx' +import TestPolicyField from './Custom/TestPolicyField.jsx' +import styles from './ParameterFields.module.css' +import ListFieldSingle from './ListFieldSingle.jsx' +import ListFieldMulti from './ListFieldMulti.jsx' +import { formatToString } from './formatters' + +const { Field } = ReactFinalForm + +// The key under which the parameters will be sent to the backend +const FIELD_NAME = 'jobParameters' + +// Overrides for fields where the generic types aren't appropriate +const getCustomComponent = (jobType, parameterName) => { + switch (jobType) { + case 'DATA_INTEGRITY': + if (parameterName === 'checks') { + return DataIntegrityChecksField + } else if (parameterName === 'type') { + return DataIntegrityReportTypeField + } + + return null + case 'AGGREGATE_DATA_EXCHANGE': + if (parameterName === 'dataExchangeIds') { + return AggregatedDataExchangeField + } + + return null + case 'ANALYTICS_TABLE': + if (parameterName === 'skipTableTypes') { + return SkipTableTypesField + } else if (parameterName === 'skipPrograms') { + return ListFieldMulti + } + + return null + case 'CONTINUOUS_ANALYTICS_TABLE': + if (parameterName === 'skipTableTypes') { + return SkipTableTypesField + } + + return null + case 'HTML_PUSH_ANALYTICS': + if (parameterName === 'dashboard') { + return ListFieldSingle + } else if (parameterName === 'receivers') { + return ListFieldSingle + } else if (parameterName === 'mode') { + return PushAnalyticsModeField + } + + return null + case 'TEST': + if (parameterName === 'failWithPolicy') { + return TestPolicyField + } + + return null + default: + return null + } +} + +// Renders all parameters for a given jobtype +const ParameterFields = ({ jobType }) => { + const { loading, error, data } = useJobTypeParameters(jobType) + + if (loading) { + return null + } + + if (error) { + return ( + + ) + } + + if (data.length === 0) { + return null + } + + // Map all parameters to the appropriate field types + const parameterComponents = data.map( + ({ fieldName, name, klass, ...rest }) => { + let parameterComponent = null + const defaultProps = { + label: fieldName, + name: `${FIELD_NAME}.${name}`, + } + const parameterProps = { + fieldName, + name, + klass, + ...rest, + } + + const CustomParameterComponent = getCustomComponent(jobType, name) + + if (CustomParameterComponent) { + return ( + + + + ) + } + + // Generic field rendering + switch (klass) { + case 'java.lang.String': + parameterComponent = ( + + ) + break + case 'java.lang.Boolean': + parameterComponent = ( + + ) + break + case 'java.lang.Integer': + case 'java.lang.Long': + parameterComponent = ( + + ) + break + case 'java.util.List': + parameterComponent = ( + + ) + break + } + + // Wrap all components in a Box for spacing + return ( + + {parameterComponent} + + ) + } + ) + + return ( + +
+

{i18n.t('Parameters')}

+
+ {parameterComponents} +
+ ) +} + +const { string } = PropTypes + +ParameterFields.propTypes = { + jobType: string.isRequired, +} + +export default ParameterFields diff --git a/src/components/FormFields/ParameterFields.test.jsx b/src/components/FormFields/ParameterFields.test.jsx new file mode 100644 index 000000000..98e1b6952 --- /dev/null +++ b/src/components/FormFields/ParameterFields.test.jsx @@ -0,0 +1,198 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useJobTypeParameters } from '../../hooks/job-types' +import { useParameterOption } from '../../hooks/parameter-options' +import ParameterFields from './ParameterFields.jsx' + +const { Form } = ReactFinalForm + +jest.mock('../../hooks/job-types', () => ({ + useJobTypeParameters: jest.fn(), +})) + +jest.mock('../../hooks/parameter-options', () => ({ + useParameterOption: jest.fn(), +})) + +describe('', () => { + it('returns null if there are no parameters', () => { + useJobTypeParameters.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 skipTableTypes', () => { + useParameterOption.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [], + })) + useJobTypeParameters.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [ + { + fieldName: 'fieldName', + name: 'skipTableTypes', + klass: 'klass', + }, + ], + })) + const props = { + jobType: 'ANALYTICS_TABLE', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const component = wrapper.find('SkipTableTypesField') + + expect(component).toHaveLength(1) + }) + + it('returns the expected component for java.lang.String', () => { + useJobTypeParameters.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [ + { + fieldName: 'fieldName', + name: 'name', + klass: 'java.lang.String', + }, + ], + })) + 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', () => { + useJobTypeParameters.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [ + { + fieldName: 'fieldName', + name: 'name', + klass: 'java.lang.Boolean', + }, + ], + })) + 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', () => { + useJobTypeParameters.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [ + { + fieldName: 'fieldName', + name: 'name', + klass: 'java.lang.Integer', + }, + ], + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const component = wrapper.find('InputFieldFF') + + expect(component).toHaveLength(1) + }) + + it('returns the expected component for java.util.List', () => { + useJobTypeParameters.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [ + { + fieldName: 'fieldName', + name: 'parameterName', + klass: 'java.util.List', + }, + ], + })) + const props = { + jobType: 'jobType', + } + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const component = wrapper.find('ListFieldMulti') + + expect(component).toHaveLength(1) + }) +}) diff --git a/src/components/FormFields/QueueOption.jsx b/src/components/FormFields/QueueOption.jsx new file mode 100644 index 000000000..5237509e1 --- /dev/null +++ b/src/components/FormFields/QueueOption.jsx @@ -0,0 +1,42 @@ +import React from 'react' +import cx from 'classnames' +import PropTypes from 'prop-types' +import styles from './QueueOption.module.css' + +const { bool, func, string } = PropTypes + +const QueueOption = ({ + label, + value, + type, + onClick, + highlighted, + onDoubleClick, +}) => { + const className = cx(styles.wrapper, { [styles.highlighted]: highlighted }) + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events +
onClick({ label, value }, event)} + onDoubleClick={(event) => onDoubleClick({ label, value }, event)} + className={className} + data-value={value} + data-test="dhis2-uicore-transferoption" + > +
{label}
+
{type}
+
+ ) +} + +QueueOption.propTypes = { + highlighted: bool.isRequired, + label: string.isRequired, + type: string.isRequired, + value: string.isRequired, + onClick: func.isRequired, + onDoubleClick: func.isRequired, +} + +export default QueueOption diff --git a/src/components/FormFields/QueueOrderField.jsx b/src/components/FormFields/QueueOrderField.jsx new file mode 100644 index 000000000..60edd98fd --- /dev/null +++ b/src/components/FormFields/QueueOrderField.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { ReactFinalForm, CircularLoader, NoticeBox } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { jobTypesMap } from '../../services/server-translations' +import { useQueueables } from '../../hooks/queueables' +import QueueTransfer from './QueueTransfer.jsx' + +const { Field } = ReactFinalForm + +// The key under which this field will be sent to the backend +const FIELD_NAME = 'sequence' +const hasEnoughJobs = (value) => + value?.length > 1 ? undefined : i18n.t('Please select at least two jobs') + +const QueueOrderField = ({ initialSelectedValues }) => { + const { loading, error, data } = useQueueables() + + if (loading) { + return + } + + if (error) { + return ( + + ) + } + + // Map to a format the transfer can render + const options = [...data, ...initialSelectedValues].map( + ({ name, id, type }) => ({ + label: name, + value: id, + type: jobTypesMap[type], + }) + ) + + return ( + + ) +} + +QueueOrderField.defaultProps = { + initialSelectedValues: [], +} + +const { array } = PropTypes + +QueueOrderField.propTypes = { + initialSelectedValues: array, +} + +export default QueueOrderField diff --git a/src/components/FormFields/QueueOrderField.test.jsx b/src/components/FormFields/QueueOrderField.test.jsx new file mode 100644 index 000000000..437da2119 --- /dev/null +++ b/src/components/FormFields/QueueOrderField.test.jsx @@ -0,0 +1,61 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm, CircularLoader } from '@dhis2/ui' +import { useQueueables } from '../../hooks/queueables' +import QueueOrderField from './QueueOrderField.jsx' + +const { Form } = ReactFinalForm + +jest.mock('../../hooks/queueables', () => ({ + useQueueables: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('should show a loading spinner when loading', () => { + useQueueables.mockImplementation(() => ({ + loading: true, + error: undefined, + data: null, + })) + + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + expect(wrapper.find(CircularLoader).exists()).toBe(true) + }) + + it('should display any loading errors', () => { + useQueueables.mockImplementation(() => ({ + loading: false, + error: new Error('Something went wrong'), + data: null, + })) + + const wrapper = mount( +
{}}> + {({ handleSubmit }) => ( + + + + )} + + ) + + expect( + wrapper.contains( + 'Something went wrong whilst fetching the queueable jobs' + ) + ).toBe(true) + }) +}) diff --git a/src/components/FormFields/QueueTransfer.jsx b/src/components/FormFields/QueueTransfer.jsx new file mode 100644 index 000000000..f285b7a9a --- /dev/null +++ b/src/components/FormFields/QueueTransfer.jsx @@ -0,0 +1,59 @@ +import React from 'react' +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import { Field, Transfer } from '@dhis2/ui' +import QueueTransferTitle from './QueueTransferTitle.jsx' +import QueueOption from './QueueOption.jsx' + +const { bool, arrayOf, shape, func, array, string, oneOfType } = PropTypes + +const QueueTransfer = ({ options, input, meta }) => { + const { onChange, value } = input + const hasError = meta.touched && !!meta.error + const errorMessage = hasError ? meta.error : '' + + return ( + + onChange(selected)} + leftHeader={ + + } + rightHeader={ + + } + /> + + ) +} + +QueueTransfer.propTypes = { + input: shape({ + onChange: func.isRequired, + value: oneOfType([string, array]).isRequired, + }).isRequired, + meta: shape({ + error: string, + touched: bool, + }).isRequired, + options: arrayOf( + shape({ + name: string, + id: string, + type: string, + }) + ).isRequired, +} + +export default QueueTransfer diff --git a/src/components/FormFields/QueueTransferTitle.jsx b/src/components/FormFields/QueueTransferTitle.jsx new file mode 100644 index 000000000..5bf5d7cc0 --- /dev/null +++ b/src/components/FormFields/QueueTransferTitle.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import PropTypes from 'prop-types' +import s from './QueueTransferTitle.module.css' + +const QueueTransferTitle = ({ title }) =>

{title}

+ +QueueTransferTitle.propTypes = { + title: PropTypes.string.isRequired, +} + +export default QueueTransferTitle diff --git a/src/components/FormFields/ScheduleField.jsx b/src/components/FormFields/ScheduleField.jsx new file mode 100644 index 000000000..56e0f2adc --- /dev/null +++ b/src/components/FormFields/ScheduleField.jsx @@ -0,0 +1,46 @@ +import React from 'react' +import i18n from '@dhis2/d2-i18n' +import { NoticeBox } from '@dhis2/ui' +import PropTypes from 'prop-types' +import { useJobType } from '../../hooks/job-types' +import CronField from './CronField.jsx' +import DelayField from './DelayField.jsx' + +const ScheduleField = ({ jobType }) => { + const { loading, error, data } = useJobType(jobType) + + if (loading) { + return null + } + + if (error) { + return ( + + ) + } + + const { schedulingType } = data + + switch (schedulingType) { + case 'CRON': + return + case 'FIXED_DELAY': + return + default: + // Unrecognised scheduling type + return null + } +} + +const { string } = PropTypes + +ScheduleField.propTypes = { + jobType: string.isRequired, +} + +export default ScheduleField diff --git a/src/components/FormFields/ScheduleField.test.jsx b/src/components/FormFields/ScheduleField.test.jsx new file mode 100644 index 000000000..0cbd4c582 --- /dev/null +++ b/src/components/FormFields/ScheduleField.test.jsx @@ -0,0 +1,93 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { useJobType } from '../../hooks/job-types' +import ScheduleField from './ScheduleField.jsx' + +const { Form } = ReactFinalForm + +jest.mock('../../hooks/job-types', () => ({ + useJobType: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('renders the cron field if the scheduling type is CRON', () => { + const props = { + jobType: 'one', + } + useJobType.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { + schedulingType: 'CRON', + }, + })) + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const component = wrapper.find('CronField') + expect(component).toHaveLength(1) + }) + + it('renders the delay field if the scheduling type is FIXED_DELAY', () => { + const props = { + jobType: 'one', + } + useJobType.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { + schedulingType: 'FIXED_DELAY', + }, + })) + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const component = wrapper.find('DelayField') + expect(component).toHaveLength(1) + }) + + it('returns null for unrecognised scheduling types', () => { + const props = { + jobType: 'one', + } + useJobType.mockImplementation(() => ({ + loading: false, + error: undefined, + data: { + schedulingType: 'DOES_NOT_EXIST', + }, + })) + const wrapper = mount( +
{}}> + {() => ( + + + + )} + + ) + + const children = wrapper.find('form').children() + + expect(children.isEmptyRender()).toBe(true) + }) +}) diff --git a/src/components/Forms/JobAddForm.jsx b/src/components/Forms/JobAddForm.jsx new file mode 100644 index 000000000..eed395023 --- /dev/null +++ b/src/components/Forms/JobAddForm.jsx @@ -0,0 +1,91 @@ +import React from 'react' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { Button, CircularLoader, Box } from '@dhis2/ui' +import { DiscardFormButton } from '../Buttons' +import { FormErrorBox } from '../FormErrorBox' +import { + ScheduleField, + NameField, + JobTypeField, + ParameterFields, + fieldNames, +} from '../FormFields' +import styles from './JobAddForm.module.css' + +const JobAddForm = ({ + handleSubmit, + pristine, + submitting, + submitError, + hasSubmitErrors, + values, +}) => { + // Check if there's currently a selected job type + const jobType = values[fieldNames.JOB_TYPE] + + // Show a spinner only when submitting + const Spinner = submitting ? : null + + return ( +
+ + + + + + + {jobType && ( + + + + )} + {jobType && ( + + + + )} + {hasSubmitErrors && ( + + + + )} +
+ + + {i18n.t('Cancel')} + +
+
+ ) +} + +const { func, bool, object, array } = PropTypes + +JobAddForm.defaultProps = { + submitError: [], +} + +JobAddForm.propTypes = { + handleSubmit: func.isRequired, + hasSubmitErrors: bool.isRequired, + pristine: bool.isRequired, + submitting: bool.isRequired, + values: object.isRequired, + submitError: array, +} + +export default JobAddForm diff --git a/src/components/Forms/JobAddForm.test.jsx b/src/components/Forms/JobAddForm.test.jsx new file mode 100644 index 000000000..0e0372994 --- /dev/null +++ b/src/components/Forms/JobAddForm.test.jsx @@ -0,0 +1,152 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { fieldNames } from '../FormFields' +import JobAddForm from './JobAddForm.jsx' + +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: {}, + } + + const wrapper = mount( +
{}}>{() => } + ) + const actual = wrapper.find({ + 'data-test': 'dhis2-uicore-noticebox-content-message', + }) + + expect(actual).toHaveLength(1) + expect(actual.text()).toEqual(expect.stringContaining(message)) + }) + + it('shows a spinner when submitting', () => { + const props = { + handleSubmit: () => {}, + pristine: false, + submitting: true, + submitError: [], + hasSubmitErrors: false, + values: {}, + } + + 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( +
{}} + component={JobAddForm} + 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( + {}} + component={JobAddForm} + 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: {}, + } + + 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: {}, + } + + 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/JobAddFormContainer.jsx b/src/components/Forms/JobAddFormContainer.jsx new file mode 100644 index 000000000..dc05f1695 --- /dev/null +++ b/src/components/Forms/JobAddFormContainer.jsx @@ -0,0 +1,20 @@ +import React from 'react' +import { ReactFinalForm } from '@dhis2/ui' +import history from '../../services/history' +import { useSubmitJob } from '../../hooks/jobs' +import JobAddForm from './JobAddForm.jsx' + +const { Form } = ReactFinalForm + +const JobAddFormContainer = () => { + const redirect = () => { + history.push('/') + } + const [submitJob] = useSubmitJob({ onSuccess: redirect }) + + return ( +
+ ) +} + +export default JobAddFormContainer diff --git a/src/components/Forms/JobAddFormContainer.test.jsx b/src/components/Forms/JobAddFormContainer.test.jsx new file mode 100644 index 000000000..a1e2961da --- /dev/null +++ b/src/components/Forms/JobAddFormContainer.test.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobAddFormContainer from './JobAddFormContainer.jsx' + +describe('', () => { + it('renders without errors', () => { + const props = { + setIsPristine: () => {}, + } + + shallow() + }) +}) diff --git a/src/components/Forms/JobEditForm.jsx b/src/components/Forms/JobEditForm.jsx new file mode 100644 index 000000000..f18968c99 --- /dev/null +++ b/src/components/Forms/JobEditForm.jsx @@ -0,0 +1,102 @@ +import React from 'react' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { Button, CircularLoader, Box } from '@dhis2/ui' +import history from '../../services/history' +import { DiscardFormButton, DeleteJobButton } from '../Buttons' +import { FormErrorBox } from '../FormErrorBox' +import { + ScheduleField, + NameField, + JobTypeField, + ParameterFields, + fieldNames, +} from '../FormFields' +import styles from './JobEditForm.module.css' + +const JobEditForm = ({ + id, + handleSubmit, + pristine, + submitting, + submitError, + hasSubmitErrors, + values, +}) => { + // Check if there's currently a selected job type + const jobType = values[fieldNames.JOB_TYPE] + + // Show a spinner only when submitting + const Spinner = submitting ? : null + + return ( + + + + + + + + {jobType && ( + + + + )} + {jobType && ( + + + + )} + {hasSubmitErrors && ( + + + + )} +
+ + + {i18n.t('Cancel')} + + + { + history.push('/') + }} + /> + +
+ + ) +} + +const { func, bool, object, array, string } = PropTypes + +JobEditForm.defaultProps = { + submitError: [], +} + +JobEditForm.propTypes = { + handleSubmit: func.isRequired, + hasSubmitErrors: bool.isRequired, + id: string.isRequired, + pristine: bool.isRequired, + submitting: bool.isRequired, + values: object.isRequired, + submitError: array, +} + +export default JobEditForm diff --git a/src/components/Forms/JobEditForm.test.jsx b/src/components/Forms/JobEditForm.test.jsx new file mode 100644 index 000000000..dc3fa14f9 --- /dev/null +++ b/src/components/Forms/JobEditForm.test.jsx @@ -0,0 +1,164 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import { fieldNames } from '../FormFields' +import JobEditForm from './JobEditForm.jsx' + +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 = { + id: 'id', + handleSubmit: () => {}, + pristine: false, + submitting: false, + submitError: [message], + hasSubmitErrors: true, + values: {}, + refetchJobs: () => {}, + } + + const wrapper = mount( +
{}}>{() => } + ) + const actual = wrapper.find({ + 'data-test': 'dhis2-uicore-noticebox-content-message', + }) + + expect(actual).toHaveLength(1) + expect(actual.text()).toEqual(expect.stringContaining(message)) + }) + + it('shows a spinner when submitting', () => { + const props = { + id: 'id', + handleSubmit: () => {}, + pristine: false, + submitting: true, + submitError: [], + hasSubmitErrors: false, + values: {}, + refetchJobs: () => {}, + } + + 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( +
{}} + onSubmit={() => {}} + component={JobEditForm} + 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( + {}} + onSubmit={() => {}} + component={JobEditForm} + 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 = { + id: 'id', + handleSubmit: () => {}, + pristine: true, + submitting: false, + submitError: [], + hasSubmitErrors: false, + values: {}, + refetchJobs: () => {}, + } + + 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 = { + id: 'id', + handleSubmit: () => {}, + pristine: false, + submitting: true, + submitError: [], + hasSubmitErrors: false, + values: {}, + refetchJobs: () => {}, + } + + 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/JobEditFormContainer.jsx b/src/components/Forms/JobEditFormContainer.jsx new file mode 100644 index 000000000..17a4536ab --- /dev/null +++ b/src/components/Forms/JobEditFormContainer.jsx @@ -0,0 +1,45 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { ReactFinalForm } from '@dhis2/ui' +import { useParams } from 'react-router-dom' +import history from '../../services/history' +import { useUpdateJob } from '../../hooks/jobs' +import JobEditForm from './JobEditForm.jsx' + +const { Form } = ReactFinalForm + +const JobEditFormContainer = ({ job }) => { + const { id } = useParams() + const redirect = () => { + history.push('/') + } + const [updateJob] = useUpdateJob({ id, onSuccess: redirect }) + + // Creating an object with just the values we want to use as initial values + const { cronExpression, delay, jobParameters, jobType, name } = job + const initialValues = { + cronExpression, + delay, + jobParameters, + jobType, + name, + } + + return ( +
+ ) +} + +const { object } = PropTypes + +JobEditFormContainer.propTypes = { + job: object.isRequired, +} + +export default JobEditFormContainer diff --git a/src/components/Forms/JobEditFormContainer.test.jsx b/src/components/Forms/JobEditFormContainer.test.jsx new file mode 100644 index 000000000..82efd19e7 --- /dev/null +++ b/src/components/Forms/JobEditFormContainer.test.jsx @@ -0,0 +1,19 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobEditFormContainer from './JobEditFormContainer.jsx' + +jest.mock('react-router-dom', () => ({ + useParams: () => ({ id: 'id' }), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('renders without errors', () => { + const job = { id: 'id' } + + shallow( {}} />) + }) +}) diff --git a/src/components/Forms/QueueAddForm.jsx b/src/components/Forms/QueueAddForm.jsx new file mode 100644 index 000000000..38f7e5e40 --- /dev/null +++ b/src/components/Forms/QueueAddForm.jsx @@ -0,0 +1,73 @@ +import React from 'react' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { Button, CircularLoader, Box } from '@dhis2/ui' +import { DiscardFormButton } from '../Buttons' +import { FormErrorBox } from '../FormErrorBox' +import { NameField, CronField, QueueOrderField } from '../FormFields' +import styles from './QueueAddForm.module.css' + +const QueueAddForm = ({ + handleSubmit, + pristine, + submitting, + submitError, + hasSubmitErrors, +}) => { + // Show a spinner only when submitting + const Spinner = submitting ? : null + + return ( + + + + + + + + + + + {hasSubmitErrors && ( + + + + )} +
+ + + {i18n.t('Cancel')} + +
+ + ) +} + +const { func, bool, array } = PropTypes + +QueueAddForm.defaultProps = { + submitError: [], +} + +QueueAddForm.propTypes = { + handleSubmit: func.isRequired, + hasSubmitErrors: bool.isRequired, + pristine: bool.isRequired, + submitting: bool.isRequired, + submitError: array, +} + +export default QueueAddForm diff --git a/src/components/Forms/QueueAddForm.test.jsx b/src/components/Forms/QueueAddForm.test.jsx new file mode 100644 index 000000000..1cf912ab8 --- /dev/null +++ b/src/components/Forms/QueueAddForm.test.jsx @@ -0,0 +1,113 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import QueueAddForm from './QueueAddForm.jsx' + +const { Form } = ReactFinalForm + +// Mock components that make network requests +jest.mock('../FormFields/QueueOrderField', () => () => ( +
QueueOrderField
+)) + +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: {}, + } + + const wrapper = mount( +
{}}>{() => } + ) + const actual = wrapper.find({ + 'data-test': 'dhis2-uicore-noticebox-content-message', + }) + + expect(actual).toHaveLength(1) + expect(actual.text()).toEqual(expect.stringContaining(message)) + }) + + it('shows a spinner when submitting', () => { + const props = { + handleSubmit: () => {}, + pristine: false, + submitting: true, + submitError: [], + hasSubmitErrors: false, + values: {}, + } + + 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('disables the submit button when pristine', () => { + const props = { + handleSubmit: () => {}, + pristine: true, + submitting: false, + submitError: [], + hasSubmitErrors: false, + values: {}, + } + + 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: {}, + } + + 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/QueueAddFormContainer.jsx b/src/components/Forms/QueueAddFormContainer.jsx new file mode 100644 index 000000000..c956b219b --- /dev/null +++ b/src/components/Forms/QueueAddFormContainer.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import { ReactFinalForm } from '@dhis2/ui' +import history from '../../services/history' +import { useSubmitQueue } from '../../hooks/queues' +import QueueAddForm from './QueueAddForm.jsx' + +const { Form } = ReactFinalForm + +const navigateHome = () => { + history.push('/') +} + +const QueueAddFormContainer = () => { + const [submitQueue] = useSubmitQueue({ onSuccess: navigateHome }) + + return ( +
+ ) +} + +export default QueueAddFormContainer diff --git a/src/components/Forms/QueueAddFormContainer.test.jsx b/src/components/Forms/QueueAddFormContainer.test.jsx new file mode 100644 index 000000000..74f93d6ae --- /dev/null +++ b/src/components/Forms/QueueAddFormContainer.test.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import { shallow } from 'enzyme' +import QueueAddFormContainer from './QueueAddFormContainer.jsx' + +describe('', () => { + it('renders without errors', () => { + const props = { + setIsPristine: () => {}, + } + + shallow() + }) +}) diff --git a/src/components/Forms/QueueEditForm.jsx b/src/components/Forms/QueueEditForm.jsx new file mode 100644 index 000000000..12f9f626f --- /dev/null +++ b/src/components/Forms/QueueEditForm.jsx @@ -0,0 +1,88 @@ +import React from 'react' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { Button, CircularLoader, Box } from '@dhis2/ui' +import { DiscardFormButton, DeleteQueueButton } from '../Buttons' +import history from '../../services/history' +import { FormErrorBox } from '../FormErrorBox' +import { NameField, CronField, QueueOrderField } from '../FormFields' +import styles from './QueueEditForm.module.css' + +const QueueEditForm = ({ + name, + handleSubmit, + pristine, + submitting, + submitError, + hasSubmitErrors, + initialSelectedValues, +}) => { + // Show a spinner only when submitting + const Spinner = submitting ? : null + + return ( + + + + + + + + + + + {hasSubmitErrors && ( + + + + )} +
+ + + {i18n.t('Cancel')} + + + { + history.push('/') + }} + /> + +
+ + ) +} + +const { func, bool, array, string } = PropTypes + +QueueEditForm.defaultProps = { + submitError: [], +} + +QueueEditForm.propTypes = { + handleSubmit: func.isRequired, + hasSubmitErrors: bool.isRequired, + name: string.isRequired, + pristine: bool.isRequired, + submitting: bool.isRequired, + initialSelectedValues: array, + submitError: array, +} + +export default QueueEditForm diff --git a/src/components/Forms/QueueEditForm.test.jsx b/src/components/Forms/QueueEditForm.test.jsx new file mode 100644 index 000000000..9972bf7b8 --- /dev/null +++ b/src/components/Forms/QueueEditForm.test.jsx @@ -0,0 +1,125 @@ +import React from 'react' +import { mount } from 'enzyme' +import { ReactFinalForm } from '@dhis2/ui' +import QueueEditForm from './QueueEditForm.jsx' + +const { Form } = ReactFinalForm + +// Mock components that make network requests +jest.mock('../FormFields/QueueOrderField', () => () => ( +
QueueOrderField
+)) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('shows submit errors if there are any', () => { + const message = 'Generic submit error' + const props = { + name: 'name', + handleSubmit: () => {}, + pristine: false, + submitting: false, + submitError: [message], + hasSubmitErrors: true, + values: {}, + } + + const wrapper = mount( +
{}}> + {() => } + + ) + const actual = wrapper.find({ + 'data-test': 'dhis2-uicore-noticebox-content-message', + }) + + expect(actual).toHaveLength(1) + expect(actual.text()).toEqual(expect.stringContaining(message)) + }) + + it('shows a spinner when submitting', () => { + const props = { + name: 'name', + handleSubmit: () => {}, + pristine: false, + submitting: true, + submitError: [], + hasSubmitErrors: false, + values: {}, + } + + 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('disables the submit button when pristine', () => { + const props = { + name: 'name', + handleSubmit: () => {}, + pristine: true, + submitting: false, + submitError: [], + hasSubmitErrors: false, + values: {}, + } + + 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 = { + name: 'name', + handleSubmit: () => {}, + pristine: false, + submitting: true, + submitError: [], + hasSubmitErrors: false, + values: {}, + } + + 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/QueueEditFormContainer.jsx b/src/components/Forms/QueueEditFormContainer.jsx new file mode 100644 index 000000000..444d38933 --- /dev/null +++ b/src/components/Forms/QueueEditFormContainer.jsx @@ -0,0 +1,59 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { ReactFinalForm } from '@dhis2/ui' +import history from '../../services/history' +import { useUpdateQueue } from '../../hooks/queues' +import QueueEditForm from './QueueEditForm.jsx' + +const { Form } = ReactFinalForm + +const navigateHome = () => { + history.push('/') +} + +const QueueEditFormContainer = ({ queue, jobs }) => { + const [submitQueue] = useUpdateQueue({ + onSuccess: navigateHome, + initialName: queue.name, + }) + + /** + * The transfer needs the selected options to be supplied as well, but the backend + * omits selected options from the queueables fetch. So we recreate them here. + */ + const findJob = (targetId) => jobs.find(({ id }) => id === targetId) + const initialSelectedValues = queue.sequence.map((currentId) => { + const { name, id, jobType: type } = findJob(currentId) + return { name, id, type } + }) + + return ( +
+ ) +} + +const { arrayOf, shape, string, array } = PropTypes + +QueueEditFormContainer.propTypes = { + jobs: arrayOf( + shape({ + id: string.isRequired, + jobType: string.isRequired, + name: string.isRequired, + }) + ).isRequired, + queue: shape({ + cronExpression: string.isRequired, + sequence: array.isRequired, + name: string.isRequired, + }).isRequired, +} + +export default QueueEditFormContainer diff --git a/src/components/Forms/QueueEditFormContainer.test.jsx b/src/components/Forms/QueueEditFormContainer.test.jsx new file mode 100644 index 000000000..65744e8a0 --- /dev/null +++ b/src/components/Forms/QueueEditFormContainer.test.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import { shallow } from 'enzyme' +import QueueEditFormContainer from './QueueEditFormContainer.jsx' + +jest.mock('react-router-dom', () => ({ + useParams: () => ({ name: 'name' }), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('renders without errors', () => { + const queue = { + cronExpression: '', + sequence: [], + name: '', + } + const jobs = [] + + shallow( + {}} + /> + ) + }) +}) diff --git a/src/components/InfoLink/InfoLink.jsx b/src/components/InfoLink/InfoLink.jsx new file mode 100644 index 000000000..313f29de5 --- /dev/null +++ b/src/components/InfoLink/InfoLink.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import { IconInfo16 } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import styles from './InfoLink.module.css' + +const InfoLink = () => { + return ( + + + + + {i18n.t('About the scheduler')} + + ) +} + +export default InfoLink diff --git a/src/components/JobDetails/JobDetails.jsx b/src/components/JobDetails/JobDetails.jsx new file mode 100644 index 000000000..a872ef022 --- /dev/null +++ b/src/components/JobDetails/JobDetails.jsx @@ -0,0 +1,54 @@ +import React from 'react' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import moment from 'moment' +import { jobStatusMap } from '../../services/server-translations' +import styles from './JobDetails.module.css' + +const JobDetails = ({ created, lastExecutedStatus, lastExecuted }) => { + // Using Date.now allows for easier mocking + const now = Date.now() + const createdFromNow = moment(created).from(now) + const translatedStatus = lastExecutedStatus + ? jobStatusMap[lastExecutedStatus] + : '' + const lastRunFromNow = lastExecuted ? moment(lastExecuted).from(now) : '' + + return ( +
+

{i18n.t('Job details')}

+
+
+ {i18n.t('Created {{ createdFromNow }}.', { + createdFromNow, + })} +
+ {lastRunFromNow && ( +
+ {i18n.t('Last run {{ lastRunFromNow }}.', { + lastRunFromNow, + })} +
+ )} + {translatedStatus && ( +
+ {i18n.t('Last run status: {{ translatedStatus }}.', { + translatedStatus, + nsSeparator: '>', + })} +
+ )} +
+
+ ) +} + +const { string } = PropTypes + +JobDetails.propTypes = { + created: string.isRequired, + lastExecuted: string, + lastExecutedStatus: string, +} + +export default JobDetails diff --git a/src/components/JobDetails/JobDetails.test.jsx b/src/components/JobDetails/JobDetails.test.jsx new file mode 100644 index 000000000..304ff67a9 --- /dev/null +++ b/src/components/JobDetails/JobDetails.test.jsx @@ -0,0 +1,71 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobDetails from './JobDetails.jsx' + +describe('', () => { + it('renders the job details', () => { + const ten = '2020-01-01T10:00:00.000' + const eleven = '2020-01-01T11:00:00.000' + const twelve = '2020-01-01T12:00:00.000' + + jest.spyOn(global.Date, 'now').mockImplementation(() => twelve) + + const props = { + created: ten, + lastExecutedStatus: 'COMPLETED', + lastExecuted: eleven, + } + + const wrapper = shallow() + + expect(wrapper.text()).toEqual( + expect.stringContaining('Created 2 hours ago.') + ) + expect(wrapper.text()).toEqual( + expect.stringContaining('Last run an hour ago.') + ) + expect(wrapper.text()).toEqual( + expect.stringContaining('Last run status: Completed.') + ) + }) + + it('omits last run info if there is no last executed prop', () => { + const ten = '2020-01-01T10:00:00.000' + const twelve = '2020-01-01T12:00:00.000' + + jest.spyOn(global.Date, 'now').mockImplementation(() => twelve) + + const props = { + created: ten, + lastExecutedStatus: 'COMPLETED', + } + + const wrapper = shallow() + + // Should not include a last run time + expect(wrapper.text()).toMatchInlineSnapshot( + `"Job detailsCreated 2 hours ago.Last run status: Completed."` + ) + }) + + it('omits last run status if there is no matching translation', () => { + const ten = '2020-01-01T10:00:00.000' + const eleven = '2020-01-01T11:00:00.000' + const twelve = '2020-01-01T12:00:00.000' + + jest.spyOn(global.Date, 'now').mockImplementation(() => twelve) + + const props = { + created: ten, + lastExecuted: eleven, + lastExecutedStatus: 'DOES NOT EXIST', + } + + const wrapper = shallow() + + // Should not include a last run status + expect(wrapper.text()).toMatchInlineSnapshot( + `"Job detailsCreated 2 hours ago.Last run an hour ago."` + ) + }) +}) diff --git a/src/components/JobTable/DeleteJobAction.jsx b/src/components/JobTable/DeleteJobAction.jsx new file mode 100644 index 000000000..bb3ce6047 --- /dev/null +++ b/src/components/JobTable/DeleteJobAction.jsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { MenuItem } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { DeleteJobModal } from '../Modal' + +const DeleteJobAction = ({ id, onSuccess }) => { + const [showModal, setShowModal] = useState(false) + + return ( + + { + setShowModal(true) + }} + label={i18n.t('Delete')} + /> + {showModal && ( + setShowModal(false) + } + onSuccess={onSuccess} + /> + )} + + ) +} + +const { string, func } = PropTypes + +DeleteJobAction.propTypes = { + id: string.isRequired, + onSuccess: func.isRequired, +} + +export default DeleteJobAction diff --git a/src/components/JobTable/DeleteJobAction.test.jsx b/src/components/JobTable/DeleteJobAction.test.jsx new file mode 100644 index 000000000..32fbda452 --- /dev/null +++ b/src/components/JobTable/DeleteJobAction.test.jsx @@ -0,0 +1,19 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import DeleteJobAction from './DeleteJobAction.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow( {}} />) + }) + + it('shows the modal when MenuItem is clicked', () => { + const wrapper = mount( {}} />) + + expect(wrapper.find('DeleteJobModal')).toHaveLength(0) + + wrapper.find('a').simulate('click') + + expect(wrapper.find('DeleteJobModal')).toHaveLength(1) + }) +}) diff --git a/src/components/JobTable/DeleteQueueAction.jsx b/src/components/JobTable/DeleteQueueAction.jsx new file mode 100644 index 000000000..71e4640b3 --- /dev/null +++ b/src/components/JobTable/DeleteQueueAction.jsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { MenuItem } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { DeleteQueueModal } from '../Modal' + +const DeleteQueueAction = ({ name, onSuccess }) => { + const [showModal, setShowModal] = useState(false) + + return ( + + { + setShowModal(true) + }} + label={i18n.t('Delete')} + /> + {showModal && ( + setShowModal(false) + } + onSuccess={onSuccess} + /> + )} + + ) +} + +const { string, func } = PropTypes + +DeleteQueueAction.propTypes = { + name: string.isRequired, + onSuccess: func.isRequired, +} + +export default DeleteQueueAction diff --git a/src/components/JobTable/DeleteQueueAction.test.jsx b/src/components/JobTable/DeleteQueueAction.test.jsx new file mode 100644 index 000000000..9269f5cf1 --- /dev/null +++ b/src/components/JobTable/DeleteQueueAction.test.jsx @@ -0,0 +1,21 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import DeleteQueueAction from './DeleteQueueAction.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow( {}} />) + }) + + it('shows the modal when MenuItem is clicked', () => { + const wrapper = mount( + {}} /> + ) + + expect(wrapper.find('DeleteQueueModal')).toHaveLength(0) + + wrapper.find('a').simulate('click') + + expect(wrapper.find('DeleteQueueModal')).toHaveLength(1) + }) +}) diff --git a/src/components/JobTable/EditJobAction.jsx b/src/components/JobTable/EditJobAction.jsx new file mode 100644 index 000000000..0b281654a --- /dev/null +++ b/src/components/JobTable/EditJobAction.jsx @@ -0,0 +1,21 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { MenuItem } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import history from '../../services/history' + +const EditJobAction = ({ id }) => ( + history.push(`/job/${id}`)} + label={i18n.t('Edit')} + /> +) + +const { string } = PropTypes + +EditJobAction.propTypes = { + id: string.isRequired, +} + +export default EditJobAction diff --git a/src/components/JobTable/EditJobAction.test.jsx b/src/components/JobTable/EditJobAction.test.jsx new file mode 100644 index 000000000..10604a73a --- /dev/null +++ b/src/components/JobTable/EditJobAction.test.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import history from '../../services/history' +import EditJobAction from './EditJobAction.jsx' + +jest.mock('../../services/history', () => ({ + push: jest.fn(), +})) + +describe('', () => { + it('renders without errors', () => { + shallow() + }) + + it('calls history.push correctly when MenuItem is clicked', () => { + const wrapper = mount() + + wrapper.find('a').simulate('click') + + expect(history.push).toHaveBeenCalledWith('/job/id') + }) +}) diff --git a/src/components/JobTable/EditQueueAction.jsx b/src/components/JobTable/EditQueueAction.jsx new file mode 100644 index 000000000..07382a128 --- /dev/null +++ b/src/components/JobTable/EditQueueAction.jsx @@ -0,0 +1,21 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { MenuItem } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import history from '../../services/history' + +const EditQueueAction = ({ name }) => ( + history.push(`/queue/${name}`)} + label={i18n.t('Edit')} + /> +) + +const { string } = PropTypes + +EditQueueAction.propTypes = { + name: string.isRequired, +} + +export default EditQueueAction diff --git a/src/components/JobTable/EditQueueAction.test.jsx b/src/components/JobTable/EditQueueAction.test.jsx new file mode 100644 index 000000000..39414077b --- /dev/null +++ b/src/components/JobTable/EditQueueAction.test.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import history from '../../services/history' +import EditQueueAction from './EditQueueAction.jsx' + +jest.mock('../../services/history', () => ({ + push: jest.fn(), +})) + +describe('', () => { + it('renders without errors', () => { + shallow() + }) + + it('calls history.push correctly when MenuItem is clicked', () => { + const wrapper = mount() + + wrapper.find('a').simulate('click') + + expect(history.push).toHaveBeenCalledWith('/queue/name') + }) +}) diff --git a/src/components/JobTable/ExpandableRow.jsx b/src/components/JobTable/ExpandableRow.jsx new file mode 100644 index 000000000..bce1ab4bf --- /dev/null +++ b/src/components/JobTable/ExpandableRow.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import { TableRow, TableCell } from '@dhis2/ui' +import PropTypes from 'prop-types' +import { jobTypesMap } from '../../services/server-translations' +import styles from './ExpandableRow.module.css' + +const ExpandableRow = ({ job }) => { + return ( + + + + {job.name} + + + {jobTypesMap[job.type]} + + + ) +} + +const { shape, string } = PropTypes + +ExpandableRow.propTypes = { + job: shape({ + name: string.isRequired, + type: string.isRequired, + }).isRequired, +} + +export default ExpandableRow diff --git a/src/components/JobTable/JobActions.jsx b/src/components/JobTable/JobActions.jsx new file mode 100644 index 000000000..dfb079ccc --- /dev/null +++ b/src/components/JobTable/JobActions.jsx @@ -0,0 +1,44 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { FlyoutMenu, DropdownButton } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import EditJobAction from './EditJobAction.jsx' +import ViewJobAction from './ViewJobAction.jsx' +import RunJobAction from './RunJobAction.jsx' +import DeleteJobAction from './DeleteJobAction.jsx' + +const JobActions = ({ id, configurable, enabled, refetch }) => ( + + {configurable ? ( + + ) : ( + + )} + + {configurable && ( + + )} + + } + > + {i18n.t('Actions')} + +) + +JobActions.defaultProps = { + configurable: false, +} + +const { string, bool, func } = PropTypes + +JobActions.propTypes = { + id: string.isRequired, + refetch: func.isRequired, + configurable: bool, + enabled: bool, +} + +export default JobActions diff --git a/src/components/JobTable/JobActions.test.jsx b/src/components/JobTable/JobActions.test.jsx new file mode 100644 index 000000000..7f67e8a26 --- /dev/null +++ b/src/components/JobTable/JobActions.test.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobActions from './JobActions.jsx' + +describe('', () => { + it('renders without errors for configurable jobs', () => { + shallow( {}} />) + }) + + it('renders without errors for non configurable jobs', () => { + shallow( {}} />) + }) +}) diff --git a/src/components/JobTable/JobTable.jsx b/src/components/JobTable/JobTable.jsx new file mode 100644 index 000000000..b0ac6234e --- /dev/null +++ b/src/components/JobTable/JobTable.jsx @@ -0,0 +1,76 @@ +import React from 'react' +import { + Table, + TableHead, + TableRowHead, + TableRow, + TableCell, + TableCellHead, + TableBody, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import JobTableRow from './JobTableRow.jsx' +import QueueTableRow from './QueueTableRow.jsx' + +const JobTable = ({ jobsAndQueues, refetch }) => ( + + + + + {i18n.t('Name')} + {i18n.t('Type')} + {i18n.t('Schedule')} + {i18n.t('Next run')} + {i18n.t('Status')} + {i18n.t('On/off')} + + + + + {jobsAndQueues.length === 0 ? ( + + {i18n.t('No jobs to display')} + + ) : ( + jobsAndQueues.map((jobOrQueue) => { + const isValid = !!jobOrQueue?.sequence?.length + + if (!isValid) { + return null + } + + // A queue will have more than one item in .sequence + const isJob = jobOrQueue.sequence.length === 1 + + if (isJob) { + return ( + + ) + } + + return ( + + ) + }) + )} + +
+) + +const { arrayOf, object, func } = PropTypes + +JobTable.propTypes = { + jobsAndQueues: arrayOf(object).isRequired, + refetch: func.isRequired, +} + +export default JobTable diff --git a/src/components/JobTable/JobTable.test.jsx b/src/components/JobTable/JobTable.test.jsx new file mode 100644 index 000000000..ecab7e266 --- /dev/null +++ b/src/components/JobTable/JobTable.test.jsx @@ -0,0 +1,38 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobTable from './JobTable.jsx' + +describe('', () => { + it('renders without errors when there are jobs or queues', () => { + const jobsAndQueues = [ + { + id: 'lnWRZN67iDU', + name: 'Job 1', + type: 'DATA_INTEGRITY', + cronExpression: '0 0 3 ? * MON', + nextExecutionTime: '2021-03-01T03:00:00.000', + status: 'SCHEDULED', + enabled: true, + configurable: true, + sequence: [ + { + id: 'lnWRZN67iDU', + name: 'Job 1', + type: 'DATA_INTEGRITY', + cronExpression: '0 0 3 ? * MON', + nextExecutionTime: '2021-03-01T03:00:00.000', + status: 'SCHEDULED', + }, + ], + }, + ] + + shallow( {}} />) + }) + + it('renders without errors when there are no jobs or queues', () => { + const jobsAndQueues = [] + + shallow( {}} />) + }) +}) diff --git a/src/components/JobTable/JobTableRow.jsx b/src/components/JobTable/JobTableRow.jsx new file mode 100644 index 000000000..9391ad592 --- /dev/null +++ b/src/components/JobTable/JobTableRow.jsx @@ -0,0 +1,73 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { TableRow, TableCell } from '@dhis2/ui' +import { jobTypesMap } from '../../services/server-translations' +import { JobSwitch } from '../Switches' +import JobActions from './JobActions.jsx' +import Status from './Status.jsx' +import NextRun from './NextRun.jsx' +import Schedule from './Schedule' + +const JobTableRow = ({ + job: { + id, + name, + type, + cronExpression, + delay, + status, + nextExecutionTime, + enabled, + configurable, + }, + refetch, +}) => ( + + + {name} + {jobTypesMap[type]} + + + + + + + + + + + + + + + + +) + +const { shape, string, bool, number, func } = PropTypes + +JobTableRow.propTypes = { + job: shape({ + name: string.isRequired, + enabled: bool.isRequired, + id: string.isRequired, + status: string.isRequired, + type: string.isRequired, + cronExpression: string, + delay: number, + nextExecutionTime: string, + }).isRequired, + refetch: func.isRequired, +} + +export default JobTableRow diff --git a/src/components/JobTable/JobTableRow.test.jsx b/src/components/JobTable/JobTableRow.test.jsx new file mode 100644 index 000000000..01abd40c2 --- /dev/null +++ b/src/components/JobTable/JobTableRow.test.jsx @@ -0,0 +1,55 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobTableRow from './JobTableRow.jsx' + +describe('', () => { + it('renders cron jobs without errors', () => { + const job = { + id: 'lnWRZN67iDU', + name: 'Job 1', + type: 'DATA_INTEGRITY', + cronExpression: '0 0 3 ? * MON', + nextExecutionTime: '2021-03-01T03:00:00.000', + status: 'SCHEDULED', + enabled: true, + configurable: true, + sequence: [ + { + id: 'lnWRZN67iDU', + name: 'Job 1', + type: 'DATA_INTEGRITY', + cronExpression: '0 0 3 ? * MON', + nextExecutionTime: '2021-03-01T03:00:00.000', + status: 'SCHEDULED', + }, + ], + } + + shallow( {}} />) + }) + + it('renders fixed delay jobs without errors', () => { + const job = { + id: 'lnWRZN67iDU', + name: 'Job 1', + type: 'CONTINUOUS_ANALYTICS_TABLE', + delay: 6000, + nextExecutionTime: '2021-03-01T03:00:00.000', + status: 'SCHEDULED', + enabled: true, + configurable: true, + sequence: [ + { + id: 'lnWRZN67iDU', + name: 'Job 1', + type: 'CONTINUOUS_ANALYTICS_TABLE', + delay: 6000, + nextExecutionTime: '2021-03-01T03:00:00.000', + status: 'SCHEDULED', + }, + ], + } + + shallow( {}} />) + }) +}) diff --git a/src/components/JobTable/NextRun.jsx b/src/components/JobTable/NextRun.jsx new file mode 100644 index 000000000..c99797d12 --- /dev/null +++ b/src/components/JobTable/NextRun.jsx @@ -0,0 +1,58 @@ +import moment from 'moment' +import { useTimeZoneConversion } from '@dhis2/app-runtime' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { Tooltip } from '@dhis2/ui' +import React from 'react' + +const formatDate = (dhis2Date) => + `${dhis2Date + .getServerZonedISOString() + .substring(0, 19) + .split('T') + .join(' ')} (${dhis2Date.serverTimezone})` + +const NextRun = ({ nextExecutionTime, enabled }) => { + const { fromServerDate } = useTimeZoneConversion() + + if (!enabled || !nextExecutionTime) { + return '-' + } + + const now = Date.now() + + /** + * Adjust for client/sever time zone difference. + */ + const nextRun = fromServerDate(nextExecutionTime) + const nextRunIsInPast = nextRun.getTime() <= now + + /** + * If the nextExecutionTime is in the past that means that + * the scheduled execution time has passed, but the allowed + * startup delay hasn't expired yet. Effectively this means + * that the backend will start the job as soon as possible. + * + * If the window expires before the job can execute the + * nextExecutionTime will be updated to a time in the future. + */ + + if (nextRunIsInPast) { + return i18n.t('Now') + } + + return ( + + {formatDate(nextRun)} + + ) +} + +const { bool, string } = PropTypes + +NextRun.propTypes = { + enabled: bool.isRequired, + nextExecutionTime: string, +} + +export default NextRun diff --git a/src/components/JobTable/NextRun.test.jsx b/src/components/JobTable/NextRun.test.jsx new file mode 100644 index 000000000..410317402 --- /dev/null +++ b/src/components/JobTable/NextRun.test.jsx @@ -0,0 +1,71 @@ +import React from 'react' +import { mount, shallow } from 'enzyme' +import { Tooltip } from '@dhis2/ui' +import NextRun from './NextRun.jsx' + +// Z is the zone designator for the zero UTC offset +const now = new Date('2010-10-10T10:10:10.000Z').valueOf() + +const past = '2009-10-10T10:10:10.000' +const future = '2011-10-10T10:10:10.000' +const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + +// in this test, we are only passing timestamps to the hook (and there is no server/client time diff) +// so mock getServerZonedISOString by returning the original timestamp +jest.mock('@dhis2/app-runtime', () => ({ + useTimeZoneConversion: () => ({ + fromServerDate: (timestamp) => { + const dhis2Date = new Date(timestamp) + dhis2Date.getServerZonedISOString = () => timestamp + dhis2Date.serverTimezone = + Intl.DateTimeFormat().resolvedOptions().timeZone + return dhis2Date + }, + }), +})) + +describe('', () => { + it('returns the next run time for an enabled job and a future execution time', () => { + const expected = `2011-10-10 10:10:10 (${timeZone})` + jest.spyOn(global.Date, 'now').mockImplementation(() => now) + + const wrapper = mount( + + ) + + expect(wrapper.contains(expected)).toEqual(true) + }) + + it('returns the relative time in tooltip for an enabled job and a future execution time', () => { + const expected = `in a year` + jest.spyOn(global.Date, 'now').mockImplementation(() => now) + + const wrapper = shallow( + + ) + + expect(wrapper.find(Tooltip).prop('content')).toEqual(expected) + }) + + it('returns message for an enabled job and a past execution time', () => { + const expected = 'Now' + jest.spyOn(global.Date, 'now').mockImplementation(() => now) + + const wrapper = shallow( + + ) + + expect(wrapper.text()).toEqual(expect.stringMatching(expected)) + }) + + it('returns fallback message for a disabled job', () => { + const expected = '-' + jest.spyOn(global.Date, 'now').mockImplementation(() => now) + + const wrapper = shallow( + + ) + + expect(wrapper.text()).toEqual(expect.stringMatching(expected)) + }) +}) diff --git a/src/components/JobTable/QueueActions.jsx b/src/components/JobTable/QueueActions.jsx new file mode 100644 index 000000000..f42beba5b --- /dev/null +++ b/src/components/JobTable/QueueActions.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { FlyoutMenu, DropdownButton } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import EditQueueAction from './EditQueueAction.jsx' +import RunQueueAction from './RunQueueAction.jsx' +import DeleteQueueAction from './DeleteQueueAction.jsx' + +const QueueActions = ({ name, refetch, id, enabled }) => ( + + + + + + } + > + {i18n.t('Actions')} + +) + +const { string, func, bool } = PropTypes + +QueueActions.propTypes = { + enabled: bool.isRequired, + id: string.isRequired, + name: string.isRequired, + refetch: func.isRequired, +} + +export default QueueActions diff --git a/src/components/JobTable/QueueActions.test.jsx b/src/components/JobTable/QueueActions.test.jsx new file mode 100644 index 000000000..d06bec4b0 --- /dev/null +++ b/src/components/JobTable/QueueActions.test.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import { shallow } from 'enzyme' +import QueueActions from './QueueActions.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow( {}} id="1" enabled />) + }) +}) diff --git a/src/components/JobTable/QueueTableRow.jsx b/src/components/JobTable/QueueTableRow.jsx new file mode 100644 index 000000000..015adbd95 --- /dev/null +++ b/src/components/JobTable/QueueTableRow.jsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { + TableRow, + TableCell, + IconChevronDown24, + IconChevronUp24, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { JobSwitch } from '../Switches' +import QueueActions from './QueueActions.jsx' +import Status from './Status.jsx' +import NextRun from './NextRun.jsx' +import Schedule from './Schedule' +import ExpandableRow from './ExpandableRow.jsx' +import styles from './QueueTableRow.module.css' + +const QueueTableRow = ({ + queue: { + id, + name, + cronExpression, + status, + nextExecutionTime, + enabled, + configurable, + sequence, + }, + refetch, +}) => { + const [showJobs, setShowJobs] = useState(false) + const handleClick = () => setShowJobs((prev) => !prev) + + return ( + <> + + + + + {name} + {i18n.t('Queue')} + + + + + + + + + + + {/* A queue can be toggled by toggling the first job in the queue */} + + + + + + + {showJobs + ? sequence.map((job) => ( + + )) + : null} + + ) +} + +const { shape, string, bool, func, arrayOf, object } = PropTypes + +QueueTableRow.propTypes = { + queue: shape({ + name: string.isRequired, + enabled: bool.isRequired, + id: string.isRequired, + status: string.isRequired, + cronExpression: string, + nextExecutionTime: string, + sequence: arrayOf(object).isRequired, + }).isRequired, + refetch: func.isRequired, +} + +export default QueueTableRow diff --git a/src/components/JobTable/QueueTableRow.test.jsx b/src/components/JobTable/QueueTableRow.test.jsx new file mode 100644 index 000000000..9b8f1695a --- /dev/null +++ b/src/components/JobTable/QueueTableRow.test.jsx @@ -0,0 +1,29 @@ +import React from 'react' +import { shallow } from 'enzyme' +import QueueTableRow from './QueueTableRow.jsx' + +describe('', () => { + it('renders queues without errors', () => { + const queue = { + id: 'lnWRZN67iDU', + name: 'Queue 1', + cronExpression: '0 0 3 ? * MON', + nextExecutionTime: '2021-03-01T03:00:00.000', + status: 'SCHEDULED', + enabled: true, + configurable: true, + sequence: [ + { + id: 'lnWRZN67iDU', + name: 'Job 1', + type: 'DATA_INTEGRITY', + cronExpression: '0 0 3 ? * MON', + nextExecutionTime: '2021-03-01T03:00:00.000', + status: 'SCHEDULED', + }, + ], + } + + shallow( {}} />) + }) +}) diff --git a/src/components/JobTable/RunJobAction.jsx b/src/components/JobTable/RunJobAction.jsx new file mode 100644 index 000000000..bab5d22e0 --- /dev/null +++ b/src/components/JobTable/RunJobAction.jsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { MenuItem } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { RunJobModal } from '../Modal' + +const RunJobAction = ({ id, enabled, onComplete }) => { + const [showModal, setShowModal] = useState(false) + + return ( + + { + setShowModal(true) + }} + disabled={!enabled} + label={i18n.t('Run manually')} + /> + {showModal && ( + setShowModal(false) + } + onComplete={onComplete} + /> + )} + + ) +} + +const { string, bool, func } = PropTypes + +RunJobAction.propTypes = { + id: string.isRequired, + onComplete: func.isRequired, + enabled: bool, +} + +export default RunJobAction diff --git a/src/components/JobTable/RunJobAction.test.jsx b/src/components/JobTable/RunJobAction.test.jsx new file mode 100644 index 000000000..e57a57162 --- /dev/null +++ b/src/components/JobTable/RunJobAction.test.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import { mount } from 'enzyme' +import RunJobAction from './RunJobAction.jsx' + +describe('', () => { + it('shows the modal when MenuItem is clicked and the job is enabled', () => { + const wrapper = mount( + {}} /> + ) + + expect(wrapper.find('RunJobModal')).toHaveLength(0) + wrapper.find('a').simulate('click') + expect(wrapper.find('RunJobModal')).toHaveLength(1) + }) + + it('does not show the modal when MenuItem is clicked and the job is disabled', () => { + const wrapper = mount( {}} />) + + expect(wrapper.find('RunJobModal')).toHaveLength(0) + wrapper.find('a').simulate('click') + expect(wrapper.find('RunJobModal')).toHaveLength(0) + }) +}) diff --git a/src/components/JobTable/RunQueueAction.jsx b/src/components/JobTable/RunQueueAction.jsx new file mode 100644 index 000000000..e4848593b --- /dev/null +++ b/src/components/JobTable/RunQueueAction.jsx @@ -0,0 +1,43 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { MenuItem } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { RunJobModal } from '../Modal' + +const RunQueueAction = ({ id, enabled, onComplete }) => { + const [showModal, setShowModal] = useState(false) + + return ( + + { + setShowModal(true) + }} + disabled={!enabled} + label={i18n.t('Run manually')} + /> + {showModal && ( + setShowModal(false) + } + onComplete={onComplete} + isQueue + /> + )} + + ) +} + +const { string, bool, func } = PropTypes + +RunQueueAction.propTypes = { + id: string.isRequired, + onComplete: func.isRequired, + enabled: bool, +} + +export default RunQueueAction diff --git a/src/components/JobTable/RunQueueAction.test.jsx b/src/components/JobTable/RunQueueAction.test.jsx new file mode 100644 index 000000000..5d506aa85 --- /dev/null +++ b/src/components/JobTable/RunQueueAction.test.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import { mount } from 'enzyme' +import RunQueueAction from './RunQueueAction.jsx' + +describe('', () => { + it('shows the modal when MenuItem is clicked and the queue is enabled', () => { + const wrapper = mount( + {}} /> + ) + + expect(wrapper.find('RunJobModal')).toHaveLength(0) + wrapper.find('a').simulate('click') + expect(wrapper.find('RunJobModal')).toHaveLength(1) + }) + + it('does not show the modal when MenuItem is clicked and the queue is disabled', () => { + const wrapper = mount( {}} />) + + expect(wrapper.find('RunJobModal')).toHaveLength(0) + wrapper.find('a').simulate('click') + expect(wrapper.find('RunJobModal')).toHaveLength(0) + }) +}) diff --git a/src/components/JobTable/Schedule.test.jsx b/src/components/JobTable/Schedule.test.jsx new file mode 100644 index 000000000..e89119e2a --- /dev/null +++ b/src/components/JobTable/Schedule.test.jsx @@ -0,0 +1,29 @@ +import React from 'react' +import { shallow } from 'enzyme' +import Schedule from './Schedule' + +describe('', () => { + it('renders a human readable cron for the CRON scheduling type', () => { + const wrapper = shallow( + + ) + + expect(wrapper.text()).toEqual(expect.stringContaining('At 01:00 AM')) + }) + + it('renders a delay for the FIXED_DELAY scheduling type', () => { + const wrapper = shallow( + + ) + + expect(wrapper.text()).toEqual( + expect.stringContaining('1000 seconds after last run') + ) + }) + + it('renders a dash for an unrecognised scheduling type', () => { + const wrapper = shallow() + + expect(wrapper.text()).toEqual(expect.stringContaining('-')) + }) +}) diff --git a/src/components/JobTable/Status.jsx b/src/components/JobTable/Status.jsx new file mode 100644 index 000000000..0a9abce8c --- /dev/null +++ b/src/components/JobTable/Status.jsx @@ -0,0 +1,31 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Tag } from '@dhis2/ui' +import { jobStatusMap } from '../../services/server-translations' + +const Status = ({ status }) => { + switch (status) { + case 'STOPPED': + case 'DISABLED': + return {jobStatusMap[status]} + case 'RUNNING': + case 'NOT_STARTED': + case 'SCHEDULED': + return {jobStatusMap[status]} + case 'FAILED': + return {jobStatusMap[status]} + case 'DONE': + return {jobStatusMap[status]} + // Unrecognised status + default: + return null + } +} + +const { string } = PropTypes + +Status.propTypes = { + status: string.isRequired, +} + +export default Status diff --git a/src/components/JobTable/Status.test.jsx b/src/components/JobTable/Status.test.jsx new file mode 100644 index 000000000..c150779e1 --- /dev/null +++ b/src/components/JobTable/Status.test.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import { shallow } from 'enzyme' +import Status from './Status.jsx' + +const statuses = [ + 'STOPPED', + 'DISABLED', + 'RUNNING', + 'NOT_STARTED', + 'SCHEDULED', + 'FAILED', + 'DONE', +] + +describe('', () => { + it.each(statuses)('renders without errors for %s status', (status) => { + shallow() + }) + + it('returns null for an invalid status', () => { + const wrapper = shallow() + + expect(wrapper.isEmptyRender()).toBe(true) + }) +}) diff --git a/src/components/JobTable/ViewJobAction.jsx b/src/components/JobTable/ViewJobAction.jsx new file mode 100644 index 000000000..b8a39f800 --- /dev/null +++ b/src/components/JobTable/ViewJobAction.jsx @@ -0,0 +1,21 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { MenuItem } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import history from '../../services/history' + +const ViewJobAction = ({ id }) => ( + history.push(`/job/${id}`)} + label={i18n.t('View')} + /> +) + +const { string } = PropTypes + +ViewJobAction.propTypes = { + id: string.isRequired, +} + +export default ViewJobAction diff --git a/src/components/JobTable/ViewJobAction.test.jsx b/src/components/JobTable/ViewJobAction.test.jsx new file mode 100644 index 000000000..43f44ccc7 --- /dev/null +++ b/src/components/JobTable/ViewJobAction.test.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import history from '../../services/history' +import ViewJobAction from './ViewJobAction.jsx' + +jest.mock('../../services/history', () => ({ + push: jest.fn(), +})) + +describe('', () => { + it('renders without errors', () => { + shallow() + }) + + it('calls history.push correctly when MenuItem is clicked', () => { + const wrapper = mount() + + wrapper.find('a').simulate('click') + + expect(history.push).toHaveBeenCalledWith('/job/id') + }) +}) diff --git a/src/components/LinkButton/LinkButton.jsx b/src/components/LinkButton/LinkButton.jsx new file mode 100644 index 000000000..b822eeedf --- /dev/null +++ b/src/components/LinkButton/LinkButton.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import PropTypes from 'prop-types' +import cx from 'classnames' +import { Link } from 'react-router-dom' +import styles from './LinkButton.module.css' + +const LinkButton = ({ className, small, ...rest }) => { + return ( + + ) +} + +LinkButton.propTypes = { + className: PropTypes.string, + small: PropTypes.bool, +} + +export default LinkButton diff --git a/src/components/Modal/CronPresetModal.jsx b/src/components/Modal/CronPresetModal.jsx new file mode 100644 index 000000000..3c62bbf7c --- /dev/null +++ b/src/components/Modal/CronPresetModal.jsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { + Button, + Modal, + ModalTitle, + ModalContent, + ModalActions, + ButtonStrip, + Radio, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' + +const cronPresets = [ + { + label: i18n.t('Every hour'), + value: '0 0 * ? * *', + }, + { + label: i18n.t('Every day at midnight'), + value: '0 0 0 ? * *', + }, + { + label: i18n.t('Every day at 3 am'), + value: '0 0 3 ? * *', + }, + { + label: i18n.t('Every day at noon'), + value: '0 0 12 ? * *', + }, + { + label: i18n.t('Every week'), + value: '0 0 3 ? * MON', + }, +] + +const CronPresetModal = ({ setCron, hideModal }) => { + const [currentPreset, setCurrentPreset] = useState('') + + return ( + + {i18n.t('Choose a preset time/interval')} + + {cronPresets.map((preset) => ( + setCurrentPreset(value)} + /> + ))} + + + + + + + + + ) +} + +const { func } = PropTypes + +CronPresetModal.propTypes = { + hideModal: func.isRequired, + setCron: func.isRequired, +} + +export default CronPresetModal diff --git a/src/components/Modal/CronPresetModal.test.jsx b/src/components/Modal/CronPresetModal.test.jsx new file mode 100644 index 000000000..44d5e0586 --- /dev/null +++ b/src/components/Modal/CronPresetModal.test.jsx @@ -0,0 +1,59 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import CronPresetModal from './CronPresetModal.jsx' + +describe('', () => { + it('renders without errors', () => { + const props = { + hideModal: () => {}, + setCron: () => {}, + } + + shallow() + }) + + it('calls hideModal when cancel button is clicked', () => { + const props = { + hideModal: jest.fn(), + setCron: () => {}, + } + const wrapper = mount() + + wrapper.find('button').find({ name: 'hide-modal' }).simulate('click') + + expect(props.hideModal).toHaveBeenCalled() + }) + + it('calls setCron and hideModal when a value is selected and insert preset button is clicked', () => { + // Value from the presets in CronPresetModal, the test will break if this value does not exist + const value = '0 0 3 ? * MON' + const props = { + hideModal: jest.fn(), + setCron: jest.fn(), + } + const wrapper = mount() + + wrapper + .find('input') + .find({ value }) + .simulate('change', { target: { value } }) + + wrapper.find('button').find({ name: 'insert-preset' }).simulate('click') + + expect(props.setCron).toHaveBeenCalledWith(value) + expect(props.hideModal).toHaveBeenCalled() + }) + + it('calls hideModal when backdrop is clicked', () => { + const props = { + hideModal: jest.fn(), + setCron: () => {}, + } + const wrapper = mount() + + // Not a stable selector, but the backdrop does not have a data-test attribute + wrapper.find('.backdrop').simulate('click') + + expect(props.hideModal).toHaveBeenCalled() + }) +}) diff --git a/src/components/Modal/DeleteJobModal.jsx b/src/components/Modal/DeleteJobModal.jsx new file mode 100644 index 000000000..55dadbd18 --- /dev/null +++ b/src/components/Modal/DeleteJobModal.jsx @@ -0,0 +1,58 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { + Button, + Modal, + ModalContent, + ModalActions, + ButtonStrip, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { useDataMutation } from '@dhis2/app-runtime' + +const mutation = { + resource: 'jobConfigurations', + id: ({ id }) => id, + type: 'delete', +} + +const DeleteJobModal = ({ id, hideModal, onSuccess }) => { + const [deleteJob] = useDataMutation(mutation) + + return ( + + + {i18n.t('Are you sure you want to delete this job?')} + + + + + + + + + ) +} + +const { func, string } = PropTypes + +DeleteJobModal.propTypes = { + hideModal: func.isRequired, + id: string.isRequired, + onSuccess: func.isRequired, +} + +export default DeleteJobModal diff --git a/src/components/Modal/DeleteJobModal.test.jsx b/src/components/Modal/DeleteJobModal.test.jsx new file mode 100644 index 000000000..477cdb8d8 --- /dev/null +++ b/src/components/Modal/DeleteJobModal.test.jsx @@ -0,0 +1,81 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import { useDataMutation } from '@dhis2/app-runtime' +import DeleteJobModal from './DeleteJobModal.jsx' + +jest.mock('@dhis2/app-runtime', () => ({ + useDataMutation: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('renders without errors', () => { + useDataMutation.mockImplementation(() => [() => {}]) + + const props = { + id: 'id', + hideModal: () => {}, + onSuccess: () => {}, + } + + shallow() + }) + + it('calls hideModal when cancel button is clicked', () => { + useDataMutation.mockImplementation(() => [() => {}]) + + const props = { + id: 'id', + hideModal: jest.fn(), + onSuccess: () => {}, + } + const wrapper = mount() + + wrapper.find('button').find({ name: 'hide-modal' }).simulate('click') + + expect(props.hideModal).toHaveBeenCalled() + }) + + it('calls deleteJob, onSuccess and hideModal when delete button is clicked', async () => { + const deletion = Promise.resolve() + const deleteJobSpy = jest.fn(() => deletion) + const onSuccessSpy = jest.fn(() => {}) + const hideModalSpy = jest.fn(() => {}) + const props = { + id: 'id', + hideModal: hideModalSpy, + onSuccess: onSuccessSpy, + } + + useDataMutation.mockImplementation(() => [deleteJobSpy]) + + const wrapper = mount() + + wrapper.find('button').find({ name: 'delete-job-id' }).simulate('click') + + await deletion + + expect(deleteJobSpy).toHaveBeenCalledWith({ id: 'id' }) + expect(hideModalSpy).toHaveBeenCalled() + expect(onSuccessSpy).toHaveBeenCalled() + }) + + it('calls hideModal when backdrop is clicked', () => { + useDataMutation.mockImplementation(() => [() => {}]) + + const props = { + id: 'id', + hideModal: jest.fn(), + onSuccess: () => {}, + } + const wrapper = mount() + + // Not a stable selector, but the backdrop does not have a data-test attribute + wrapper.find('.backdrop').simulate('click') + + expect(props.hideModal).toHaveBeenCalled() + }) +}) diff --git a/src/components/Modal/DeleteQueueModal.jsx b/src/components/Modal/DeleteQueueModal.jsx new file mode 100644 index 000000000..fa498e01f --- /dev/null +++ b/src/components/Modal/DeleteQueueModal.jsx @@ -0,0 +1,58 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { + Button, + Modal, + ModalContent, + ModalActions, + ButtonStrip, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { useDataMutation } from '@dhis2/app-runtime' + +const mutation = { + resource: 'scheduler/queues', + id: ({ name }) => name, + type: 'delete', +} + +const DeleteQueueModal = ({ name, hideModal, onSuccess }) => { + const [deleteQueue] = useDataMutation(mutation) + + return ( + + + {i18n.t('Are you sure you want to delete this queue?')} + + + + + + + + + ) +} + +const { func, string } = PropTypes + +DeleteQueueModal.propTypes = { + hideModal: func.isRequired, + name: string.isRequired, + onSuccess: func.isRequired, +} + +export default DeleteQueueModal diff --git a/src/components/Modal/DeleteQueueModal.test.jsx b/src/components/Modal/DeleteQueueModal.test.jsx new file mode 100644 index 000000000..41a53f5b4 --- /dev/null +++ b/src/components/Modal/DeleteQueueModal.test.jsx @@ -0,0 +1,84 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import { useDataMutation } from '@dhis2/app-runtime' +import DeleteQueueModal from './DeleteQueueModal.jsx' + +jest.mock('@dhis2/app-runtime', () => ({ + useDataMutation: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('renders without errors', () => { + useDataMutation.mockImplementation(() => [() => {}]) + + const props = { + name: 'name', + hideModal: () => {}, + onSuccess: () => {}, + } + + shallow() + }) + + it('calls hideModal when cancel button is clicked', () => { + useDataMutation.mockImplementation(() => [() => {}]) + + const props = { + name: 'name', + hideModal: jest.fn(), + onSuccess: () => {}, + } + const wrapper = mount() + + wrapper.find('button').find({ name: 'hide-modal' }).simulate('click') + + expect(props.hideModal).toHaveBeenCalled() + }) + + it('calls deleteQueue, onSuccess and hideModal when delete button is clicked', async () => { + const deletion = Promise.resolve() + const deleteQueueSpy = jest.fn(() => deletion) + const onSuccessSpy = jest.fn(() => {}) + const hideModalSpy = jest.fn(() => {}) + const props = { + name: 'name', + hideModal: hideModalSpy, + onSuccess: onSuccessSpy, + } + + useDataMutation.mockImplementation(() => [deleteQueueSpy]) + + const wrapper = mount() + + wrapper + .find('button') + .find({ name: 'delete-queue-name' }) + .simulate('click') + + await deletion + + expect(deleteQueueSpy).toHaveBeenCalled() + expect(hideModalSpy).toHaveBeenCalled() + expect(onSuccessSpy).toHaveBeenCalled() + }) + + it('calls hideModal when backdrop is clicked', () => { + useDataMutation.mockImplementation(() => [() => {}]) + + const props = { + name: 'name', + hideModal: jest.fn(), + onSuccess: () => {}, + } + const wrapper = mount() + + // Not a stable selector, but the backdrop does not have a data-test attribute + wrapper.find('.backdrop').simulate('click') + + expect(props.hideModal).toHaveBeenCalled() + }) +}) diff --git a/src/components/Modal/DiscardFormModal.jsx b/src/components/Modal/DiscardFormModal.jsx new file mode 100644 index 000000000..1984970de --- /dev/null +++ b/src/components/Modal/DiscardFormModal.jsx @@ -0,0 +1,52 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { + Button, + Modal, + ModalTitle, + ModalContent, + ModalActions, + ButtonStrip, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import history from '../../services/history' + +const DiscardFormModal = ({ hideModal }) => ( + + {i18n.t('Discard unsaved changes?')} + + {i18n.t( + 'This form has unsaved changes. Are you sure that you want to discard them?' + )} + + + + + + + + +) + +const { func } = PropTypes + +DiscardFormModal.propTypes = { + hideModal: func.isRequired, +} + +export default DiscardFormModal diff --git a/src/components/Modal/DiscardFormModal.test.jsx b/src/components/Modal/DiscardFormModal.test.jsx new file mode 100644 index 000000000..96b2673ca --- /dev/null +++ b/src/components/Modal/DiscardFormModal.test.jsx @@ -0,0 +1,50 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import history from '../../services/history' +import DiscardFormModal from './DiscardFormModal.jsx' + +jest.mock('../../services/history', () => ({ + push: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('renders without errors', () => { + shallow( {}} />) + }) + + it('calls hideModal when cancel button is clicked', () => { + const spy = jest.fn() + const wrapper = mount() + + wrapper + .find('button') + .find({ name: 'cancel-discard-form' }) + .simulate('click') + + expect(spy).toHaveBeenCalled() + }) + + it('calls history push and hideModal when discard button is clicked', () => { + const spy = jest.fn() + const wrapper = mount() + + wrapper.find('button').find({ name: 'discard-form' }).simulate('click') + + expect(history.push).toHaveBeenCalledWith('/') + expect(spy).toHaveBeenCalled() + }) + + it('calls hideModal when backdrop is clicked', () => { + const spy = jest.fn() + const wrapper = mount() + + // Not a stable selector, but the backdrop does not have a data-test attribute + wrapper.find('.backdrop').simulate('click') + + expect(spy).toHaveBeenCalled() + }) +}) diff --git a/src/components/Modal/RunJobModal.jsx b/src/components/Modal/RunJobModal.jsx new file mode 100644 index 000000000..36303f523 --- /dev/null +++ b/src/components/Modal/RunJobModal.jsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { useDataMutation } from '@dhis2/app-runtime' +import { + Button, + Modal, + ModalContent, + ModalActions, + ButtonStrip, + NoticeBox, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' + +const messages = { + error: { + job: i18n.t('Error running job'), + queue: i18n.t('Error running queue'), + }, + confirm: { + job: i18n.t('Are you sure you want to run this job?'), + queue: i18n.t('Are you sure you want to run this queue?'), + }, +} + +/** + * This modal can be used to trigger a job or a queue. To start + * a queue, pass the id of the first job of that queue. + */ + +const RunJobModal = ({ id, hideModal, onComplete, isQueue }) => { + const [mutation] = useState({ + resource: `jobConfigurations/${id}/execute`, + type: 'create', + }) + const [runJob, { loading, error }] = useDataMutation(mutation, { + onComplete: () => { + hideModal() + onComplete() + }, + }) + const errorTitle = isQueue ? messages.error.queue : messages.error.job + const confirmation = isQueue ? messages.confirm.queue : messages.confirm.job + + return ( + + + {error && ( + + {error.message} + + )} +

{confirmation}

+
+ + + + + + +
+ ) +} + +const { func, string, bool } = PropTypes + +RunJobModal.propTypes = { + hideModal: func.isRequired, + id: string.isRequired, + onComplete: func.isRequired, + isQueue: bool, +} + +export default RunJobModal diff --git a/src/components/Modal/RunJobModal.test.jsx b/src/components/Modal/RunJobModal.test.jsx new file mode 100644 index 000000000..7baa1bcf7 --- /dev/null +++ b/src/components/Modal/RunJobModal.test.jsx @@ -0,0 +1,97 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' +import { useDataMutation } from '@dhis2/app-runtime' +import RunJobModal from './RunJobModal.jsx' + +jest.mock('@dhis2/app-runtime', () => ({ + useDataMutation: jest.fn(), +})) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('', () => { + it('renders without errors', () => { + useDataMutation.mockImplementation(() => [ + jest.fn(), + { loading: false, error: null }, + ]) + + const props = { + id: 'id', + hideModal: () => {}, + onComplete: () => {}, + } + + shallow() + }) + + it('calls hideModal when cancel button is clicked', () => { + useDataMutation.mockImplementation(() => [ + jest.fn(), + { loading: false, error: null }, + ]) + + const props = { + id: 'id', + hideModal: jest.fn(), + onComplete: () => {}, + } + const wrapper = mount() + + wrapper.find('button').find({ name: 'hide-modal' }).simulate('click') + + expect(props.hideModal).toHaveBeenCalled() + }) + + it('calls hideModal when backdrop is clicked', () => { + useDataMutation.mockImplementation(() => [ + jest.fn(), + { loading: false, error: null }, + ]) + + const props = { + id: 'id', + hideModal: jest.fn(), + onComplete: () => {}, + } + const wrapper = mount() + + // Not a stable selector, but the backdrop does not have a data-test attribute + wrapper.find('.backdrop').simulate('click') + + expect(props.hideModal).toHaveBeenCalled() + }) + + it('runs the expected tasks after a click on run job', async () => { + const resolvedPromise = Promise.resolve() + const onCompleteSpy = jest.fn() + const hideModalSpy = jest.fn() + const mutateSpy = jest.fn(() => resolvedPromise) + let onComplete + useDataMutation.mockImplementation((mutation, options) => { + onComplete = options.onComplete + return [mutateSpy, { loading: false, error: null }] + }) + + const props = { + id: 'id', + hideModal: hideModalSpy, + onComplete: onCompleteSpy, + } + const wrapper = mount() + + wrapper + .find('button') + .find({ name: 'run-job-id', type: 'button' }) + .simulate('click') + + await resolvedPromise + expect(mutateSpy).toHaveBeenCalled() + + onComplete() + expect(hideModalSpy).toHaveBeenCalled() + expect(onCompleteSpy).toHaveBeenCalled() + }) +}) diff --git a/src/components/PageWrapper/PageWrapper.jsx b/src/components/PageWrapper/PageWrapper.jsx new file mode 100644 index 000000000..7a9578cf5 --- /dev/null +++ b/src/components/PageWrapper/PageWrapper.jsx @@ -0,0 +1,15 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from './PageWrapper.module.css' + +const PageWrapper = ({ children }) => ( +
{children}
+) + +const { node } = PropTypes + +PageWrapper.propTypes = { + children: node.isRequired, +} + +export default PageWrapper diff --git a/src/components/PageWrapper/PageWrapper.test.jsx b/src/components/PageWrapper/PageWrapper.test.jsx new file mode 100644 index 000000000..ab3e9f278 --- /dev/null +++ b/src/components/PageWrapper/PageWrapper.test.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import { shallow } from 'enzyme' +import PageWrapper from './PageWrapper.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow(Text) + }) +}) diff --git a/src/components/Routes/JobViewOrEdit.jsx b/src/components/Routes/JobViewOrEdit.jsx new file mode 100644 index 000000000..d2992e1f8 --- /dev/null +++ b/src/components/Routes/JobViewOrEdit.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import { NoticeBox } from '@dhis2/ui' +import { useParams } from 'react-router-dom' +import i18n from '@dhis2/d2-i18n' +import { useJobById } from '../../hooks/jobs' +import { Spinner } from '../Spinner' +import JobEdit from '../../pages/JobEdit' +import JobView from '../../pages/JobView' + +const JobViewOrEdit = () => { + const { id } = useParams() + const { data, fetching, error } = useJobById(id) + + if (fetching) { + return + } + + if (error) { + return ( + + {i18n.t( + 'Something went wrong whilst loading the requested job. Make sure it has not been deleted and try refreshing the page.' + )} + + ) + } + + const { configurable } = data + + if (configurable) { + return + } else { + return + } +} + +export default JobViewOrEdit diff --git a/src/components/Routes/JobViewOrEdit.test.jsx b/src/components/Routes/JobViewOrEdit.test.jsx new file mode 100644 index 000000000..d9ab5fed5 --- /dev/null +++ b/src/components/Routes/JobViewOrEdit.test.jsx @@ -0,0 +1,88 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { useParams } from 'react-router-dom' +import { useJobById } from '../../hooks/jobs' +import JobViewOrEdit from './JobViewOrEdit.jsx' + +jest.mock('react-router-dom', () => ({ + useParams: jest.fn(), +})) + +jest.mock('../../hooks/jobs', () => ({ + useJobById: jest.fn(), +})) + +describe('', () => { + it('renders a spinner when loading the requested job', () => { + const id = 'id' + + useParams.mockImplementation(() => id) + useJobById.mockImplementation(() => ({ fetching: true })) + + const wrapper = shallow() + const spinner = wrapper.find('Spinner') + + expect(spinner).toHaveLength(1) + }) + + it('renders errors encountered during fetching', () => { + const id = 'id' + + useParams.mockImplementation(() => id) + useJobById.mockImplementation(() => ({ + fetching: false, + error: new Error('Something went wrong'), + })) + + const wrapper = shallow() + const noticebox = wrapper.find('NoticeBox') + + expect(noticebox).toHaveLength(1) + }) + + it('renders JobEdit if the job is configurable', () => { + const id = 'id' + + useParams.mockImplementation(() => id) + useJobById.mockImplementation(() => ({ + fetching: false, + error: undefined, + data: { + name: 'name', + created: '', + lastExecutedStatus: '', + lastExecuted: '', + configurable: true, + }, + })) + + const wrapper = shallow() + const component = wrapper.find('JobEdit') + + expect(component).toHaveLength(1) + }) + + it('renders JobView if the job is not configurable', () => { + const id = 'id' + + useParams.mockImplementation(() => id) + useJobById.mockImplementation(() => ({ + fetching: false, + error: undefined, + data: { + name: 'name', + created: '', + lastExecutedStatus: '', + lastExecuted: '', + jobType: '', + cronExpression: '', + configurable: false, + }, + })) + + const wrapper = shallow() + const component = wrapper.find('JobView') + + expect(component).toHaveLength(1) + }) +}) diff --git a/src/components/Routes/Routes.jsx b/src/components/Routes/Routes.jsx new file mode 100644 index 000000000..14baffa97 --- /dev/null +++ b/src/components/Routes/Routes.jsx @@ -0,0 +1,26 @@ +import React from 'react' +import { Route, Switch, Redirect } from 'react-router-dom' +import { Router } from 'react-router' +import JobAndQueueList from '../../pages/JobAndQueueList' +import JobAdd from '../../pages/JobAdd' +import QueueAdd from '../../pages/QueueAdd' +import QueueEdit from '../../pages/QueueEdit' +import history from '../../services/history' +import JobViewOrEdit from './JobViewOrEdit.jsx' + +const Routes = () => ( + + + + + + + + + + + + +) + +export default Routes diff --git a/src/components/Routes/Routes.test.jsx b/src/components/Routes/Routes.test.jsx new file mode 100644 index 000000000..8d045328f --- /dev/null +++ b/src/components/Routes/Routes.test.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import { shallow } from 'enzyme' +import Routes from './Routes.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow() + }) +}) diff --git a/src/components/Spinner/Spinner.jsx b/src/components/Spinner/Spinner.jsx new file mode 100644 index 000000000..70040e6d9 --- /dev/null +++ b/src/components/Spinner/Spinner.jsx @@ -0,0 +1,14 @@ +import React from 'react' +import { CircularLoader, Layer, CenteredContent } from '@dhis2/ui' + +const Spinner = () => { + return ( + + + + + + ) +} + +export default Spinner diff --git a/src/components/Store/Store.jsx b/src/components/Store/Store.jsx new file mode 100644 index 000000000..a912969e0 --- /dev/null +++ b/src/components/Store/Store.jsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import StoreContext from './StoreContext' + +const Store = ({ children }) => { + // State that should persist + const nameFilterState = useState('') + const showSystemJobsState = useState(false) + + return ( + + {children} + + ) +} + +const { node } = PropTypes + +Store.propTypes = { + children: node.isRequired, +} + +export default Store diff --git a/src/components/Store/Store.test.jsx b/src/components/Store/Store.test.jsx new file mode 100644 index 000000000..d6a892b40 --- /dev/null +++ b/src/components/Store/Store.test.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import { shallow } from 'enzyme' +import Store from './Store.jsx' + +describe('', () => { + it('renders the children', () => { + const wrapper = shallow(Child) + + expect(wrapper.text()).toEqual(expect.stringContaining('Child')) + }) +}) diff --git a/src/components/Store/hooks.test.jsx b/src/components/Store/hooks.test.jsx new file mode 100644 index 000000000..b92405e1c --- /dev/null +++ b/src/components/Store/hooks.test.jsx @@ -0,0 +1,38 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { useNameFilter, useShowSystemJobs } from './hooks' +import StoreContext from './StoreContext' + +describe('useNameFilter', () => { + it('should return the nameFilter part of the store', () => { + const nameFilter = 'nameFilter' + const store = { + nameFilter, + } + const wrapper = ({ children }) => ( + + {children} + + ) + const { result } = renderHook(() => useNameFilter(), { wrapper }) + + expect(result.current).toBe(nameFilter) + }) +}) + +describe('useShowSystemJobs', () => { + it('should return the showSystemJobs part of the store', () => { + const showSystemJobs = 'showSystemJobs' + const store = { + showSystemJobs, + } + const wrapper = ({ children }) => ( + + {children} + + ) + const { result } = renderHook(() => useShowSystemJobs(), { wrapper }) + + expect(result.current).toBe(showSystemJobs) + }) +}) diff --git a/src/components/Switches/JobSwitch.jsx b/src/components/Switches/JobSwitch.jsx new file mode 100644 index 000000000..61110bc8d --- /dev/null +++ b/src/components/Switches/JobSwitch.jsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { useDataMutation } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { Switch } from '@dhis2/ui' + +const JobSwitch = ({ id, checked, disabled, refetch }) => { + const [disableQuery] = useState({ + resource: `jobConfigurations/${id}/disable`, + type: 'create', + }) + const [enableQuery] = useState({ + resource: `jobConfigurations/${id}/enable`, + type: 'create', + }) + const [disableJob, disableMutation] = useDataMutation(disableQuery) + const [enableJob, enableMutation] = useDataMutation(enableQuery) + + const toggleJob = checked ? disableJob : enableJob + const loading = disableMutation.loading || enableMutation.loading + + return ( + { + toggleJob().then(refetch) + }} + ariaLabel={checked ? i18n.t('Disable') : i18n.t('Enable')} + /> + ) +} + +const { bool, string, func } = PropTypes + +JobSwitch.propTypes = { + checked: bool.isRequired, + disabled: bool.isRequired, + id: string.isRequired, + refetch: func.isRequired, +} + +export default JobSwitch diff --git a/src/components/Switches/JobSwitch.test.jsx b/src/components/Switches/JobSwitch.test.jsx new file mode 100644 index 000000000..2dc9296ed --- /dev/null +++ b/src/components/Switches/JobSwitch.test.jsx @@ -0,0 +1,16 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobSwitch from './JobSwitch.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow( + {}} + /> + ) + }) +}) diff --git a/src/hooks/job-types/use-job-type-parameters.test.jsx b/src/hooks/job-types/use-job-type-parameters.test.jsx new file mode 100644 index 000000000..073ea43e7 --- /dev/null +++ b/src/hooks/job-types/use-job-type-parameters.test.jsx @@ -0,0 +1,61 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useJobTypeParameters from './use-job-type-parameters' + +describe('useJobTypeParameters', () => { + it('should return the requested job parameters', async () => { + const jobParameters = 'jobParameters' + const jobType = 'match' + const data = { + 'jobConfigurations/jobTypes': { + jobTypes: [{ jobType, jobParameters }], + }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook( + () => useJobTypeParameters(jobType), + { + wrapper, + } + ) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: jobParameters, + }) + }) + }) + + it('should return an empty array if the jobType has no jobParameters', async () => { + const jobType = 'match' + const data = { + 'jobConfigurations/jobTypes': { + jobTypes: [{ jobType }], + }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook( + () => useJobTypeParameters(jobType), + { + wrapper, + } + ) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: [], + }) + }) + }) +}) diff --git a/src/hooks/job-types/use-job-type.test.jsx b/src/hooks/job-types/use-job-type.test.jsx new file mode 100644 index 000000000..a5d4f0ae1 --- /dev/null +++ b/src/hooks/job-types/use-job-type.test.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useJobType from './use-job-type' + +describe('useJobType', () => { + it('should return the requested job type', async () => { + const jobType = 'match' + const job = { jobType } + const data = { + 'jobConfigurations/jobTypes': { + jobTypes: [{ jobType: 'nomatch' }, job], + }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobType(jobType), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: job, + }) + }) + }) + + it('should return an error if the job could not be found', async () => { + const jobType = 'match' + const data = { + 'jobConfigurations/jobTypes': { + jobTypes: [{ jobType: 'nomatch' }], + }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobType(jobType), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + data: undefined, + }) + expect(result.current.error.message).toBe( + 'Job type could not be found' + ) + }) + }) + + it('should return an error if the job types are in an unexpected format', async () => { + const jobType = 'match' + const data = { + 'jobConfigurations/jobTypes': { + jobTypes: '', + }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobType(jobType), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + data: undefined, + }) + expect(result.current.error.message).toBe( + 'Did not receive the expected job types' + ) + }) + }) +}) diff --git a/src/hooks/job-types/use-job-types.test.jsx b/src/hooks/job-types/use-job-types.test.jsx new file mode 100644 index 000000000..25c856a2b --- /dev/null +++ b/src/hooks/job-types/use-job-types.test.jsx @@ -0,0 +1,51 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useJobTypes from './use-job-types' + +describe('useJobTypes', () => { + it('should return the job types without nesting', async () => { + const jobTypes = 'jobTypes' + const data = { 'jobConfigurations/jobTypes': { jobTypes } } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobTypes(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: jobTypes, + }) + }) + }) + + it('should return an error if job types are in an unexpected format', async () => { + const data = { + 'jobConfigurations/jobTypes': { + jobTypes: false, + }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobTypes(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + data: undefined, + }) + expect(result.current.error.message).toBe( + 'Did not receive the expected job types' + ) + }) + }) +}) diff --git a/src/hooks/jobs-and-queues/use-jobs-and-queues.test.jsx b/src/hooks/jobs-and-queues/use-jobs-and-queues.test.jsx new file mode 100644 index 000000000..2656eb483 --- /dev/null +++ b/src/hooks/jobs-and-queues/use-jobs-and-queues.test.jsx @@ -0,0 +1,116 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useJobsAndQueues from './use-jobs-and-queues' + +describe('useJobsAndQueues', () => { + it('should return the expected data', async () => { + const item = { sequence: [{ id: 'id' }] } + const data = { scheduler: [item] } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobsAndQueues(), { + wrapper, + }) + + // Loading state + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: true, + error: undefined, + data: undefined, + }) + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: [{ id: 'id', sequence: [{ id: 'id' }] }], + }) + }) + }) + + it('should not fail if sequence is missing', async () => { + const item = {} + const data = { scheduler: [item] } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobsAndQueues(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: [item], + }) + }) + }) + + it('should not fail if sequence is empty', async () => { + const item = { sequence: [] } + const data = { scheduler: [item] } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobsAndQueues(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: [item], + }) + }) + }) + + it('should not fail if the first sequence item has no id', async () => { + const item = { sequence: [{}] } + const data = { scheduler: [item] } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobsAndQueues(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: [item], + }) + }) + }) + + it('should return an error if data is in an unexpected format', async () => { + const data = { scheduler: '' } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobsAndQueues(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + data: undefined, + }) + expect(result.current.error.message).toBe( + 'Did not receive the expected jobs and queues' + ) + }) + }) +}) diff --git a/src/hooks/jobs/use-job-by-id.test.jsx b/src/hooks/jobs/use-job-by-id.test.jsx new file mode 100644 index 000000000..d4a4ef540 --- /dev/null +++ b/src/hooks/jobs/use-job-by-id.test.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useJobById from './use-job-by-id' + +describe('useJobById', () => { + it('should return a job by id', async () => { + const id = 'id' + const job = { id } + const data = { + 'jobConfigurations/id': job, + } + + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobById(id), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: job, + }) + }) + }) +}) diff --git a/src/hooks/jobs/use-jobs.test.jsx b/src/hooks/jobs/use-jobs.test.jsx new file mode 100644 index 000000000..274e099a8 --- /dev/null +++ b/src/hooks/jobs/use-jobs.test.jsx @@ -0,0 +1,56 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useJobs from './use-jobs' + +describe('useJobs', () => { + it('should return the expected data', async () => { + const match = [{ id: 'id' }] + const data = { + jobConfigurations: { + jobConfigurations: match, + }, + } + + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobs(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: match, + }) + }) + }) + + it('should return an error if the jobs are in an unexpected format', async () => { + const data = { + jobConfigurations: { + jobConfigurations: '', + }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useJobs(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + data: undefined, + }) + expect(result.current.error.message).toBe( + 'Did not receive the expected job configurations' + ) + }) + }) +}) diff --git a/src/hooks/parameter-options/use-parameter-option.test.jsx b/src/hooks/parameter-options/use-parameter-option.test.jsx new file mode 100644 index 000000000..99634df92 --- /dev/null +++ b/src/hooks/parameter-options/use-parameter-option.test.jsx @@ -0,0 +1,42 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useParameterOption from './use-parameter-option' + +describe('useParameterOption', () => { + it('should return the requested parameter option', async () => { + const parameter = 'validationRuleGroups' + const expected = 'expected' + const data = { + 'analytics/tableTypes': 'skipTableTypes', + validationRuleGroups: { + validationRuleGroups: expected, + }, + pushAnalysis: { pushAnalysis: 'pushAnalysis' }, + predictors: { predictors: 'predictors' }, + predictorGroups: { predictorGroups: 'predictorGroups' }, + dataIntegrity: 'dataIntegrityChecks', + dashboards: { dashboards: 'dashboard' }, + userGroups: { userGroups: 'receivers' }, + programs: { programs: 'programs' }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook( + () => useParameterOption(parameter), + { + wrapper, + } + ) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: expected, + }) + }) + }) +}) diff --git a/src/hooks/parameter-options/use-parameter-options.test.jsx b/src/hooks/parameter-options/use-parameter-options.test.jsx new file mode 100644 index 000000000..ce40c7bbf --- /dev/null +++ b/src/hooks/parameter-options/use-parameter-options.test.jsx @@ -0,0 +1,78 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useParameterOptions from './use-parameter-options' + +describe('useParameterOptions', () => { + it('should return the expected data without nesting', async () => { + const data = { + 'analytics/tableTypes': 'skipTableTypes', + validationRuleGroups: { + validationRuleGroups: 'validationRuleGroups', + }, + pushAnalysis: { pushAnalysis: 'pushAnalysis' }, + predictors: { predictors: 'predictors' }, + predictorGroups: { predictorGroups: 'predictorGroups' }, + dataIntegrity: 'dataIntegrityChecks', + dashboards: { dashboards: 'dashboard' }, + userGroups: { userGroups: 'receivers' }, + programs: { programs: 'programs' }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useParameterOptions(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: { + skipTableTypes: 'skipTableTypes', + validationRuleGroups: 'validationRuleGroups', + pushAnalysis: 'pushAnalysis', + predictors: 'predictors', + predictorGroups: 'predictorGroups', + dataIntegrityChecks: 'dataIntegrityChecks', + dashboard: 'dashboard', + receivers: 'receivers', + skipPrograms: 'programs', + }, + }) + }) + }) + + it('should return an error if the parameter options are in an unexpected format', async () => { + const data = { + 'analytics/tableTypes': 'skipTableTypes', + validationRuleGroups: {}, + pushAnalysis: { pushAnalysis: 'pushAnalysis' }, + predictors: { predictors: 'predictors' }, + predictorGroups: { predictorGroups: 'predictorGroups' }, + dataIntegrity: 'dataIntegrityChecks', + dashboards: { dashboards: 'dashboard' }, + userGroups: { userGroups: 'receivers' }, + programs: { programs: 'programs' }, + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useParameterOptions(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + data: undefined, + }) + expect(result.current.error.message).toBe( + 'Did not receive the expected parameter options' + ) + }) + }) +}) diff --git a/src/hooks/queueables/use-queueables.test.jsx b/src/hooks/queueables/use-queueables.test.jsx new file mode 100644 index 000000000..bf13af097 --- /dev/null +++ b/src/hooks/queueables/use-queueables.test.jsx @@ -0,0 +1,31 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useQueueables from './use-queueables' + +describe('useQueueables', () => { + it('should return the requested data', async () => { + const queueable = { + sequence: [{ id: 'id' }], + } + const expected = [{ ...queueable, id: queueable.sequence[0].id }] + const data = { + 'scheduler/queueable': [queueable], + } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useQueueables(), { + wrapper, + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: expected, + }) + }) + }) +}) diff --git a/src/hooks/queues/use-queue-by-name.test.jsx b/src/hooks/queues/use-queue-by-name.test.jsx new file mode 100644 index 000000000..6fa98b9a2 --- /dev/null +++ b/src/hooks/queues/use-queue-by-name.test.jsx @@ -0,0 +1,35 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { CustomDataProvider } from '@dhis2/app-runtime' +import useQueueByName from './use-queue-by-name' + +describe('useQueueByName', () => { + it('should return the expected data', async () => { + const expected = 'expected' + const data = { 'scheduler/queues/name': expected } + const wrapper = ({ children }) => ( + {children} + ) + + const { result, waitFor } = renderHook(() => useQueueByName('name'), { + wrapper, + }) + + // Loading state + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: true, + error: undefined, + data: undefined, + }) + }) + + await waitFor(() => { + expect(result.current).toMatchObject({ + loading: false, + error: undefined, + data: expected, + }) + }) + }) +}) diff --git a/src/pages/JobAdd/JobAdd.jsx b/src/pages/JobAdd/JobAdd.jsx new file mode 100644 index 000000000..1faf205d9 --- /dev/null +++ b/src/pages/JobAdd/JobAdd.jsx @@ -0,0 +1,27 @@ +import React from 'react' +import { Card } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { InfoLink } from '../../components/InfoLink' +import { JobAddFormContainer } from '../../components/Forms' +import styles from './JobAdd.module.css' + +const JobAdd = () => { + return ( + +
+

{i18n.t('New Job')}

+
+ +
+

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

+ +
+ +
+
+ ) +} + +export default JobAdd diff --git a/src/pages/JobAdd/JobAdd.test.jsx b/src/pages/JobAdd/JobAdd.test.jsx new file mode 100644 index 000000000..9071bb50c --- /dev/null +++ b/src/pages/JobAdd/JobAdd.test.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobAdd from './JobAdd.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow() + }) +}) diff --git a/src/pages/JobAndQueueList/JobAndQueueList.jsx b/src/pages/JobAndQueueList/JobAndQueueList.jsx new file mode 100644 index 000000000..352349579 --- /dev/null +++ b/src/pages/JobAndQueueList/JobAndQueueList.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import { NoticeBox, Card, Checkbox, InputField } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { useJobsAndQueues } from '../../hooks/jobs-and-queues' +import { useNameFilter, useShowSystemJobs } from '../../components/Store' +import { JobTable } from '../../components/JobTable' +import { LinkButton } from '../../components/LinkButton' +import { InfoLink } from '../../components/InfoLink' +import { Spinner } from '../../components/Spinner' +import styles from './JobAndQueueList.module.css' +import filterJobsAndQueues from './filter-jobs-and-queues' + +const JobAndQueueList = () => { + const [nameFilter, setNameFilter] = useNameFilter() + const [showSystemJobs, setShowSystemJobs] = useShowSystemJobs() + const { data, loading, error, refetch } = useJobsAndQueues() + + if (loading) { + return + } + + if (error) { + return ( + + {i18n.t( + 'Something went wrong whilst loading the jobs and queues. Try refreshing the page.' + )} + + ) + } + + // Apply the current filter settings + const jobsAndQueues = filterJobsAndQueues({ + nameFilter, + showSystemJobs, + jobsAndQueues: data, + }) + + return ( + +
+

+ {i18n.t('Scheduled jobs')} +

+ +
+ +
+ { + setNameFilter(value) + }} + value={nameFilter} + type="search" + role="searchbox" + name="name-filter" + /> +
+ { + setShowSystemJobs(checked) + }} + /> + + {i18n.t('New job')} + + + {i18n.t('New queue')} + +
+
+ +
+
+ ) +} + +export default JobAndQueueList diff --git a/src/pages/JobAndQueueList/JobAndQueueList.test.jsx b/src/pages/JobAndQueueList/JobAndQueueList.test.jsx new file mode 100644 index 000000000..c6bfc3a08 --- /dev/null +++ b/src/pages/JobAndQueueList/JobAndQueueList.test.jsx @@ -0,0 +1,55 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { useJobsAndQueues } from '../../hooks/jobs-and-queues' +import JobAndQueueList from './JobAndQueueList.jsx' + +jest.mock('../../hooks/jobs-and-queues', () => ({ + useJobsAndQueues: jest.fn(), +})) + +jest.mock('../../components/Store', () => ({ + useNameFilter: jest.fn(() => ['', () => {}]), + useShowSystemJobs: jest.fn(() => [false, () => {}]), +})) + +describe('', () => { + it('renders a spinner when loading the jobs and queues', () => { + useJobsAndQueues.mockImplementation(() => ({ loading: true })) + + const wrapper = shallow() + const spinner = wrapper.find('Spinner') + + expect(spinner).toHaveLength(1) + }) + + it('renders errors encountered during fetching', () => { + useJobsAndQueues.mockImplementation(() => ({ + loading: false, + error: new Error('Something went wrong'), + })) + + const wrapper = shallow() + const noticebox = wrapper.find('NoticeBox') + + expect(noticebox).toHaveLength(1) + }) + + it('renders without errors when loading has completed', () => { + useJobsAndQueues.mockImplementation(() => ({ + loading: false, + error: undefined, + data: [ + { + name: '', + configurable: true, + }, + ], + refetch: () => {}, + })) + + const wrapper = shallow() + const jobtable = wrapper.find('JobTable') + + expect(jobtable).toHaveLength(1) + }) +}) diff --git a/src/pages/JobEdit/JobEdit.jsx b/src/pages/JobEdit/JobEdit.jsx new file mode 100644 index 000000000..86dd9c387 --- /dev/null +++ b/src/pages/JobEdit/JobEdit.jsx @@ -0,0 +1,54 @@ +import React from 'react' +import { Card } from '@dhis2/ui' +import PropTypes from 'prop-types' +import i18n from '@dhis2/d2-i18n' +import { InfoLink } from '../../components/InfoLink' +import { JobEditFormContainer } from '../../components/Forms' +import { JobDetails } from '../../components/JobDetails' +import styles from './JobEdit.module.css' + +const JobEdit = ({ job }) => { + const { name, created, lastExecutedStatus, lastExecuted } = job + + return ( + +
+

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

+
+ +
+

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

+ +
+
+ +
+ +
+
+ ) +} + +const { shape, string } = PropTypes + +JobEdit.propTypes = { + job: shape({ + name: string.isRequired, + created: string.isRequired, + lastExecuted: string, + lastExecutedStatus: string, + }).isRequired, +} + +export default JobEdit diff --git a/src/pages/JobEdit/JobEdit.test.jsx b/src/pages/JobEdit/JobEdit.test.jsx new file mode 100644 index 000000000..3b6ecf224 --- /dev/null +++ b/src/pages/JobEdit/JobEdit.test.jsx @@ -0,0 +1,18 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobEdit from './JobEdit.jsx' + +describe('', () => { + it('renders without errors', () => { + const job = { + name: '', + created: '', + lastExecutedStatus: '', + lastExecuted: '', + } + const wrapper = shallow() + const jobform = wrapper.find('JobEditFormContainer') + + expect(jobform).toHaveLength(1) + }) +}) diff --git a/src/pages/JobView/JobView.jsx b/src/pages/JobView/JobView.jsx new file mode 100644 index 000000000..f13ecca76 --- /dev/null +++ b/src/pages/JobView/JobView.jsx @@ -0,0 +1,105 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { + Card, + Box, + SingleSelectField, + SingleSelectOption, + InputField, +} from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { InfoLink } from '../../components/InfoLink' +import { LinkButton } from '../../components/LinkButton' +import { JobDetails } from '../../components/JobDetails' +import translateCron from '../../services/translate-cron' +import { jobTypesMap } from '../../services/server-translations' +import styles from './JobView.module.css' + +const JobView = ({ job }) => { + const { + name, + created, + lastExecutedStatus, + lastExecuted, + jobType, + cronExpression, + } = job + + return ( + +
+

+ {i18n.t('System job: {{ name }}', { + name, + nsSeparator: '>', + })} +

+
+ +
+

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

+ +
+
+ +
+ + + + + + + + + + + + + + {i18n.t('Back to all jobs')} + + +
+
+ ) +} + +const { shape, string } = PropTypes + +JobView.propTypes = { + job: shape({ + name: string.isRequired, + created: string.isRequired, + jobType: string.isRequired, + cronExpression: string.isRequired, + lastExecuted: string, + lastExecutedStatus: string, + }).isRequired, +} + +export default JobView diff --git a/src/pages/JobView/JobView.test.jsx b/src/pages/JobView/JobView.test.jsx new file mode 100644 index 000000000..2e26c23bd --- /dev/null +++ b/src/pages/JobView/JobView.test.jsx @@ -0,0 +1,20 @@ +import React from 'react' +import { shallow } from 'enzyme' +import JobView from './JobView.jsx' + +describe('', () => { + it('renders without errors', () => { + const job = { + name: '', + created: '', + lastExecutedStatus: '', + lastExecuted: '', + jobType: 'DATA_INTEGRITY', + cronExpression: '', + } + const wrapper = shallow() + const jobform = wrapper.find('JobDetails') + + expect(jobform).toHaveLength(1) + }) +}) diff --git a/src/pages/QueueAdd/QueueAdd.jsx b/src/pages/QueueAdd/QueueAdd.jsx new file mode 100644 index 000000000..b972c73d7 --- /dev/null +++ b/src/pages/QueueAdd/QueueAdd.jsx @@ -0,0 +1,29 @@ +import React from 'react' +import { Card } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { InfoLink } from '../../components/InfoLink' +import { QueueAddFormContainer } from '../../components/Forms' +import styles from './QueueAdd.module.css' + +const QueueAdd = () => { + return ( + +
+

+ {i18n.t('New queue')} +

+
+ +
+

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

+ +
+ +
+
+ ) +} + +export default QueueAdd diff --git a/src/pages/QueueAdd/QueueAdd.test.jsx b/src/pages/QueueAdd/QueueAdd.test.jsx new file mode 100644 index 000000000..f21f6960d --- /dev/null +++ b/src/pages/QueueAdd/QueueAdd.test.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import { shallow } from 'enzyme' +import QueueAdd from './QueueAdd.jsx' + +describe('', () => { + it('renders without errors', () => { + shallow() + }) +}) diff --git a/src/pages/QueueEdit/QueueEdit.jsx b/src/pages/QueueEdit/QueueEdit.jsx new file mode 100644 index 000000000..194db2eb1 --- /dev/null +++ b/src/pages/QueueEdit/QueueEdit.jsx @@ -0,0 +1,57 @@ +import React from 'react' +import { Card, NoticeBox } from '@dhis2/ui' +import { useParams } from 'react-router-dom' +import i18n from '@dhis2/d2-i18n' +import { InfoLink } from '../../components/InfoLink' +import { Spinner } from '../../components/Spinner' +import { QueueEditFormContainer } from '../../components/Forms' +import { useQueueByName } from '../../hooks/queues' +import { useJobs } from '../../hooks/jobs' +import styles from './QueueEdit.module.css' + +const QueueEdit = () => { + const { name } = useParams() + const queueResult = useQueueByName(name) + const jobsResult = useJobs() + + if (queueResult.fetching || jobsResult.fetching) { + return + } + + if (queueResult.error || jobsResult.error) { + return ( + + {i18n.t( + 'Something went wrong whilst loading the requested queue. Make sure it has not been deleted and try refreshing the page.' + )} + + ) + } + + return ( + +
+

+ {i18n.t('Queue: {{ name }}', { + name, + nsSeparator: '>', + })} +

+
+ +
+

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

+ +
+ +
+
+ ) +} + +export default QueueEdit diff --git a/src/pages/QueueEdit/QueueEdit.test.jsx b/src/pages/QueueEdit/QueueEdit.test.jsx new file mode 100644 index 000000000..8be61d306 --- /dev/null +++ b/src/pages/QueueEdit/QueueEdit.test.jsx @@ -0,0 +1,90 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { useParams } from 'react-router-dom' +import { useQueueByName } from '../../hooks/queues' +import { useJobs } from '../../hooks/jobs' +import QueueEdit from './QueueEdit.jsx' + +jest.mock('react-router-dom', () => ({ + useParams: jest.fn(), +})) + +jest.mock('../../hooks/queues/', () => ({ + useQueueByName: jest.fn(), +})) + +jest.mock('../../hooks/jobs/', () => ({ + useJobs: jest.fn(), +})) + +describe('', () => { + it('renders a spinner when loading the requested queue', () => { + const name = 'name' + + useParams.mockImplementation(() => name) + useQueueByName.mockImplementation(() => ({ fetching: true })) + useJobs.mockImplementation(() => ({ fetching: true })) + + const wrapper = shallow() + const spinner = wrapper.find('Spinner') + + expect(spinner).toHaveLength(1) + }) + + it('renders queue errors encountered during fetching', () => { + const name = 'name' + + useParams.mockImplementation(() => name) + useQueueByName.mockImplementation(() => ({ + fetching: false, + error: new Error('Something went wrong'), + })) + useJobs.mockImplementation(() => ({ fetching: false })) + + const wrapper = shallow() + const noticebox = wrapper.find('NoticeBox') + + expect(noticebox).toHaveLength(1) + }) + + it('renders job errors encountered during fetching', () => { + const name = 'name' + + useParams.mockImplementation(() => name) + useQueueByName.mockImplementation(() => ({ fetching: false })) + useJobs.mockImplementation(() => ({ + fetching: false, + error: new Error('Something went wrong'), + })) + + const wrapper = shallow() + const noticebox = wrapper.find('NoticeBox') + + expect(noticebox).toHaveLength(1) + }) + + it('renders without errors when loading has completed', () => { + const name = 'name' + + useParams.mockImplementation(() => name) + useQueueByName.mockImplementation(() => ({ + fetching: false, + error: undefined, + data: { + name: '', + cronExpression: '', + sequence: [], + }, + })) + useJobs.mockImplementation(() => ({ + fetching: false, + error: undefined, + data: [], + })) + + const wrapper = shallow() + const jobform = wrapper.find('QueueEditFormContainer') + + expect(jobform).toHaveLength(1) + }) +})