1
0
mirror of https://gitlab.nic.cz/turris/reforis/foris-js.git synced 2025-06-16 13:46:16 +02:00

Set babel.

This commit is contained in:
Bogdan Bodnar
2019-08-23 15:20:22 +02:00
parent 02ca27e49c
commit 7b38c1658c
69 changed files with 2577 additions and 1269 deletions

View File

@ -0,0 +1,26 @@
/*
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from 'react';
import {render} from 'customTestRender';
import SubmitButton, {STATES} from '../components/SubmitButton';
describe('<SubmitButton/>', () => {
it('Render ready', () => {
const {container} = render(<SubmitButton state={STATES.READY}/>);
expect(container).toMatchSnapshot();
});
it('Render saving', () => {
const {container} = render(<SubmitButton state={STATES.SAVING}/>);
expect(container).toMatchSnapshot();
});
it('Render load', () => {
const {container} = render(<SubmitButton state={STATES.LOAD}/>);
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SubmitButton/> Render load 1`] = `
<div>
<button
class="btn btn-primary offset-lg-8 col-lg-3 col-sm-12"
disabled=""
>
<span
aria-hidden="true"
class="spinner-border spinner-border-sm"
role="status"
/>
Load settings
</button>
</div>
`;
exports[`<SubmitButton/> Render ready 1`] = `
<div>
<button
class="btn btn-primary offset-lg-8 col-lg-3 col-sm-12"
>
Save
</button>
</div>
`;
exports[`<SubmitButton/> Render saving 1`] = `
<div>
<button
class="btn btn-primary offset-lg-8 col-lg-3 col-sm-12"
disabled=""
>
<span
aria-hidden="true"
class="spinner-border spinner-border-sm"
role="status"
/>
Updating
</button>
</div>
`;

View File

@ -0,0 +1,115 @@
/*
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
// TODO: rewrite this test
import React from 'react';
import {fireEvent, render, waitForElement} from 'customTestRender';
import mockAxios from 'jest-mock-axios';
import ForisForm from '../components/ForisForm';
import API_URLs from 'common/API';
// It's possible to unittest each hooks via react-hooks-testing-library.
// But it's better and easier to test it by test components which uses this hooks.
const TestForm = ({formData, formErrors, setFormValue}) => {
return <>
<input
data-testid='test-input'
value={formData.field}
onChange={setFormValue(value => ({field: {$set: value}}))}
/>
<p>{formErrors.field}</p>
</>
};
describe('useForm hook.', () => {
let mockValidator;
let mockPrepData;
let mockPrepDataToSubmit;
let mockWebSockets;
let input;
let form;
const Child = jest.fn(props => <TestForm {...props}/>);
beforeEach(async () => {
mockPrepData = jest.fn(() => ({field: 'preparedData'}));
mockPrepDataToSubmit = jest.fn(() => ({field: 'preparedDataToSubmit'}));
mockValidator = jest.fn(data => data.field === 'invalidValue' ? {field: 'Error'} : {});
const {getByTestId, container} = render(
<ForisForm
ws={mockWebSockets}
// Just some module which exists...
forisConfig={{
endpoint: API_URLs.wan,
wsModule: 'wan'
}}
prepData={mockPrepData}
prepDataToSubmit={mockPrepDataToSubmit}
validator={mockValidator}
>
<Child/>
</ForisForm>
);
mockAxios.mockResponse({field: 'fetchedData'});
input = await waitForElement(() =>
getByTestId('test-input')
);
form = container.firstChild
});
it('Validation on changing.', () => {
expect(mockValidator).toHaveBeenCalledTimes(1);
expect(Child).toHaveBeenCalledTimes(1);
expect(Child.mock.calls[0][0].formErrors).toMatchObject({});
fireEvent.change(input, {target: {value: 'invalidValue', type: 'text'}});
expect(Child).toHaveBeenCalledTimes(2);
expect(mockValidator).toHaveBeenCalledTimes(2);
expect(Child.mock.calls[1][0].formErrors).toMatchObject({field: 'Error'});
});
it('Update text value.', () => {
fireEvent.change(input, {target: {value: 'newValue', type: 'text'}})
expect(input.value).toBe('newValue');
});
it('Update text value.', () => {
fireEvent.change(input, {target: {value: 123, type: 'number'}})
expect(input.value).toBe('123');
});
it('Update checkbox value.', () => {
fireEvent.change(input, {target: {checked: true, type: 'checkbox'}})
expect(input.checked).toBe(true);
});
it('Fetch data.', () => {
expect(mockAxios.get).toHaveBeenCalledWith('/api/wan', expect.anything());
expect(mockPrepData).toHaveBeenCalledTimes(1);
expect(Child.mock.calls[0][0].formData).toMatchObject({field: 'preparedData'});
});
it('Submit.', () => {
expect(mockAxios.get).toHaveBeenCalledTimes(1);
expect(mockPrepDataToSubmit).toHaveBeenCalledTimes(0);
fireEvent.submit(form);
expect(mockPrepDataToSubmit).toHaveBeenCalledTimes(1);
expect(mockAxios.post).toHaveBeenCalledTimes(1);
expect(mockAxios.post).toHaveBeenCalledWith(
'/api/wan',
{'field': 'preparedDataToSubmit'},
expect.anything(),
);
});
});

View File

@ -0,0 +1,84 @@
/*
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import {
validateDomain,
validateDUID,
validateIPv4Address,
validateIPv6Address,
validateIPv6Prefix, validateMAC
} from 'validations';
describe('Validation functions', () => {
it('validateIPv4Address valid', () => {
expect(validateIPv4Address('192.168.1.1')).toBe(undefined);
expect(validateIPv4Address('1.1.1.1')).toBe(undefined);
expect(validateIPv4Address('0.0.0.0')).toBe(undefined);
});
it('validateIPv4Address invalid', () => {
expect(validateIPv4Address('invalid')).not.toBe(undefined);
expect(validateIPv4Address('192.256.1.1')).not.toBe(undefined);
expect(validateIPv4Address('192.168.256.1')).not.toBe(undefined);
expect(validateIPv4Address('192.168.1.256')).not.toBe(undefined);
expect(validateIPv4Address('192.168.1.256')).not.toBe(undefined);
});
it('validateIPv6Address valid', () => {
expect(validateIPv6Address('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(undefined);
expect(validateIPv6Address('0:0:0:0:0:0:0:1')).toBe(undefined);
expect(validateIPv6Address('::1')).toBe(undefined);
expect(validateIPv6Address('::')).toBe(undefined);
});
it('validateIPv6Address invalid', () => {
expect(validateIPv6Address('invalid')).not.toBe(undefined);
expect(validateIPv6Address('1.1.1.1')).not.toBe(undefined);
expect(validateIPv6Address('1200::AB00:1234::2552:7777:1313')).not.toBe(undefined);
expect(validateIPv6Address('1200:0000:AB00:1234:O000:2552:7777:1313')).not.toBe(undefined);
});
it('validateIPv6Prefix valid', () => {
expect(validateIPv6Prefix('2002:0000::/16')).toBe(undefined);
expect(validateIPv6Prefix('0::/0')).toBe(undefined);
});
it('validateIPv6Prefix invalid', () => {
expect(validateIPv6Prefix('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).not.toBe(undefined);
expect(validateIPv6Prefix('::1')).not.toBe(undefined);
expect(validateIPv6Prefix('2002:0000::/999')).not.toBe(undefined);
});
it('validateDomain valid', () => {
expect(validateDomain('example.com')).toBe(undefined);
expect(validateDomain('one.two.three')).toBe(undefined);
});
it('validateDomain invalid', () => {
expect(validateDomain('test/')).not.toBe(undefined);
expect(validateDomain('.')).not.toBe(undefined);
});
it('validateDUID valid', () => {
expect(validateDUID('abcdefAB')).toBe(undefined);
expect(validateDUID('ABCDEF12')).toBe(undefined);
expect(validateDUID('ABCDEF12AB')).toBe(undefined);
});
it('validateDUID invalid', () => {
expect(validateDUID('gggggggg')).not.toBe(undefined);
expect(validateDUID('abcdefABa')).not.toBe(undefined);
});
it('validateMAC valid', () => {
expect(validateMAC('00:D0:56:F2:B5:12')).toBe(undefined);
expect(validateMAC('00:26:DD:14:C4:EE')).toBe(undefined);
expect(validateMAC('06:00:00:00:00:00')).toBe(undefined);
});
it('validateMAC invalid', () => {
expect(validateMAC('00:00:00:00:00:0G')).not.toBe(undefined);
expect(validateMAC('06:00:00:00:00:00:00')).not.toBe(undefined);
});
});

View File

@ -0,0 +1,148 @@
/*
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types';
import {Prompt} from 'react-router';
import Spinner from 'bootstrap/components/Spinner';
import {useAPIPost} from 'api';
import {useForisModule, useForm} from '../hooks';
import SubmitButton, {STATES as SUBMIT_BUTTON_STATES} from './SubmitButton';
import {FailAlert, SuccessAlert} from './alerts';
ForisForm.propTypes = {
/** WebSocket object see `scr/common/WebSockets.js`. */
ws: PropTypes.object,
/** Foris configuration object. See usage in main components. */
forisConfig: PropTypes.shape({
/** reForis Flask aplication API endpoint from `src/common/API.js`. */
endpoint: PropTypes.string.isRequired,
/** `foris-controller` module name to be used via WebSockets.
* If it's not passed then WebSockets aren't used
* */
wsModule: PropTypes.string,
/**`foris-controller` action name to be used via WebSockets.
* If it's not passed then `update_settings` is used. see `src/common/WebSocketHooks.js`
* */
wsAction: PropTypes.string,
}).isRequired,
/** Function to prepare data recived from the API before using in forms. */
prepData: PropTypes.func.isRequired,
/** Function to prepare data from form before submitting. */
prepDataToSubmit: PropTypes.func.isRequired,
/** Function to handle response to POST request. */
postCallback: PropTypes.func.isRequired,
/** Validate data and provide validation object. Then validation errors passed to children. */
validator: PropTypes.func.isRequired,
/** Disables form */
disabled: PropTypes.bool,
/** reForis form components. */
children: PropTypes.node.isRequired,
/** Optional override of form submit callback */
onSubmitOverridden: PropTypes.func
};
ForisForm.defaultProps = {
prepData: data => data,
prepDataToSubmit: data => data,
postCallback: () => undefined,
validator: () => undefined,
disabled: false
};
/** Serves as HOC for all foris forms components. */
export default function ForisForm({
ws,
forisConfig,
prepData,
prepDataToSubmit,
postCallback,
validator,
disabled,
onSubmitOverridden,
children
}) {
const [formState, onFormChangeHandler, resetFormData] = useForm(validator, prepData);
const [forisModuleState] = useForisModule(ws, forisConfig);
useEffect(() => {
if (forisModuleState.data) {
resetFormData(forisModuleState.data)
}
}, [forisModuleState.data, resetFormData, prepData]);
const [postState, post] = useAPIPost(forisConfig.endpoint);
useEffect(() => {
if (postState.isSuccess)
postCallback();
}, [postCallback, postState.isSuccess]);
function onSubmitHandler(e) {
e.preventDefault();
resetFormData();
const copiedFormData = JSON.parse(JSON.stringify(formState.data));
const preparedData = prepDataToSubmit(copiedFormData);
post(preparedData);
}
function getSubmitButtonState() {
if (postState.isSending)
return SUBMIT_BUTTON_STATES.SAVING;
else if (forisModuleState.isLoading)
return SUBMIT_BUTTON_STATES.LOAD;
return SUBMIT_BUTTON_STATES.READY;
}
const [alertIsDismissed, setAlertIsDismissed] = useState(false);
if (!formState.data)
return <Spinner className='row justify-content-center'/>;
const formIsDisabled = disabled || forisModuleState.isLoading || postState.isSending;
const submitButtonIsDisabled = disabled || !!formState.errors;
const childrenWithFormProps =
React.Children.map(children, child =>
React.cloneElement(child, {
formData: formState.data,
formErrors: formState.errors,
setFormValue: onFormChangeHandler,
disabled: formIsDisabled,
})
);
const onSubmit = onSubmitOverridden ?
onSubmitOverridden(formState.data, onFormChangeHandler, onSubmitHandler) : onSubmitHandler;
function getMessageOnLeavingPage() {
if (JSON.stringify(formState.data) === JSON.stringify(formState.initialData))
return true;
return _('Changes you made may not be saved. Are you sure you want to leave?')
}
return <>
<Prompt message={getMessageOnLeavingPage}/>
{!alertIsDismissed ?
postState.isSuccess ?
<SuccessAlert onDismiss={() => setAlertIsDismissed(true)}/>
: postState.isError ?
<FailAlert onDismiss={() => setAlertIsDismissed(true)}/>
: null
: null
}
<form onSubmit={onSubmit}>
{childrenWithFormProps}
<SubmitButton
state={getSubmitButtonState()}
disabled={submitButtonIsDisabled}
/>
</form>
</>
}

View File

@ -0,0 +1,49 @@
/*
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from 'react';
import PropTypes from 'prop-types';
import Button from 'bootstrap/components/Button';
export const STATES = {
READY: 1,
SAVING: 2,
LOAD: 3,
};
SubmitButton.propTypes = {
disabled: PropTypes.bool,
state: PropTypes.oneOf(Object.keys(STATES).map(key => STATES[key]))
};
export default function SubmitButton({disabled, state, ...props}) {
const disableSubmitButton = disabled || state !== STATES.READY;
const loadingSubmitButton = state !== STATES.READY;
let labelSubmitButton;
switch (state) {
case STATES.SAVING:
labelSubmitButton = _('Updating');
break;
case STATES.LOAD:
labelSubmitButton = _('Load settings');
break;
default:
labelSubmitButton = _('Save');
}
return <Button
loading={loadingSubmitButton}
disabled={disableSubmitButton}
forisFormSize
{...props}
>
{labelSubmitButton}
</Button>;
}

View File

@ -0,0 +1,42 @@
/*
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from 'react';
import PropTypes from 'prop-types';
import Alert from 'bootstrap/components/Alert';
import Portal from 'utils/Portal';
SuccessAlert.propTypes = {
onDismiss: PropTypes.func.isRequired,
};
const ALERT_CONTAINER_ID = 'alert_container';
export function SuccessAlert({onDismiss}) {
return <Portal containerId={ALERT_CONTAINER_ID}>
<Alert
type='success'
message={_('Settings were successfully saved.')}
onDismiss={onDismiss}
/>
</Portal>;
}
FailAlert.propTypes = {
onDismiss: PropTypes.func.isRequired,
};
export function FailAlert({onDismiss}) {
return <Portal containerId={ALERT_CONTAINER_ID}>
<Alert
type='danger'
message={_('Settings update was failed.')}
onDismiss={onDismiss}
/>
</Portal>
}

100
src/form/hooks.js Normal file
View File

@ -0,0 +1,100 @@
/*
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import {useCallback, useEffect, useReducer} from 'react';
import update from 'immutability-helper';
import {useAPIGet} from 'api/hooks';
import useWSForisModule from 'webSockets/hooks';
export function useForm(validator, prepData) {
const [state, dispatch] = useReducer(formReducer, {
data: null,
initialData: null,
errors: {},
});
const onFormReload = useCallback(data => {
dispatch({
type: FORM_ACTIONS.resetData,
data: data,
prepData: prepData,
validator: validator,
});
}, [prepData, validator]);
const onFormChangeHandler = useCallback(updateRule =>
event => {
dispatch({
type: FORM_ACTIONS.updateValue,
value: getChangedValue(event.target),
updateRule: updateRule,
validator: validator,
})
}, [validator]);
return [
state,
onFormChangeHandler,
onFormReload,
]
}
const FORM_ACTIONS = {
updateValue: 1,
resetData: 2,
};
function formReducer(state, action) {
switch (action.type) {
case FORM_ACTIONS.updateValue: {
const newData = update(state.data, action.updateRule(action.value));
const errors = action.validator(newData);
return {
...state,
data: newData,
errors: errors
};
}
case FORM_ACTIONS.resetData: {
if (!action.data)
return {...state, initialData: state.data};
const prepData = action.prepData ? action.prepData(action.data) : action.data;
return {
data: prepData,
initialData: prepData,
errors: action.data ? action.validator(prepData) : undefined,
};
}
default: {
throw new Error();
}
}
}
function getChangedValue(target) {
let value = target.value;
if (target.type === 'checkbox') {
value = target.checked;
} else if (target.type === 'number') {
const parsedValue = parseInt(value);
value = isNaN(parsedValue) ? value : parsedValue;
}
return value
}
export function useForisModule(ws, config) {
const [APIGetState, get] = useAPIGet(config.endpoint);
const [WSData] = useWSForisModule(ws, config.wsModule, config.wsAction);
useEffect(() => {
get();
}, [WSData, get]);
return [APIGetState];
}

5
src/form/index.js Normal file
View File

@ -0,0 +1,5 @@
import * as ForisForm from "./components/ForisForm";
import * as SubmitButton from "./components/SubmitButton";
import * as hooks from "./hooks";
export {ForisForm, SubmitButton, hooks}