1
0
mirror of https://gitlab.nic.cz/turris/reforis/foris-js.git synced 2024-11-14 17:35:35 +01:00

Release 0.1.0

This commit is contained in:
Maciej Lenartowicz 2019-10-07 15:16:27 +00:00
parent 9e965bdcef
commit 50a1bfd9b5
38 changed files with 2651 additions and 518 deletions

2
.gitignore vendored
View File

@ -43,8 +43,8 @@ coverage.xml
## Translations ## Translations
*.mo *.mo
/js/styleguide/
.gitignore .gitignore
dist/ dist/
foris-*.tgz foris-*.tgz
styleguide/

View File

@ -3,6 +3,7 @@ image: node:8-alpine
stages: stages:
- test - test
- build - build
- publish
before_script: before_script:
- npm install - npm install
@ -24,3 +25,19 @@ build:
artifacts: artifacts:
paths: paths:
- foris-*.tgz - foris-*.tgz
publish_beta:
stage: publish
only:
refs:
- dev
script:
- sh scripts/publish.sh beta
publish_latest:
stage: publish
only:
refs:
- master
script:
- sh scripts/publish.sh latest

View File

@ -1,17 +1,50 @@
.PHONY: all create-messages update-messages clean .PHONY: all install-js watch-js build-js lint-js test-js create-messages update-messages docs clean
all: all:
@echo "make install-js"
@echo " Install dependencies"
@echo "make watch-js"
@echo " Compile JS in watch mode."
@echo "make build-js"
@echo " Compile JS."
@echo "make lint-js"
@echo " Run linter"
@echo "make test-js"
@echo " Run tests"
@echo "make create-messages" @echo "make create-messages"
@echo " Create locale messages (.pot)." @echo " Create locale messages (.pot)."
@echo "make update-messages" @echo "make update-messages"
@echo " Update locale messages from .pot file." @echo " Update locale messages from .pot file."
@echo "make docs"
@echo " Build project documentation."
@echo "make docs-watch"
@echo " Start styleguidist server."
@echo "make clean" @echo "make clean"
@echo " Remove python artifacts and virtualenv." @echo " Remove python artifacts and virtualenv."
install-js: package.json
npm install --save-dev
watch-js:
npm run build:watch
build-js:
npm run build
lint:
npm run lint
test:
npm test
create-messages: create-messages:
pybabel extract -F babel.cfg -o ./translations/forisjs.pot . pybabel extract -F babel.cfg -o ./translations/forisjs.pot .
update-messages: update-messages:
pybabel update -i translations/forisjs.pot -d translations pybabel update -i translations/forisjs.pot -d translations
docs:
npm run-script docs
docs-watch:
npm run-script docs:watch
clean: clean:
rm -rf node_modules dist rm -rf node_modules dist

17
README.md Normal file
View File

@ -0,0 +1,17 @@
# foris-js
## Publishing package
### Beta versions
Each commit to `dev` branch will result in publishing a new version of library
tagged `beta`. Versions names are based on commit SHA, e.g.
`foris@0.1.0-d9073aa4.0`.
### Preparing a release
1. Crete a merge request to `dev` branch with version bumped
2. When merging add `[skip ci]` to commit message to prevent publishing
unnecessary version
3. Create a merge request from `dev` to `master` branch
4. New version should be published automatically

1
docs/intro.md Normal file
View File

@ -0,0 +1 @@
Foris JS library is set of componets and utils for Foris JS application and plugins.

View File

@ -24,4 +24,8 @@ module.exports = {
globals: { globals: {
TZ: "utc", TZ: "utc",
}, },
transform: {
"^.+\\.js$": "babel-jest",
"^.+\\.css$": "jest-transform-css",
},
}; };

2134
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "foris", "name": "foris",
"version": "0.0.7", "version": "0.1.0",
"description": "Set of components and utils for Foris and its plugins.", "description": "Set of components and utils for Foris and its plugins.",
"author": "CZ.NIC, z.s.p.o.", "author": "CZ.NIC, z.s.p.o.",
"repository": { "repository": {
@ -15,54 +15,67 @@
"main": "./dist/index.js", "main": "./dist/index.js",
"dependencies": { "dependencies": {
"axios": "^0.19.0", "axios": "^0.19.0",
"immutability-helper": "^3.0.1", "immutability-helper": "^3.0.0",
"jest-transform-css": "^2.0.0",
"moment": "^2.24.0",
"moment-timezone": "^0.5.25",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react-datetime": "^2.16.3", "react-datetime": "^2.16.3",
"react-router": "^5.0.1", "react-router": "^5.0.1",
"react-uid": "^2.2.0", "react-uid": "^2.2.0"
"moment": "^2.24.0",
"moment-timezone": "^0.5.25"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.9.0", "react": "^16.9.0",
"react-dom": "^16.9.0" "react-dom": "^16.9.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.5.5", "@babel/cli": "^7.4.4",
"@babel/core": "^7.5.5", "@babel/core": "^7.4.5",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/plugin-syntax-export-default-from": "^7.2.0", "@babel/plugin-syntax-export-default-from": "^7.2.0",
"@babel/plugin-transform-runtime": "^7.4.4", "@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.5", "@babel/preset-env": "^7.4.5",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@testing-library/react": "^8.0.1", "@fortawesome/fontawesome-free": "^5.11.2",
"@testing-library/react": "^8.0.9",
"babel-eslint": "^9.0.0", "babel-eslint": "^9.0.0",
"babel-jest": "^24.8.0", "babel-jest": "^24.8.0",
"babel-loader": "^8.0.6", "babel-loader": "^8.0.6",
"babel-plugin-module-resolver": "^3.2.0", "babel-plugin-module-resolver": "^3.2.0",
"babel-plugin-react-transform": "^3.0.0", "babel-plugin-react-transform": "^3.0.0",
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"eslint": "^5.16.0", "bootstrap": "^4.3.1",
"copy-webpack-plugin": "^5.0.4",
"css-loader": "^3.2.0",
"eslint": "^6.1.0",
"eslint-config-airbnb": "^18.0.1", "eslint-config-airbnb": "^18.0.1",
"eslint-plugin-import": "^2.17.3", "eslint-plugin-import": "^2.18.2",
"eslint-plugin-jsx-a11y": "^6.2.1", "eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.13.0", "eslint-plugin-react": "^7.14.3",
"eslint-plugin-react-hooks": "^1.6.0", "eslint-plugin-react-hooks": "^1.7.0",
"file-loader": "^4.2.0",
"jest": "^24.8.0", "jest": "^24.8.0",
"jest-mock-axios": "^3.0.0", "jest-mock-axios": "^3.0.0",
"moment": "^2.24.0", "moment": "^2.24.0",
"moment-timezone": "^0.5.25", "moment-timezone": "^0.5.25",
"react": "^16.9.0", "react": "^16.9.0",
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-styleguidist": "^9.1.11", "react-styleguidist": "^9.1.16",
"snapshot-diff": "^0.5.1" "snapshot-diff": "^0.5.1",
"style-loader": "^1.0.0",
"webpack": "^4.41.0"
}, },
"scripts": { "scripts": {
"build": "rm -rf dist; babel src --out-dir dist --ignore '**/__tests__' --source-maps inline", "build": "rm -rf dist; babel src --out-dir dist --ignore '**/__tests__' --source-maps inline --copy-files",
"build:watch": "babel src --verbose --watch --out-dir dist --ignore '**/__tests__' --source-maps inline --copy-files",
"prepare": "rm -rf ./dist && npm run build", "prepare": "rm -rf ./dist && npm run build",
"lint": "eslint src", "lint": "eslint src",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage --colors" "test:coverage": "jest --coverage --colors",
"test:update-snapshots": "jest -u",
"docs": "npx styleguidist build ",
"docs:watch": "styleguidist server"
}, },
"files": [ "files": [
"dist/**", "dist/**",

22
scripts/publish.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/sh
if test -z "$NPM_TOKEN"
then
echo "\$NPM_TOKEN is not set"
exit 1
else
# Need to replace "_" with "_" as GitLab CI won't accept secret vars with "-"
echo "//registry.npmjs.org/:_authToken=$(echo $NPM_TOKEN | tr _ -)" > .npmrc
echo "unsafe-perm = true" >> ~/.npmrc
if test "$1" = "beta"
then
npm version prerelease --preid=$CI_COMMIT_SHORT_SHA --git-tag-version false
npm publish --tag beta
elif test "$1" = "latest"
then
npm publish
else
echo "Usage: publish.sh [ beta | latest ]"
exit 1
fi
fi

View File

@ -0,0 +1,35 @@
/*
* 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, { useState } from "react";
import PropTypes from "prop-types";
import { Alert } from "bootstrap/Alert";
const AlertContext = React.createContext();
AlertContextProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]),
};
function AlertContextProvider({ children }) {
const [alert, setAlert] = useState(null);
return (
<>
{alert && <Alert type="danger" message={alert} onDismiss={() => setAlert(null)} />}
<AlertContext.Provider value={setAlert}>
{ children }
</AlertContext.Provider>
</>
);
}
export { AlertContext, AlertContextProvider };

View File

@ -0,0 +1,48 @@
/*
* 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, { useContext } from "react";
import { render, getByText, queryByText, fireEvent } from "customTestRender";
import { AlertContext, AlertContextProvider } from "../AlertContext";
function AlertTest() {
const setAlert = useContext(AlertContext);
return <button onClick={() => setAlert("Alert content")}>Set alert</button>;
};
describe("AlertContext", () => {
let componentContainer;
beforeEach(() => {
const { container } = render(
<AlertContextProvider>
<AlertTest />
</AlertContextProvider>
);
componentContainer = container;
});
it("should render component without alert", () => {
expect(componentContainer).toMatchSnapshot();
});
it("should render alert", () => {
fireEvent.click(getByText(componentContainer, "Set alert"));
expect(componentContainer).toMatchSnapshot();
});
it("should dismiss alert", () => {
fireEvent.click(getByText(componentContainer, "Set alert"));
// Alert is present
expect(getByText(componentContainer, "Alert content")).toBeDefined();
fireEvent.click(componentContainer.querySelector(".close"));
// Alert is gone
expect(queryByText(componentContainer, "Alert content")).toBeNull();
});
});

View File

@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AlertContext should render alert 1`] = `
<div>
<div
class="alert alert-dismissible alert-danger"
>
<button
class="close"
type="button"
>
×
</button>
Alert content
</div>
<button>
Set alert
</button>
</div>
`;
exports[`AlertContext should render component without alert 1`] = `
<div>
<button>
Set alert
</button>
</div>
`;

41
src/api/delete.js Normal file
View File

@ -0,0 +1,41 @@
/*
* 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 { useReducer, useCallback } from "react";
import axios from "axios";
import {
API_ACTIONS, TIMEOUT, HEADERS, APIReducer, getErrorMessage,
} from "./utils";
export function useAPIDelete(url) {
const [state, dispatch] = useReducer(APIReducer, {
isSending: false,
isError: false,
isSuccess: false,
data: null,
});
const requestDelete = useCallback(async () => {
dispatch({ type: API_ACTIONS.INIT });
try {
await axios.delete(url, {
timeout: TIMEOUT,
headers: HEADERS,
});
dispatch({ type: API_ACTIONS.SUCCESS });
} catch (error) {
dispatch({
type: API_ACTIONS.FAILURE,
payload: getErrorMessage(error),
status: error.response.status,
});
}
}, [url]);
return [state, requestDelete];
}

65
src/api/get.js Normal file
View File

@ -0,0 +1,65 @@
/*
* 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 { useReducer, useCallback } from "react";
import axios from "axios";
import { ForisURLs } from "forisUrls";
import { API_ACTIONS, TIMEOUT } from "./utils";
const APIGetReducer = (state, action) => {
switch (action.type) {
case API_ACTIONS.INIT:
return {
...state,
isLoading: true,
isError: false,
};
case API_ACTIONS.SUCCESS:
return {
...state,
isLoading: false,
isError: false,
data: action.payload,
};
case API_ACTIONS.FAILURE:
if (action.status === 403) window.location.assign(ForisURLs.login);
return {
...state,
isLoading: false,
isError: true,
data: action.payload,
};
default:
throw new Error();
}
};
export function useAPIGet(url) {
const [state, dispatch] = useReducer(APIGetReducer, {
isLoading: false,
isError: false,
data: null,
});
const get = useCallback(async () => {
dispatch({ type: API_ACTIONS.INIT });
try {
const result = await axios.get(url, {
timeout: TIMEOUT,
});
dispatch({ type: API_ACTIONS.SUCCESS, payload: result.data });
} catch (error) {
dispatch({
type: API_ACTIONS.FAILURE,
payload: error.response.data,
status: error.response.status,
});
}
}, [url]);
return [state, get];
}

View File

@ -1,153 +0,0 @@
/*
* 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 axios from "axios";
import { useCallback, useReducer } from "react";
import { ForisURLs } from "forisUrls";
const POST_HEADERS = {
Accept: "application/json",
"Content-Type": "application/json",
"X-CSRFToken": getCookie("_csrf_token"),
};
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (`${name}=`)) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
export const TIMEOUT = 5000;
const API_ACTIONS = {
INIT: 1,
SUCCESS: 2,
FAILURE: 3,
};
const APIGetReducer = (state, action) => {
switch (action.type) {
case API_ACTIONS.INIT:
return {
...state,
isLoading: true,
isError: false,
};
case API_ACTIONS.SUCCESS:
return {
...state,
isLoading: false,
isError: false,
data: action.payload,
};
case API_ACTIONS.FAILURE:
if (action.status === 403) window.location.assign(ForisURLs.login);
return {
...state,
isLoading: false,
isError: true,
data: action.payload,
};
default:
throw new Error();
}
};
export function useAPIGet(url) {
const [state, dispatch] = useReducer(APIGetReducer, {
isLoading: false,
isError: false,
data: null,
});
const get = useCallback(async () => {
dispatch({ type: API_ACTIONS.INIT });
try {
const result = await axios.get(url, {
timeout: TIMEOUT,
});
dispatch({ type: API_ACTIONS.SUCCESS, payload: result.data });
} catch (error) {
dispatch({
type: API_ACTIONS.FAILURE,
payload: error.response.data,
status: error.response.status,
});
}
}, [url]);
return [state, get];
}
const APIPostReducer = (state, action) => {
switch (action.type) {
case API_ACTIONS.INIT:
return {
...state,
isSending: true,
isError: false,
isSuccess: false,
};
case API_ACTIONS.SUCCESS:
return {
...state,
isSending: false,
isError: false,
isSuccess: true,
data: action.payload,
};
case API_ACTIONS.FAILURE:
if (action.status === 403) window.location.assign(ForisURLs.login);
return {
...state,
isSending: false,
isError: true,
isSuccess: false,
data: action.payload,
};
default:
throw new Error();
}
};
export function useAPIPost(url) {
const [state, dispatch] = useReducer(APIPostReducer, {
isSending: false,
isError: false,
isSuccess: false,
data: null,
});
const post = async (data) => {
dispatch({ type: API_ACTIONS.INIT });
try {
const result = await axios.post(url, data, {
timeout: TIMEOUT,
headers: POST_HEADERS,
});
dispatch({ type: API_ACTIONS.SUCCESS, payload: result.data });
} catch (error) {
dispatch({
type: API_ACTIONS.FAILURE,
payload: error.response.data,
status: error.response.status,
});
}
};
return [state, post];
}

40
src/api/patch.js Normal file
View File

@ -0,0 +1,40 @@
/*
* 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 { useReducer } from "react";
import axios from "axios";
import {
API_ACTIONS, TIMEOUT, HEADERS, APIReducer, getErrorMessage,
} from "./utils";
export function useAPIPatch(url) {
const [state, dispatch] = useReducer(APIReducer, {
isSending: false,
isError: false,
isSuccess: false,
data: null,
});
const patch = async (data) => {
dispatch({ type: API_ACTIONS.INIT });
try {
const result = await axios.patch(url, data, {
timeout: TIMEOUT,
headers: HEADERS,
});
dispatch({ type: API_ACTIONS.SUCCESS, payload: result.data });
} catch (error) {
dispatch({
type: API_ACTIONS.FAILURE,
payload: getErrorMessage(error),
status: error.response.status,
});
}
};
return [state, patch];
}

40
src/api/post.js Normal file
View File

@ -0,0 +1,40 @@
/*
* 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 { useReducer } from "react";
import axios from "axios";
import {
API_ACTIONS, TIMEOUT, HEADERS, APIReducer, getErrorMessage,
} from "./utils";
export function useAPIPost(url) {
const [state, dispatch] = useReducer(APIReducer, {
isSending: false,
isError: false,
isSuccess: false,
data: null,
});
const post = async (data) => {
dispatch({ type: API_ACTIONS.INIT });
try {
const result = await axios.post(url, data, {
timeout: TIMEOUT,
headers: HEADERS,
});
dispatch({ type: API_ACTIONS.SUCCESS, payload: result.data });
} catch (error) {
dispatch({
type: API_ACTIONS.FAILURE,
payload: getErrorMessage(error),
status: error.response.status,
});
}
};
return [state, post];
}

77
src/api/utils.js Normal file
View File

@ -0,0 +1,77 @@
/*
* 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 { ForisURLs } from "forisUrls";
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (`${name}=`)) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
export const HEADERS = {
Accept: "application/json",
"Content-Type": "application/json",
"X-CSRFToken": getCookie("_csrf_token"),
};
export const TIMEOUT = 5000;
export const API_ACTIONS = {
INIT: 1,
SUCCESS: 2,
FAILURE: 3,
};
export function APIReducer(state, action) {
switch (action.type) {
case API_ACTIONS.INIT:
return {
...state,
isSending: true,
isError: false,
isSuccess: false,
};
case API_ACTIONS.SUCCESS:
return {
...state,
isSending: false,
isError: false,
isSuccess: true,
data: action.payload,
};
case API_ACTIONS.FAILURE:
if (action.status === 403) window.location.assign(ForisURLs.login);
return {
...state,
isSending: false,
isError: true,
isSuccess: false,
data: action.payload,
};
default:
throw new Error();
}
}
export function getErrorMessage(error) {
let payload = "An unknown error occurred";
if (error.response.headers["content-type"] === "application/json") {
payload = error.response.data;
}
return payload;
}

View File

@ -11,6 +11,6 @@ const [value, setValue] = useState(false);
value={value} value={value}
label="Some label" label="Some label"
helpText="Read the small text!" helpText="Read the small text!"
onChange={value => setValue(value)} onChange={event =>setValue(event.target.value)}
/> />
``` ```

View File

@ -9,6 +9,7 @@ import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Datetime from "react-datetime/DateTime"; import Datetime from "react-datetime/DateTime";
import moment from "moment/moment"; import moment from "moment/moment";
import "react-datetime/css/react-datetime.css";
import { Input } from "./Input"; import { Input } from "./Input";

View File

@ -11,7 +11,7 @@ const [email, setEmail] = useState('Wrong email');
value={email} value={email}
label="Some label" label="Some label"
helpText="Read the small text!" helpText="Read the small text!"
onChange={target => setEmail(target.value)} onChange={event =>setEmail(event.target.value)}
/> />
<button type="submit">Try to submit</button> <button type="submit">Try to submit</button>
</form> </form>

View File

@ -1,6 +1,13 @@
Bootstrap modal component. Bootstrap modal component.
I have no idea why example doesn't work here but you can investigate HTML code... it's required to have an element `<div id={"modal-container"}/>` somewhere on the page since modals are rendered in portals.
```js
<div id="modal-container"/>
```
I have no idea why example doesn't work here but you can investigate HTML code and Foris project.
```js ```js
import {ModalHeader, ModalBody, ModalFooter} from './Modal'; import {ModalHeader, ModalBody, ModalFooter} from './Modal';
@ -8,7 +15,7 @@ import {useState} from 'react';
const [shown, setShown] = useState(false); const [shown, setShown] = useState(false);
<> <>
<Modal shown={shown}> <Modal setShown={setShown} shown={shown}>
<ModalHeader setShown={setShown} title='Warning!'/> <ModalHeader setShown={setShown} title='Warning!'/>
<ModalBody><p>Bla bla bla...</p></ModalBody> <ModalBody><p>Bla bla bla...</p></ModalBody>
<ModalFooter> <ModalFooter>
@ -19,9 +26,8 @@ const [shown, setShown] = useState(false);
</ModalFooter> </ModalFooter>
</Modal> </Modal>
<button <button className='btn btn-secondary' onClick={()=>setShown(true)}>
className='btn btn-secondary' Show modal
onClick={()=>setShown(true)} </button>
>Show modal</button>
</> </>
``` ```

View File

@ -0,0 +1,10 @@
input[type="number"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
}

View File

@ -6,11 +6,11 @@
*/ */
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Input } from "./Input";
export const NumberInput = ({ ...props }) => <Input type="number" {...props} />; import { useConditionalTimeout } from "utils/hooks";
import { Input } from "./Input";
import "./NumberInput.css";
NumberInput.propTypes = { NumberInput.propTypes = {
/** Field label. */ /** Field label. */
@ -24,4 +24,49 @@ NumberInput.propTypes = {
PropTypes.string, PropTypes.string,
PropTypes.number, PropTypes.number,
]), ]),
/** Function called when value changes. */
onChange: PropTypes.func.isRequired,
/** Additional description dispaled to the right of input value. */
inlineText: PropTypes.string,
}; };
NumberInput.defaultProps = {
value: 0,
};
export function NumberInput({
onChange, inlineText, value, ...props
}) {
function updateValue(initialValue, difference) {
onChange({ target: { value: initialValue + difference } });
}
const enableIncrease = useConditionalTimeout({ callback: updateValue }, value, 1);
const enableDecrease = useConditionalTimeout({ callback: updateValue }, value, -1);
return (
<Input type="number" onChange={onChange} value={value} {...props}>
<div className="input-group-append">
{inlineText && <p className="input-group-text">{inlineText}</p>}
<button
type="button"
className="btn btn-outline-secondary"
onMouseDown={() => enableIncrease(true)}
onMouseUp={() => enableIncrease(false)}
aria-label="Increase"
>
<i className="fas fa-plus" />
</button>
<button
type="button"
className="btn btn-outline-secondary"
onMouseDown={() => enableDecrease(true)}
onMouseUp={() => enableDecrease(false)}
aria-label="Decrease"
>
<i className="fas fa-minus" />
</button>
</div>
</Input>
);
}

View File

@ -12,6 +12,6 @@ const [value, setValue] = useState(42);
helpText="Read the small text!" helpText="Read the small text!"
min='33' min='33'
max='54' max='54'
onChange={target => setValue(target.value)} onChange={event =>setValue(event.target.value)}
/> />
``` ```

View File

@ -12,6 +12,6 @@ const [value, setValue] = useState('secret');
value={value} value={value}
label="Some password" label="Some password"
helpText="Read the small text!" helpText="Read the small text!"
onChange={target => setValue(target.value)} onChange={event =>setValue(event.target.value)}
/> />
``` ```

View File

@ -17,7 +17,7 @@ const [value, setValue] = useState(CHOICES[0].value);
value={value} value={value}
name='some-radio' name='some-radio'
choices={CHOICES} choices={CHOICES}
onChange={event=>setValue(event.target.value)} onChange={event =>setValue(event.target.value)}
/> />
<p>Selected value: {value}</p> <p>Selected value: {value}</p>
</> </>

View File

@ -10,6 +10,6 @@ const [value, setValue] = useState('Bla bla');
value={value} value={value}
label="Some text" label="Some text"
helpText="Read the small text!" helpText="Read the small text!"
onChange={event => setValue(event.target.value)} onChange={event =>setValue(event.target.value)}
/> />
``` ```

View File

@ -7,23 +7,39 @@
import React from "react"; import React from "react";
import { render } from "customTestRender"; import { render, fireEvent, getByLabelText, wait } from "customTestRender";
import { NumberInput } from "../NumberInput"; import { NumberInput } from "../NumberInput";
describe("<NumberInput/>", () => { describe("<NumberInput/>", () => {
it("Render number input", () => { const onChangeMock = jest.fn();
let componentContainer;
beforeEach(() => {
const { container } = render( const { container } = render(
<NumberInput <NumberInput
label="Test label" label="Test label"
helpText="Some help text" helpText="Some help text"
value={1123} value={1}
onChange={() => { onChange={onChangeMock}
}}
/> />
); );
expect(container.firstChild) componentContainer = container;
.toMatchSnapshot(); });
it("Render number input", () => {
expect(componentContainer.firstChild).toMatchSnapshot();
});
it("Increase number with button", async () => {
const increaseButton = getByLabelText(componentContainer, "Increase");
fireEvent.mouseDown(increaseButton);
await wait(() => expect(onChangeMock).toHaveBeenCalledWith({"target": {"value": 2}}));
});
it("Decrease number with button", async () => {
const decreaseButton = getByLabelText(componentContainer, "Decrease");
fireEvent.mouseDown(decreaseButton);
await wait(() => expect(onChangeMock).toHaveBeenCalledWith({"target": {"value": 0}}));
}); });
}); });

View File

@ -19,8 +19,30 @@ exports[`<NumberInput/> Render number input 1`] = `
class="form-control" class="form-control"
id="1" id="1"
type="number" type="number"
value="1123" value="1"
/> />
<div
class="input-group-append"
>
<button
aria-label="Increase"
class="btn btn-outline-secondary"
type="button"
>
<i
class="fas fa-plus"
/>
</button>
<button
aria-label="Decrease"
class="btn btn-outline-secondary"
type="button"
>
<i
class="fas fa-minus"
/>
</button>
</div>
</div> </div>
<small <small
class="form-text text-muted" class="form-text text-muted"

View File

@ -1,3 +1,10 @@
/*
* 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.
*/
export const REFORIS_URL_PREFIX = process.env.LIGHTTPD ? "/reforis" : ""; export const REFORIS_URL_PREFIX = process.env.LIGHTTPD ? "/reforis" : "";
export const ForisURLs = { export const ForisURLs = {

View File

@ -9,7 +9,7 @@ import React, { useEffect, useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Spinner } from "bootstrap/Spinner"; import { Spinner } from "bootstrap/Spinner";
import { useAPIPost } from "api/hooks"; import { useAPIPost } from "api/post";
import { Prompt } from "react-router"; import { Prompt } from "react-router";
import { useForisModule, useForm } from "../hooks"; import { useForisModule, useForm } from "../hooks";

View File

@ -0,0 +1,73 @@
`<ForisForm/>` is Higher-Order Component which encapsulates entire form logic and provides with children required props.
This component structure provides comfort API and allows to create typical Foris module forms easily.
## Example of usage of `<ForisForm/>`
You can pass more forms as children.
```js
<ForisForm
ws={ws}
forisConfig={{
endpoint: API_URLs.wan,
wsModule: "wan",
}}
prepData={prepData}
prepDataToSubmit={prepDataToSubmit}
validator={validator}
>
<WANForm />
<WAN6Form />
<MACForm />
</ForisForm>
```
### Example of children forms `props` usage
```js
export default function MACForm({
formData, formErrors, setFormValue, ...props
}) {
const macSettings = formData.mac_settings;
const errors = (formErrors || {}).mac_settings || {};
return (
<>
<h3>{_("MAC")}</h3>
<CheckBox
label={_("Custom MAC address")}
checked={macSettings.custom_mac_enabled}
helpText={HELP_TEXTS.custom_mac_enabled}
onChange={setFormValue(
(value) => ({ mac_settings: { custom_mac_enabled: { $set: value } } }),
)}
{...props}
/>
{macSettings.custom_mac_enabled
? (
<TextInput
label={_("MAC address")}
value={macSettings.custom_mac || ""}
helpText={HELP_TEXTS.custom_mac}
error={errors.custom_mac}
required
onChange={setFormValue(
(value) => ({ mac_settings: { custom_mac: { $set: value } } }),
)}
{...props}
/>
)
: null}
</>
);
}
```
The <ForisForm/> passes subsequent `props` to the child components.
| Prop | Type | Description |
|----------------|--------|----------------------------------------------------------------------------|
| `formData` | object | Data returned from API. |
| `formErrors` | object | Errors returned after validation via validator. |
| `setFormValue` | func | Function for data update. It takes update rule as arg (see example above). |
| `disabled` | bool | Flag to disable form elements (during updates or loadings e.t.c.). |

View File

@ -8,7 +8,7 @@
import { useCallback, useEffect, useReducer } from "react"; import { useCallback, useEffect, useReducer } from "react";
import update from "immutability-helper"; import update from "immutability-helper";
import { useAPIGet } from "api/hooks"; import { useAPIGet } from "api/get";
import { useWSForisModule } from "webSockets/hooks"; import { useWSForisModule } from "webSockets/hooks";

View File

@ -1,5 +1,15 @@
/*
* 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.
*/
// API // API
export { useAPIGet, useAPIPost } from "./api/hooks"; export { useAPIGet } from "api/get";
export { useAPIPost } from "api/post";
export { useAPIDelete } from "api/delete";
export { useAPIPatch } from "api/patch";
// Bootstrap // Bootstrap
export { Alert } from "bootstrap/Alert"; export { Alert } from "bootstrap/Alert";
@ -37,6 +47,7 @@ export { WebSockets } from "webSockets/WebSockets";
// Utils // Utils
export { Portal } from "utils/Portal"; export { Portal } from "utils/Portal";
export { undefinedIfEmpty, withoutUndefinedKeys, onlySpecifiedKeys } from "utils/objectHelpers";
// Foris URL // Foris URL
export { ForisURLs, REFORIS_URL_PREFIX } from "forisUrls"; export { ForisURLs, REFORIS_URL_PREFIX } from "forisUrls";
@ -51,3 +62,6 @@ export {
validateMAC, validateMAC,
validateMultipleEmails, validateMultipleEmails,
} from "validations"; } from "validations";
// Alert context
export { AlertContext, AlertContextProvider } from "alertContext/AlertContext";

20
src/utils/hooks.js Normal file
View File

@ -0,0 +1,20 @@
/*
* 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 { useState, useEffect } from "react";
/** Execute callback when condition is set to true. */
export function useConditionalTimeout({ callback, timeout = 125 }, ...callbackArgs) {
const [condition, setCondition] = useState(false);
useEffect(() => {
if (condition) {
const interval = setTimeout(() => callback(...callbackArgs), timeout);
return () => setTimeout(interval);
}
}, [condition, callback, timeout, callbackArgs]);
return setCondition;
}

View File

@ -0,0 +1,35 @@
/*
* 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.
*/
/** Return undefined if object has no keys, otherwise return object. */
export function undefinedIfEmpty(instance) {
if (Object.keys(instance).length > 0) {
return instance;
}
return undefined;
}
/** Return object without keys that have undefined value. */
export function withoutUndefinedKeys(instance) {
return Object.keys(instance).reduce(
(accumulator, key) => {
if (instance[key] !== undefined) {
accumulator[key] = instance[key];
}
return accumulator;
},
{},
);
}
/** Return copy of passed object that has only desired keys. */
export function onlySpecifiedKeys(object, desiredKeys) {
return desiredKeys.reduce(
(accumulator, key) => { accumulator[key] = object[key]; return accumulator; },
{},
);
}

60
styleguide.config.js Normal file
View File

@ -0,0 +1,60 @@
/*
* 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.
*/
const path = require("path");
module.exports = {
title: "Foris JS docs",
sections: [
{
name: "Foris JS",
content: "docs/intro.md",
},
{
name: "Foris forms",
components: [
"src/form/components/ForisForm.js",
"src/form/components/alerts.js",
"src/form/components/SubmitButton.js",
],
exampleMode: "expand",
usageMode: "expand",
},
{
name: "Bootstrap components",
description: "Set of bootstrap components.",
components: "src/bootstrap/*.js",
exampleMode: "expand",
usageMode: "expand",
ignore: [
"src/bootstrap/constants.js",
],
},
],
require: [
"babel-polyfill",
path.join(__dirname, "node_modules/bootstrap/dist/css/bootstrap.min.css"),
path.join(__dirname, "node_modules/@fortawesome/fontawesome-free/css/all.min.css"),
],
webpackConfig: {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel-loader",
}, {
test: /\.css$/,
use: ["style-loader", "css-loader"],
}, {
test: /\.(jpg|jpeg|png|woff|woff2|eot|ttf|svg)$/,
loader: "file-loader",
},
],
},
},
};