Merge pull request #4650 from keithjgrant/4431-jt-form-advanced-fields

4431 jt form advanced fields

Reviewed-by: Keith Grant
             https://github.com/keithjgrant
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-09-06 23:02:08 +00:00 committed by GitHub
commit 28630cb7fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1221 additions and 339 deletions

View File

@ -2072,9 +2072,9 @@
"dev": true
},
"@types/node": {
"version": "11.13.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-11.13.4.tgz",
"integrity": "sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ==",
"version": "12.7.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.2.tgz",
"integrity": "sha512-dyYO+f6ihZEtNPDcWNR1fkoTDf3zAK3lAABDze3mz6POyIercH0lEUawUFXlG8xaQZmm1yEBON/4TsYv/laDYg==",
"dev": true
},
"@types/stack-utils": {
@ -2338,13 +2338,13 @@
"integrity": "sha512-ugTb7Lq7u4GfWSqqpwE0bGyoBZNMTok/zDBXxfEG0QM50jNlGhIWjRC1pPN7bvV1anhF+bs+/gNcRw+o55Evbg=="
},
"airbnb-prop-types": {
"version": "2.13.2",
"resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.13.2.tgz",
"integrity": "sha512-2FN6DlHr6JCSxPPi25EnqGaXC4OC3/B3k1lCd6MMYrZ51/Gf/1qDfaR+JElzWa+Tl7cY2aYOlsYJGFeQyVHIeQ==",
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.15.0.tgz",
"integrity": "sha512-jUh2/hfKsRjNFC4XONQrxo/n/3GG4Tn6Hl0WlFQN5PY9OMC9loSCoAYKnZsWaP8wEfd5xcrPloK0Zg6iS1xwVA==",
"dev": true,
"requires": {
"array.prototype.find": "^2.0.4",
"function.prototype.name": "^1.1.0",
"array.prototype.find": "^2.1.0",
"function.prototype.name": "^1.1.1",
"has": "^1.0.3",
"is-regex": "^1.0.4",
"object-is": "^1.0.1",
@ -2352,9 +2352,21 @@
"object.entries": "^1.1.0",
"prop-types": "^15.7.2",
"prop-types-exact": "^1.2.0",
"react-is": "^16.8.6"
"react-is": "^16.9.0"
},
"dependencies": {
"function.prototype.name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.1.tgz",
"integrity": "sha512-e1NzkiJuw6xqVH7YSdiW/qDHebcmMhPNe6w+4ZYYEg0VA+LaLzx37RimbPLuonHhYGFGPx1ME2nSi74JiaCr/Q==",
"dev": true,
"requires": {
"define-properties": "^1.1.3",
"function-bind": "^1.1.1",
"functions-have-names": "^1.1.1",
"is-callable": "^1.1.4"
}
},
"object.entries": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.0.tgz",
@ -2379,9 +2391,9 @@
}
},
"react-is": {
"version": "16.8.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz",
"integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==",
"version": "16.9.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz",
"integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==",
"dev": true
}
}
@ -2949,13 +2961,35 @@
"dev": true
},
"array.prototype.find": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.0.4.tgz",
"integrity": "sha1-VWpcU2LAhkgyPdrrnenRS8GGTJA=",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.1.0.tgz",
"integrity": "sha512-Wn41+K1yuO5p7wRZDl7890c3xvv5UBrfVXTVIe28rSQb6LS0fZMDrQB6PAcxQFRFy6vJTLDc3A2+3CjQdzVKRg==",
"dev": true,
"requires": {
"define-properties": "^1.1.2",
"es-abstract": "^1.7.0"
"define-properties": "^1.1.3",
"es-abstract": "^1.13.0"
},
"dependencies": {
"es-abstract": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz",
"integrity": "sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==",
"dev": true,
"requires": {
"es-to-primitive": "^1.2.0",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"is-callable": "^1.1.4",
"is-regex": "^1.0.4",
"object-keys": "^1.0.12"
}
},
"object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true
}
}
},
"array.prototype.flat": {
@ -5183,7 +5217,7 @@
},
"css-select": {
"version": "1.2.0",
"resolved": "http://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
"integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=",
"dev": true,
"requires": {
@ -5886,9 +5920,9 @@
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w=="
},
"enzyme": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.9.0.tgz",
"integrity": "sha512-JqxI2BRFHbmiP7/UFqvsjxTirWoM1HfeaJrmVSZ9a1EADKkZgdPcAuISPMpoUiHlac9J4dYt81MC5BBIrbJGMg==",
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.10.0.tgz",
"integrity": "sha512-p2yy9Y7t/PFbPoTvrWde7JIYB2ZyGC+NgTNbVEGvZ5/EyoYSr9aG/2rSbVvyNvMHEhw9/dmGUJHWtfQIEiX9pg==",
"dev": true,
"requires": {
"array.prototype.flat": "^1.2.1",
@ -5915,18 +5949,19 @@
}
},
"enzyme-adapter-react-16": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.12.1.tgz",
"integrity": "sha512-GB61gvY97XvrA6qljExGY+lgI6BBwz+ASLaRKct9VQ3ozu0EraqcNn3CcrUckSGIqFGa1+CxO5gj5is5t3lwrw==",
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.14.0.tgz",
"integrity": "sha512-7PcOF7pb4hJUvjY7oAuPGpq3BmlCig3kxXGi2kFx0YzJHppqX1K8IIV9skT1IirxXlu8W7bneKi+oQ10QRnhcA==",
"dev": true,
"requires": {
"enzyme-adapter-utils": "^1.11.0",
"enzyme-adapter-utils": "^1.12.0",
"has": "^1.0.3",
"object.assign": "^4.1.0",
"object.values": "^1.1.0",
"prop-types": "^15.7.2",
"react-is": "^16.8.6",
"react-test-renderer": "^16.0.0-0",
"semver": "^5.6.0"
"semver": "^5.7.0"
},
"dependencies": {
"prop-types": {
@ -5941,20 +5976,26 @@
}
},
"react-is": {
"version": "16.8.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz",
"integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==",
"version": "16.9.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz",
"integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==",
"dev": true
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"dev": true
}
}
},
"enzyme-adapter-utils": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.11.0.tgz",
"integrity": "sha512-0VZeoE9MNx+QjTfsjmO1Mo+lMfunucYB4wt5ficU85WB/LoetTJrbuujmHP3PJx6pSoaAuLA+Mq877x4LoxdNg==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.12.0.tgz",
"integrity": "sha512-wkZvE0VxcFx/8ZsBw0iAbk3gR1d9hK447ebnSYBf95+r32ezBq+XDSAvRErkc4LZosgH8J7et7H7/7CtUuQfBA==",
"dev": true,
"requires": {
"airbnb-prop-types": "^2.12.0",
"airbnb-prop-types": "^2.13.2",
"function.prototype.name": "^1.1.0",
"object.assign": "^4.1.0",
"object.fromentries": "^2.0.0",
@ -5974,9 +6015,9 @@
}
},
"react-is": {
"version": "16.8.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz",
"integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==",
"version": "16.9.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz",
"integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==",
"dev": true
}
}
@ -7940,6 +7981,12 @@
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
"dev": true
},
"functions-have-names": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.1.1.tgz",
"integrity": "sha512-U0kNHUoxwPNPWOJaMG7Z00d4a/qZVrFtzWJRaK8V9goaVOCXBSQSJpt3MYGNtkScKEBKovxLjnNdC9MlXwo5Pw==",
"dev": true
},
"fuzzaldrin": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fuzzaldrin/-/fuzzaldrin-2.1.0.tgz",
@ -8348,9 +8395,9 @@
}
},
"html-element-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.0.1.tgz",
"integrity": "sha512-BZSfdEm6n706/lBfXKWa4frZRZcT5k1cOusw95ijZsHlI+GdgY0v95h6IzO3iIDf2ROwq570YTwqNPqHcNMozw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.1.0.tgz",
"integrity": "sha512-iqiG3dTZmy+uUaTmHarTL+3/A2VW9ox/9uasKEZC+R/wAtUrTcRlXPSaPqsnWPfIu8wqn09jQNwMRqzL54jSYA==",
"dev": true,
"requires": {
"array-filter": "^1.0.0"
@ -8396,9 +8443,9 @@
},
"dependencies": {
"readable-stream": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.3.0.tgz",
"integrity": "sha512-EsI+s3k3XsW+fU8fQACLN59ky34AZ14LoeVZpYwmZvldCFo0r0gnelwF2TcMjLor/BTL5aDJVBMkss0dthToPw==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz",
"integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==",
"dev": true,
"requires": {
"inherits": "^2.0.3",
@ -8406,13 +8453,19 @@
"util-deprecate": "^1.0.1"
}
},
"safe-buffer": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==",
"dev": true
},
"string_decoder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.2.0.tgz",
"integrity": "sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.0"
"safe-buffer": "~5.2.0"
}
}
}
@ -11809,9 +11862,9 @@
"dev": true
},
"nearley": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.16.0.tgz",
"integrity": "sha512-Tr9XD3Vt/EujXbZBv6UAHYoLUSMQAxSsTnm9K3koXzjzNWY195NqALeyrzLZBKzAkL3gl92BcSogqrHjD8QuUg==",
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.18.0.tgz",
"integrity": "sha512-/zQOMCeJcioI0xJtd5RpBiWw2WP7wLe6vq8/3Yu0rEwgus/G/+pViX80oA87JdVgjRt2895mZSv2VfZmy4W1uw==",
"dev": true,
"requires": {
"commander": "^2.19.0",
@ -13376,32 +13429,22 @@
}
},
"react-test-renderer": {
"version": "16.8.6",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.6.tgz",
"integrity": "sha512-H2srzU5IWYT6cZXof6AhUcx/wEyJddQ8l7cLM/F7gDXYyPr4oq+vCIxJYXVGhId1J706sqziAjuOEjyNkfgoEw==",
"version": "16.9.0",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.9.0.tgz",
"integrity": "sha512-R62stB73qZyhrJo7wmCW9jgl/07ai+YzvouvCXIJLBkRlRqLx4j9RqcLEAfNfU3OxTGucqR2Whmn3/Aad6L3hQ==",
"dev": true,
"requires": {
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"react-is": "^16.8.6",
"scheduler": "^0.13.6"
"react-is": "^16.9.0",
"scheduler": "^0.15.0"
},
"dependencies": {
"react-is": {
"version": "16.8.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz",
"integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==",
"version": "16.9.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz",
"integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==",
"dev": true
},
"scheduler": {
"version": "0.13.6",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz",
"integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==",
"dev": true,
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
}
}
},
@ -14364,6 +14407,16 @@
"object-assign": "^4.1.1"
}
},
"scheduler": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.15.0.tgz",
"integrity": "sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg==",
"dev": true,
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
},
"schema-utils": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
@ -15250,14 +15303,36 @@
}
},
"string.prototype.trim": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz",
"integrity": "sha1-0E3iyJ4Tf019IG8Ia17S+ua+jOo=",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.0.tgz",
"integrity": "sha512-9EIjYD/WdlvLpn987+ctkLf0FfvBefOCuiEr2henD8X+7jfwPnyvTdmW8OJhj5p+M0/96mBdynLWkxUr+rHlpg==",
"dev": true,
"requires": {
"define-properties": "^1.1.2",
"es-abstract": "^1.5.0",
"function-bind": "^1.0.2"
"define-properties": "^1.1.3",
"es-abstract": "^1.13.0",
"function-bind": "^1.1.1"
},
"dependencies": {
"es-abstract": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz",
"integrity": "sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==",
"dev": true,
"requires": {
"es-to-primitive": "^1.2.0",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"is-callable": "^1.1.4",
"is-regex": "^1.0.4",
"object-keys": "^1.0.12"
}
},
"object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true
}
}
},
"string_decoder": {

View File

@ -33,8 +33,8 @@
"babel-plugin-macros": "^2.4.2",
"babel-plugin-styled-components": "^1.10.0",
"css-loader": "^1.0.0",
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.12.1",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
"enzyme-to-json": "^3.3.5",
"eslint": "^5.6.0",
"eslint-config-airbnb": "^17.1.0",

View File

@ -1,5 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
arrayOf,
oneOfType,
func,
number,
string,
shape,
bool,
} from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormSelect, FormSelectOption } from '@patternfly/react-core';
@ -48,12 +56,12 @@ AnsibleSelect.defaultProps = {
};
AnsibleSelect.propTypes = {
data: PropTypes.arrayOf(PropTypes.object),
id: PropTypes.string.isRequired,
isValid: PropTypes.bool,
onBlur: PropTypes.func,
onChange: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
data: arrayOf(shape()),
id: string.isRequired,
isValid: bool,
onBlur: func,
onChange: func.isRequired,
value: oneOfType([string, number]).isRequired,
};
export { AnsibleSelect as _AnsibleSelect };

View File

@ -0,0 +1,55 @@
import React, { useState } from 'react';
import { bool, string } from 'prop-types';
import styled from 'styled-components';
import { Button } from '@patternfly/react-core';
import { AngleRightIcon } from '@patternfly/react-icons';
import omitProps from '@util/omitProps';
import ExpandingContainer from './ExpandingContainer';
const Toggle = styled.div`
display: flex;
hr {
margin-left: 20px;
flex: 1 1 auto;
align-self: center;
border: 0;
border-bottom: 1px solid var(--pf-global--BorderColor--300);
}
`;
const Arrow = styled(omitProps(AngleRightIcon, 'isExpanded'))`
margin-right: -5px;
margin-left: 5px;
transition: transform 0.1s ease-out;
transform-origin: 50% 50%;
${props => props.isExpanded && `transform: rotate(90deg);`}
`;
function CollapsibleSection({ label, startExpanded, children }) {
const [isExpanded, setIsExpanded] = useState(startExpanded);
const toggle = () => setIsExpanded(!isExpanded);
return (
<div>
<Toggle>
<Button onClick={toggle}>
{label} <Arrow isExpanded={isExpanded} />
</Button>
<hr />
</Toggle>
<ExpandingContainer isExpanded={isExpanded}>
{children}
</ExpandingContainer>
</div>
);
}
CollapsibleSection.propTypes = {
label: string.isRequired,
startExpanded: bool,
};
CollapsibleSection.defaultProps = {
startExpanded: false,
};
export default CollapsibleSection;

View File

@ -0,0 +1,26 @@
import React from 'react';
import { shallow } from 'enzyme';
import CollapsibleSection from './CollapsibleSection';
describe('<CollapsibleSection>', () => {
it('should render contents', () => {
const wrapper = shallow(
<CollapsibleSection label="Advanced">foo</CollapsibleSection>
);
expect(wrapper.find('Button > *').prop('isExpanded')).toEqual(false);
expect(wrapper.find('ExpandingContainer').prop('isExpanded')).toEqual(
false
);
expect(wrapper.find('ExpandingContainer').prop('children')).toEqual('foo');
});
it('should toggle when clicked', () => {
const wrapper = shallow(
<CollapsibleSection label="Advanced">foo</CollapsibleSection>
);
expect(wrapper.find('Button > *').prop('isExpanded')).toEqual(false);
wrapper.find('Button').simulate('click');
expect(wrapper.find('Button > *').prop('isExpanded')).toEqual(true);
expect(wrapper.find('ExpandingContainer').prop('isExpanded')).toEqual(true);
});
});

View File

@ -0,0 +1,43 @@
import React, { useState, useEffect, useRef } from 'react';
import { bool } from 'prop-types';
import styled from 'styled-components';
const Container = styled.div`
margin: 15px 0;
transition: height 0.2s ease-out;
${props => props.hideOverflow && `overflow: hidden;`}
`;
function ExpandingContainer({ isExpanded, children }) {
const [contentHeight, setContentHeight] = useState(0);
const [hideOverflow, setHideOverflow] = useState(!isExpanded);
const ref = useRef(null);
useEffect(() => {
ref.current.addEventListener('transitionend', () => {
setHideOverflow(!isExpanded);
});
});
useEffect(() => {
setContentHeight(ref.current.scrollHeight);
});
const height = isExpanded ? contentHeight : '0';
return (
<Container
ref={ref}
css={`
height: ${height}px;
`}
hideOverflow={!isExpanded || hideOverflow}
>
{children}
</Container>
);
}
ExpandingContainer.propTypes = {
isExpanded: bool,
};
ExpandingContainer.defaultProps = {
isExpanded: false,
};
export default ExpandingContainer;

View File

@ -0,0 +1 @@
export { default } from './CollapsibleSection';

View File

@ -29,6 +29,8 @@ const ToolbarItem = styled(PFToolbarItem)`
}
`;
// TODO: Recommend renaming this component to avoid confusion
// with ExpandingContainer
class ExpandCollapse extends React.Component {
render() {
const { isCompact, onCompact, onExpand, i18n } = this.props;

View File

@ -0,0 +1,55 @@
import React from 'react';
import { string, func } from 'prop-types';
import { Field } from 'formik';
import { Checkbox, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
function CheckboxField({ id, name, label, tooltip, validate, ...rest }) {
return (
<Field
name={name}
validate={validate}
render={({ field }) => (
<Checkbox
aria-label={label}
label={
<span>
{label}
&nbsp;
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
</span>
}
id={id}
{...rest}
isChecked={field.value}
{...field}
onChange={(value, event) => {
field.onChange(event);
}}
/>
)}
/>
);
}
CheckboxField.propTypes = {
id: string.isRequired,
name: string.isRequired,
label: string.isRequired,
validate: func,
tooltip: string,
};
CheckboxField.defaultProps = {
validate: () => {},
tooltip: '',
};
export default CheckboxField;

View File

@ -57,7 +57,7 @@ FormField.propTypes = {
type: PropTypes.string,
validate: PropTypes.func,
isRequired: PropTypes.bool,
tooltip: PropTypes.string,
tooltip: PropTypes.node,
};
FormField.defaultProps = {

View File

@ -1 +1,2 @@
export { default } from './FormField';
export { default as CheckboxField } from './CheckboxField';

View File

@ -0,0 +1,82 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon } from '@patternfly/react-icons';
import { InstanceGroupsAPI } from '@api';
import Lookup from '@components/Lookup';
const getInstanceGroups = async params => InstanceGroupsAPI.read(params);
class InstanceGroupsLookup extends React.Component {
render() {
const { value, tooltip, onChange, className, i18n } = this.props;
/*
Wrapping <div> added to workaround PF bug:
https://github.com/patternfly/patternfly-react/issues/2855
*/
return (
<div className={className}>
<FormGroup
label={
<Fragment>
{i18n._(t`Instance Groups`)}{' '}
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
</Fragment>
}
fieldId="org-instance-groups"
>
<Lookup
id="org-instance-groups"
lookupHeader={i18n._(t`Instance Groups`)}
name="instanceGroups"
value={value}
onLookupSave={onChange}
getItems={getInstanceGroups}
multiple
columns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
},
]}
sortedColumnKey="name"
/>
</FormGroup>
</div>
);
}
}
InstanceGroupsLookup.propTypes = {
value: PropTypes.arrayOf(PropTypes.object).isRequired,
tooltip: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
InstanceGroupsLookup.defaultProps = {
tooltip: '',
};
export default withI18n()(InstanceGroupsLookup);

View File

@ -27,6 +27,7 @@ class InventoriesLookup extends React.Component {
)}
</Fragment>
}
isRequired={required}
fieldId="inventories-lookup"
>
<Lookup

View File

@ -1 +1,3 @@
export { default } from './Lookup';
export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
export { default as InventoriesLookup } from './InventoriesLookup';

View File

@ -1,7 +1,5 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom';
import { shape, number, string, func, arrayOf, oneOfType } from 'prop-types';
import { Chip, ChipGroup } from '@components/Chip';
import {
Dropdown as PFDropdown,
@ -15,11 +13,13 @@ const InputGroup = styled.div`
border: 1px solid black;
margin-top: 2px;
`;
const TextInput = styled(PFTextInput)`
border: none;
width: 100%;
padding-left: 8px;
`;
const Dropdown = styled(PFDropdown)`
width: 100%;
.pf-c-dropdown__toggle.pf-m-plain {
@ -38,15 +38,27 @@ const Dropdown = styled(PFDropdown)`
}
`;
const Item = shape({
id: oneOfType([number, string]).isRequired,
name: string.isRequired,
});
class MultiSelect extends Component {
static propTypes = {
associatedItems: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
})
).isRequired,
onAddNewItem: PropTypes.func.isRequired,
onRemoveItem: PropTypes.func.isRequired,
associatedItems: arrayOf(Item).isRequired,
options: arrayOf(Item),
onAddNewItem: func,
onRemoveItem: func,
onChange: func,
createNewItem: func,
};
static defaultProps = {
onAddNewItem: () => {},
onRemoveItem: () => {},
onChange: () => {},
options: [],
createNewItem: null,
};
constructor(props) {
@ -61,6 +73,7 @@ class MultiSelect extends Component {
this.handleSelection = this.handleSelection.bind(this);
this.removeChip = this.removeChip.bind(this);
this.handleClick = this.handleClick.bind(this);
this.createNewItem = this.createNewItem.bind(this);
}
componentDidMount() {
@ -73,11 +86,7 @@ class MultiSelect extends Component {
getInitialChipItems() {
const { associatedItems } = this.props;
return associatedItems.map(item => ({
name: item.name,
id: item.id,
organization: item.organization,
}));
return associatedItems.map(item => ({ ...item }));
}
handleClick(e, option) {
@ -92,19 +101,33 @@ class MultiSelect extends Component {
handleSelection(e, item) {
const { chipItems } = this.state;
const { onAddNewItem } = this.props;
const { onAddNewItem, onChange } = this.props;
e.preventDefault();
const items = chipItems.concat({ name: item.name, id: item.id });
this.setState({
chipItems: chipItems.concat({ name: item.name, id: item.id }),
chipItems: items,
isExpanded: false,
});
onAddNewItem(item);
onChange(items);
}
createNewItem(name) {
const { createNewItem } = this.props;
if (createNewItem) {
return createNewItem(name);
}
return {
id: Math.random(),
name,
};
}
handleAddItem(event) {
const { input, chipItems } = this.state;
const { onAddNewItem } = this.props;
const { options, onAddNewItem, onChange } = this.props;
const match = options.find(item => item.name === input);
const isIncluded = chipItems.some(chipItem => chipItem.name === input);
if (!input) {
@ -118,30 +141,35 @@ class MultiSelect extends Component {
this.setState({ input: '', isExpanded: false });
return;
}
if (event.key === 'Enter') {
const isNewItem = !match || !chipItems.find(item => item.id === match.id);
if (event.key === 'Enter' && isNewItem) {
event.preventDefault();
const items = chipItems.concat({ name: input, id: input });
const newItem = match || this.createNewItem(input);
this.setState({
chipItems: chipItems.concat({ name: input, id: input }),
chipItems: items,
isExpanded: false,
input: '',
});
onAddNewItem(input);
} else if (event.key === 'Tab') {
this.setState({ input: '' });
onAddNewItem(newItem);
onChange(items);
} else if (!isNewItem || event.key === 'Tab') {
this.setState({ isExpanded: false, input: '' });
}
}
handleInputChange(e) {
this.setState({ input: e, isExpanded: true });
handleInputChange(value) {
this.setState({ input: value, isExpanded: true });
}
removeChip(e, item) {
const { onRemoveItem } = this.props;
const { onRemoveItem, onChange } = this.props;
const { chipItems } = this.state;
const chips = chipItems.filter(chip => chip.id !== item.id);
this.setState({ chipItems: chips });
onRemoveItem(item);
onChange(chips);
e.preventDefault();
}
@ -214,5 +242,4 @@ class MultiSelect extends Component {
);
}
}
export { MultiSelect as _MultiSelect };
export default withI18n()(withRouter(MultiSelect));
export default MultiSelect;

View File

@ -1,7 +1,7 @@
import React from 'react';
import { mount } from 'enzyme';
import { sleep } from '@testUtils/testUtils';
import MultiSelect, { _MultiSelect } from './MultiSelect';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import MultiSelect from './MultiSelect';
describe('<MultiSelect />', () => {
const associatedItems = [
@ -11,11 +11,7 @@ describe('<MultiSelect />', () => {
const options = [{ name: 'Angry', id: 3 }, { name: 'Potato', id: 4 }];
test('Initially render successfully', () => {
const getInitialChipItems = jest.spyOn(
_MultiSelect.prototype,
'getInitialChipItems'
);
const wrapper = mountWithContexts(
const wrapper = mount(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={jest.fn()}
@ -25,11 +21,11 @@ describe('<MultiSelect />', () => {
);
const component = wrapper.find('MultiSelect');
expect(getInitialChipItems).toBeCalled();
expect(component.state().chipItems.length).toBe(2);
});
test('handleSelection add item to chipItems', async () => {
const wrapper = mountWithContexts(
const wrapper = mount(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={jest.fn()}
@ -45,12 +41,15 @@ describe('<MultiSelect />', () => {
await sleep(1);
expect(component.state().chipItems.length).toBe(2);
});
test('handleAddItem adds a chip only when Tab is pressed', () => {
const onAddNewItem = jest.fn();
const wrapper = mountWithContexts(
const onChange = jest.fn();
const wrapper = mount(
<MultiSelect
onAddNewItem={onAddNewItem}
onRemoveItem={jest.fn()}
onChange={onChange}
associatedItems={associatedItems}
options={options}
/>
@ -68,14 +67,18 @@ describe('<MultiSelect />', () => {
expect(component.state().input.length).toBe(0);
expect(component.state().isExpanded).toBe(false);
expect(onAddNewItem).toBeCalled();
expect(onChange).toBeCalled();
});
test('removeChip removes chip properly', () => {
const onRemoveItem = jest.fn();
const onChange = jest.fn();
const wrapper = mountWithContexts(
const wrapper = mount(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={onRemoveItem}
onChange={onChange}
associatedItems={associatedItems}
options={options}
/>
@ -89,5 +92,6 @@ describe('<MultiSelect />', () => {
.removeChip(event, { name: 'Foo', id: 1, organization: 1 });
expect(component.state().chipItems.length).toBe(1);
expect(onRemoveItem).toBeCalled();
expect(onChange).toBeCalled();
});
});

View File

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { func, string } from 'prop-types';
import MultiSelect from './MultiSelect';
function arrayToString(tags) {
return tags.map(v => v.name).join(',');
}
function stringToArray(value) {
return value
.split(',')
.filter(val => !!val)
.map(val => ({
id: val,
name: val,
}));
}
/*
* Adapter providing a simplified API to a MultiSelect. The value
* is a comma-separated string.
*/
function TagMultiSelect({ onChange, value }) {
const [options, setOptions] = useState(stringToArray(value));
return (
<MultiSelect
onChange={val => {
onChange(arrayToString(val));
}}
onAddNewItem={newItem => {
if (!options.find(o => o.name === newItem.name)) {
setOptions(options.concat(newItem));
}
}}
associatedItems={stringToArray(value)}
options={options}
createNewItem={name => ({ id: name, name })}
/>
);
}
TagMultiSelect.propTypes = {
onChange: func.isRequired,
value: string.isRequired,
};
export default TagMultiSelect;

View File

@ -0,0 +1,35 @@
import React from 'react';
import { mount } from 'enzyme';
import TagMultiSelect from './TagMultiSelect';
describe('<TagMultiSelect />', () => {
it('should render MultiSelect', () => {
const wrapper = mount(
<TagMultiSelect value="foo,bar" onChange={jest.fn()} />
);
expect(wrapper.find('MultiSelect').prop('options')).toEqual([
{ id: 'foo', name: 'foo' },
{ id: 'bar', name: 'bar' },
]);
});
it('should not treat empty string as an option', () => {
const wrapper = mount(<TagMultiSelect value="" onChange={jest.fn()} />);
expect(wrapper.find('MultiSelect').prop('options')).toEqual([]);
});
it('should trigger onChange', () => {
const onChange = jest.fn();
const wrapper = mount(
<TagMultiSelect value="foo,bar" onChange={onChange} />
);
const select = wrapper.find('MultiSelect');
select.invoke('onChange')([
{ name: 'foo' },
{ name: 'bar' },
{ name: 'baz' },
]);
expect(onChange).toHaveBeenCalledWith('foo,bar,baz');
});
});

View File

@ -1 +1,2 @@
export { default } from './MultiSelect';
export { default as TagMultiSelect } from './TagMultiSelect';

View File

@ -1,76 +0,0 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon } from '@patternfly/react-icons';
import { InstanceGroupsAPI } from '@api';
import Lookup from '@components/Lookup';
const getInstanceGroups = async params => InstanceGroupsAPI.read(params);
class InstanceGroupsLookup extends React.Component {
render() {
const { value, tooltip, onChange, i18n } = this.props;
return (
<FormGroup
label={
<Fragment>
{i18n._(t`Instance Groups`)}{' '}
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
</Fragment>
}
fieldId="org-instance-groups"
>
<Lookup
id="org-instance-groups"
lookupHeader={i18n._(t`Instance Groups`)}
name="instanceGroups"
value={value}
onLookupSave={onChange}
getItems={getInstanceGroups}
multiple
columns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
},
]}
sortedColumnKey="name"
/>
</FormGroup>
);
}
}
InstanceGroupsLookup.propTypes = {
value: PropTypes.arrayOf(PropTypes.object).isRequired,
tooltip: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
InstanceGroupsLookup.defaultProps = {
tooltip: '',
};
export default withI18n()(InstanceGroupsLookup);

View File

@ -15,10 +15,9 @@ import FormRow from '@components/FormRow';
import FormField from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import AnsibleSelect from '@components/AnsibleSelect';
import { InstanceGroupsLookup } from '@components/Lookup/';
import { required, minMaxValue } from '@util/validators';
import InstanceGroupsLookup from './InstanceGroupsLookup';
class OrganizationForm extends Component {
constructor(props) {
super(props);

View File

@ -1,2 +1,2 @@
export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
/* eslint-disable-next-line import/prefer-default-export */
export { default as OrganizationForm } from './OrganizationForm';

View File

@ -17,23 +17,30 @@ function JobTemplateAdd({ history, i18n }) {
const [formSubmitError, setFormSubmitError] = useState(null);
async function handleSubmit(values) {
const { newLabels, removedLabels } = values;
delete values.newLabels;
delete values.removedLabels;
const {
newLabels,
removedLabels,
addedInstanceGroups,
removedInstanceGroups,
...remainingValues
} = values;
setFormSubmitError(null);
try {
const {
data: { id, type },
} = await JobTemplatesAPI.create(values);
await Promise.all([submitLabels(id, newLabels, removedLabels)]);
} = await JobTemplatesAPI.create(remainingValues);
await Promise.all([
submitLabels(id, newLabels, removedLabels),
submitInstanceGroups(id, addedInstanceGroups, removedInstanceGroups),
]);
history.push(`/templates/${type}/${id}/details`);
} catch (error) {
setFormSubmitError(error);
}
}
async function submitLabels(id, newLabels = [], removedLabels = []) {
function submitLabels(id, newLabels = [], removedLabels = []) {
const disassociationPromises = removedLabels.map(label =>
JobTemplatesAPI.disassociateLabel(id, label)
);
@ -44,12 +51,18 @@ function JobTemplateAdd({ history, i18n }) {
.filter(label => label.organization)
.map(label => JobTemplatesAPI.generateLabel(id, label));
const results = await Promise.all([
return Promise.all([
...disassociationPromises,
...associationPromises,
...creationPromises,
]);
return results;
}
function submitInstanceGroups(templateId, addedGroups = []) {
const associatePromises = addedGroups.map(group =>
JobTemplatesAPI.associateInstanceGroup(templateId, group.id)
);
return Promise.all(associatePromises);
}
function handleCancel() {

View File

@ -6,6 +6,27 @@ import { JobTemplatesAPI, LabelsAPI } from '@api';
jest.mock('@api');
const jobTemplateData = {
name: 'Foo',
description: 'Baz',
job_type: 'run',
inventory: 1,
project: 2,
playbook: 'Bar',
forks: 0,
limit: '',
verbosity: '0',
job_slice_count: 1,
timeout: 0,
job_tags: '',
skip_tags: '',
diff_mode: false,
allow_callbacks: false,
allow_simultaneous: false,
use_fact_cache: false,
host_config_key: '',
};
describe('<JobTemplateAdd />', () => {
const defaultProps = {
description: '',
@ -63,14 +84,6 @@ describe('<JobTemplateAdd />', () => {
});
test('handleSubmit should post to api', async done => {
const jobTemplateData = {
description: 'Baz',
inventory: 1,
job_type: 'run',
name: 'Foo',
playbook: 'Bar',
project: 2,
};
JobTemplatesAPI.create.mockResolvedValueOnce({
data: {
id: 1,
@ -99,14 +112,6 @@ describe('<JobTemplateAdd />', () => {
const history = {
push: jest.fn(),
};
const jobTemplateData = {
description: 'Baz',
inventory: 1,
job_type: 'run',
name: 'Foo',
playbook: 'Bar',
project: 2,
};
JobTemplatesAPI.create.mockResolvedValueOnce({
data: {
id: 1,
@ -118,7 +123,9 @@ describe('<JobTemplateAdd />', () => {
context: { router: { history } },
});
await wrapper.find('JobTemplateForm').prop('handleSubmit')(jobTemplateData);
await wrapper.find('JobTemplateForm').invoke('handleSubmit')(
jobTemplateData
);
await sleep(0);
expect(history.push).toHaveBeenCalledWith(
'/templates/job_template/1/details'
@ -134,7 +141,7 @@ describe('<JobTemplateAdd />', () => {
context: { router: { history } },
});
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(history.push).toHaveBeenCalledWith('/templates');
done();
});

View File

@ -102,18 +102,22 @@ class JobTemplateEdit extends Component {
}
async handleSubmit(values) {
const { template, history } = this.props;
const {
template: { id },
history,
} = this.props;
const { newLabels, removedLabels } = values;
delete values.newLabels;
delete values.removedLabels;
newLabels,
removedLabels,
addedInstanceGroups,
removedInstanceGroups,
...remainingValues
} = values;
this.setState({ formSubmitError: null });
try {
await JobTemplatesAPI.update(id, values);
await Promise.all([this.submitLabels(newLabels, removedLabels)]);
await JobTemplatesAPI.update(template.id, remainingValues);
await Promise.all([
this.submitLabels(newLabels, removedLabels),
this.submitInstanceGroups(addedInstanceGroups, removedInstanceGroups),
]);
history.push(this.detailsUrl);
} catch (formSubmitError) {
this.setState({ formSubmitError });
@ -121,18 +125,16 @@ class JobTemplateEdit extends Component {
}
async submitLabels(newLabels = [], removedLabels = []) {
const {
template: { id },
} = this.props;
const { template } = this.props;
const disassociationPromises = removedLabels.map(label =>
JobTemplatesAPI.disassociateLabel(id, label)
JobTemplatesAPI.disassociateLabel(template.id, label)
);
const associationPromises = newLabels
.filter(label => !label.organization)
.map(label => JobTemplatesAPI.associateLabel(id, label));
.map(label => JobTemplatesAPI.associateLabel(template.id, label));
const creationPromises = newLabels
.filter(label => label.organization)
.map(label => JobTemplatesAPI.generateLabel(id, label));
.map(label => JobTemplatesAPI.generateLabel(template.id, label));
const results = await Promise.all([
...disassociationPromises,
@ -142,6 +144,17 @@ class JobTemplateEdit extends Component {
return results;
}
async submitInstanceGroups(addedGroups, removedGroups) {
const { template } = this.props;
const associatePromises = addedGroups.map(group =>
JobTemplatesAPI.associateInstanceGroup(template.id, group.id)
);
const disassociatePromises = removedGroups.map(group =>
JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id)
);
return Promise.all([...associatePromises, ...disassociatePromises]);
}
handleCancel() {
const { history } = this.props;
history.push(this.detailsUrl);

View File

@ -15,6 +15,18 @@ const mockJobTemplate = {
project: 3,
playbook: 'Baz',
type: 'job_template',
forks: 0,
limit: '',
verbosity: '0',
job_slice_count: 1,
timeout: 0,
job_tags: '',
skip_tags: '',
diff_mode: false,
allow_callbacks: false,
allow_simultaneous: false,
use_fact_cache: false,
host_config_key: '',
summary_fields: {
user_capabilities: {
edit: true,
@ -92,6 +104,32 @@ const mockRelatedProjectPlaybooks = [
'vault.yml',
];
const mockInstanceGroups = [
{
id: 1,
type: 'instance_group',
url: '/api/v2/instance_groups/1/',
related: {
jobs: '/api/v2/instance_groups/1/jobs/',
instances: '/api/v2/instance_groups/1/instances/',
},
name: 'tower',
capacity: 59,
committed_capacity: 0,
consumed_capacity: 0,
percent_capacity_remaining: 100.0,
jobs_running: 0,
jobs_total: 3,
instances: 1,
controller: null,
is_controller: false,
is_isolated: false,
policy_instance_percentage: 100,
policy_instance_minimum: 0,
policy_instance_list: [],
},
];
JobTemplatesAPI.readCredentials.mockResolvedValue({
data: mockRelatedCredentials,
});
@ -101,12 +139,25 @@ ProjectsAPI.readPlaybooks.mockResolvedValue({
LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
describe('<JobTemplateEdit />', () => {
test('initially renders successfully', async done => {
beforeEach(() => {
LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
JobTemplatesAPI.readCredentials.mockResolvedValue({
data: mockRelatedCredentials,
});
JobTemplatesAPI.readInstanceGroups.mockReturnValue({
data: { results: mockInstanceGroups },
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', async () => {
const wrapper = mountWithContexts(
<JobTemplateEdit template={mockJobTemplate} />
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
done();
});
test('handleSubmit should call api update', async done => {

View File

@ -4,30 +4,45 @@ import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { withFormik, Field } from 'formik';
import { Form, FormGroup, Tooltip, Card } from '@patternfly/react-core';
import {
Form,
FormGroup,
Tooltip,
Card,
Switch,
Checkbox,
TextInput,
} from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import AnsibleSelect from '@components/AnsibleSelect';
import MultiSelect from '@components/MultiSelect';
import MultiSelect, { TagMultiSelect } from '@components/MultiSelect';
import FormActionGroup from '@components/FormActionGroup';
import FormField from '@components/FormField';
import FormField, { CheckboxField } from '@components/FormField';
import FormRow from '@components/FormRow';
import CollapsibleSection from '@components/CollapsibleSection';
import { required } from '@util/validators';
import styled from 'styled-components';
import { JobTemplate } from '@types';
import InventoriesLookup from './InventoriesLookup';
import { InventoriesLookup, InstanceGroupsLookup } from '@components/Lookup';
import ProjectLookup from './ProjectLookup';
import { LabelsAPI, ProjectsAPI } from '@api';
import { JobTemplatesAPI, LabelsAPI, ProjectsAPI } from '@api';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
const QSConfig = {
page: 1,
page_size: 200,
order_by: 'name',
};
const GridFormGroup = styled(FormGroup)`
& > label {
grid-column: 1 / -1;
}
&& {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
`;
class JobTemplateForm extends Component {
static propTypes = {
@ -49,6 +64,7 @@ class JobTemplateForm extends Component {
labels: { results: [] },
project: null,
},
isNew: true,
},
};
@ -63,31 +79,45 @@ class JobTemplateForm extends Component {
project: props.template.summary_fields.project,
inventory: props.template.summary_fields.inventory,
relatedProjectPlaybooks: props.relatedProjectPlaybooks,
relatedInstanceGroups: [],
allowCallbacks: !!props.template.host_config_key,
};
this.handleNewLabel = this.handleNewLabel.bind(this);
this.loadLabels = this.loadLabels.bind(this);
this.removeLabel = this.removeLabel.bind(this);
this.handleProjectValidation = this.handleProjectValidation.bind(this);
this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this);
this.loadRelatedProjectPlaybooks = this.loadRelatedProjectPlaybooks.bind(
this
);
this.handleInstanceGroupsChange = this.handleInstanceGroupsChange.bind(
this
);
}
async componentDidMount() {
componentDidMount() {
const { validateField } = this.props;
await this.loadLabels(QSConfig);
validateField('project');
this.setState({ contentError: null, hasContentLoading: true });
Promise.all([this.loadLabels(), this.loadRelatedInstanceGroups()]).then(
() => {
this.setState({ hasContentLoading: false });
validateField('project');
}
);
}
async loadLabels(QueryConfig) {
async loadLabels() {
// This function assumes that the user has no more than 400
// labels. For the vast majority of users this will be more thans
// enough.This can be updated to allow more than 400 labels if we
// enough. This can be updated to allow more than 400 labels if we
// decide it is necessary.
this.setState({ contentError: null, hasContentLoading: true });
let loadedLabels;
try {
const { data } = await LabelsAPI.read(QueryConfig);
const { data } = await LabelsAPI.read({
page: 1,
page_size: 200,
order_by: 'name',
});
loadedLabels = [...data.results];
if (data.next && data.next.includes('page=2')) {
const {
@ -102,8 +132,22 @@ class JobTemplateForm extends Component {
this.setState({ loadedLabels });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
async loadRelatedInstanceGroups() {
const { template } = this.props;
if (!template.id) {
return;
}
try {
const { data } = await JobTemplatesAPI.readInstanceGroups(template.id);
this.setState({
initialInstanceGroups: data.results,
relatedInstanceGroups: [...data.results],
});
} catch (err) {
this.setState({ contentError: err });
}
}
@ -116,23 +160,6 @@ class JobTemplateForm extends Component {
newLabel => newLabel.name !== label
);
this.setState({ newLabels: filteredLabels });
} else if (typeof label === 'string') {
setFieldValue('newLabels', [
...newLabels,
{
name: label,
organization: template.summary_fields.inventory.organization_id,
},
]);
this.setState({
newLabels: [
...newLabels,
{
name: label,
organization: template.summary_fields.inventory.organization_id,
},
],
});
} else {
setFieldValue('newLabels', [
...newLabels,
@ -141,7 +168,12 @@ class JobTemplateForm extends Component {
this.setState({
newLabels: [
...newLabels,
{ name: label.name, associate: true, id: label.id },
{
name: label.name,
associate: true,
id: label.id,
organization: template.summary_fields.inventory.organization_id,
},
],
});
}
@ -201,6 +233,30 @@ class JobTemplateForm extends Component {
};
}
handleInstanceGroupsChange(relatedInstanceGroups) {
const { setFieldValue } = this.props;
const { initialInstanceGroups } = this.state;
let added = [];
const removed = [];
if (initialInstanceGroups) {
initialInstanceGroups.forEach(group => {
if (!relatedInstanceGroups.find(g => g.id === group.id)) {
removed.push(group);
}
});
relatedInstanceGroups.forEach(group => {
if (!initialInstanceGroups.find(g => g.id === group.id)) {
added.push(group);
}
});
} else {
added = relatedInstanceGroups;
}
setFieldValue('addedInstanceGroups', added);
setFieldValue('removedInstanceGroups', removed);
this.setState({ relatedInstanceGroups });
}
render() {
const {
loadedLabels,
@ -209,6 +265,8 @@ class JobTemplateForm extends Component {
inventory,
project,
relatedProjectPlaybooks = [],
relatedInstanceGroups,
allowCallbacks,
} = this.state;
const {
handleCancel,
@ -255,6 +313,20 @@ class JobTemplateForm extends Component {
]
);
const verbosityOptions = [
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
{ value: '1', key: '1', label: i18n._(t`1 (Verbose)`) },
{ value: '2', key: '2', label: i18n._(t`2 (More Verbose)`) },
{ value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
{ value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
];
let callbackUrl;
if (template && template.related) {
const { origin } = document.location;
const path = template.related.callback || `${template.url}callback`;
callbackUrl = `${origin}${path}`;
}
if (hasContentLoading) {
return (
<Card className="awx-c-card">
@ -270,7 +342,7 @@ class JobTemplateForm extends Component {
</Card>
);
}
const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div';
return (
<Form autoComplete="off" onSubmit={handleSubmit}>
<FormRow>
@ -293,8 +365,7 @@ class JobTemplateForm extends Component {
validate={required(null, i18n)}
onBlur={handleBlur}
render={({ form, field }) => {
const isValid =
form && (!form.touched[field.name] || !form.errors[field.name]);
const isValid = !form.touched.job_type || !form.errors.job_type;
return (
<FormGroup
fieldId="template-job-type"
@ -314,7 +385,7 @@ class JobTemplateForm extends Component {
</Tooltip>
<AnsibleSelect
isValid={isValid}
id="job_type"
id="template-job-type"
data={jobTypeOptions}
{...field}
/>
@ -341,34 +412,29 @@ class JobTemplateForm extends Component {
<Field
name="project"
validate={this.handleProjectValidation()}
render={({ form }) => {
const isValid = form && !form.errors.project;
return (
<ProjectLookup
helperTextInvalid={form.errors.project}
isValid={isValid}
value={project}
onBlur={handleBlur}
tooltip={i18n._(t`Select the project containing the playbook
render={({ form }) => (
<ProjectLookup
helperTextInvalid={form.errors.project}
isValid={!form.errors.project}
value={project}
onBlur={handleBlur}
tooltip={i18n._(t`Select the project containing the playbook
you want this job to execute.`)}
onChange={value => {
this.loadRelatedProjectPlaybooks(value.id);
form.setFieldValue('project', value.id);
form.setFieldTouched('project');
this.setState({ project: value });
}}
required
/>
);
}}
onChange={value => {
this.loadRelatedProjectPlaybooks(value.id);
form.setFieldValue('project', value.id);
this.setState({ project: value });
}}
required
/>
)}
/>
<Field
name="playbook"
validate={required(i18n._(t`Select a value for this field`), i18n)}
onBlur={handleBlur}
render={({ field, form }) => {
const isValid =
form && (!form.touched[field.name] || !form.errors[field.name]);
const isValid = !form.touched.playbook || !form.errors.playbook;
return (
<FormGroup
fieldId="template-playbook"
@ -386,7 +452,7 @@ class JobTemplateForm extends Component {
<QuestionCircleIcon />
</Tooltip>
<AnsibleSelect
id="playbook"
id="template-playbook"
data={playbookOptions}
isValid={isValid}
form={form}
@ -415,6 +481,251 @@ class JobTemplateForm extends Component {
/>
</FormGroup>
</FormRow>
<AdvancedFieldsWrapper label="Advanced">
<FormRow>
<FormField
id="template-forks"
name="forks"
type="number"
min="0"
label={i18n._(t`Forks`)}
tooltip={
<span>
{i18n._(t`The number of parallel or simultaneous
processes to use while executing the playbook. An empty value,
or a value less than 1 will use the Ansible default which is
usually 5. The default number of forks can be overwritten
with a change to`)}{' '}
<code>ansible.cfg</code>.{' '}
{i18n._(t`Refer to the Ansible documentation for details
about the configuration file.`)}
</span>
}
/>
<FormField
id="template-limit"
name="limit"
type="text"
label={i18n._(t`Limit`)}
tooltip={i18n._(t`Provide a host pattern to further constrain
the list of hosts that will be managed or affected by the
playbook. Multiple patterns are allowed. Refer to Ansible
documentation for more information and examples on patterns.`)}
/>
<Field
name="verbosity"
render={({ field }) => (
<FormGroup
fieldId="template-verbosity"
label={i18n._(t`Verbosity`)}
>
<Tooltip
position="right"
content={i18n._(t`Control the level of output ansible will
produce as the playbook executes.`)}
>
<QuestionCircleIcon />
</Tooltip>
<AnsibleSelect
id="template-verbosity"
data={verbosityOptions}
{...field}
/>
</FormGroup>
)}
/>
<FormField
id="template-job-slicing"
name="job_slice_count"
type="number"
min="1"
label={i18n._(t`Job Slicing`)}
tooltip={i18n._(t`Divide the work done by this job template
into the specified number of job slices, each running the
same tasks against a portion of the inventory.`)}
/>
<FormField
id="template-timeout"
name="timeout"
type="number"
min="0"
label={i18n._(t`Timeout`)}
tooltip={i18n._(t`The amount of time (in seconds) to run
before the task is canceled. Defaults to 0 for no job
timeout.`)}
/>
<Field
name="diff_mode"
render={({ field, form }) => (
<FormGroup
fieldId="template-show-changes"
label={i18n._(t`Show Changes`)}
>
<Tooltip
position="right"
content={i18n._(t`If enabled, show the changes made by
Ansible tasks, where supported. This is equivalent
to Ansible&#x2019s --diff mode.`)}
>
<QuestionCircleIcon />
</Tooltip>
<div>
<Switch
id="template-show-changes"
label={field.value ? i18n._(t`On`) : i18n._(t`Off`)}
isChecked={field.value}
onChange={checked =>
form.setFieldValue(field.name, checked)
}
/>
</div>
</FormGroup>
)}
/>
</FormRow>
<InstanceGroupsLookup
css="margin-top: 20px"
value={relatedInstanceGroups}
onChange={this.handleInstanceGroupsChange}
tooltip={i18n._(
t`Select the Instance Groups for this Organization to run on.`
)}
/>
<Field
name="job_tags"
render={({ field, form }) => (
<FormGroup
label={i18n._(t`Job Tags`)}
css="margin-top: 20px"
fieldId="template-job-tags"
>
<Tooltip
position="right"
content={i18n._(t`Tags are useful when you have a large
playbook, and you want to run a specific part of a
play or task. Use commas to separate multiple tags.
Refer to Ansible Tower documentation for details on
the usage of tags.`)}
>
<QuestionCircleIcon />
</Tooltip>
<TagMultiSelect
value={field.value}
onChange={value => form.setFieldValue(field.name, value)}
/>
</FormGroup>
)}
/>
<Field
name="skip_tags"
render={({ field, form }) => (
<FormGroup
label={i18n._(t`Skip Tags`)}
css="margin-top: 20px"
fieldId="template-skip-tags"
>
<Tooltip
position="right"
content={i18n._(t`Skip tags are useful when you have a
large playbook, and you want to skip specific parts of a
play or task. Use commas to separate multiple tags. Refer
to Ansible Tower documentation for details on the usage
of tags.`)}
>
<QuestionCircleIcon />
</Tooltip>
<TagMultiSelect
value={field.value}
onChange={value => form.setFieldValue(field.name, value)}
/>
</FormGroup>
)}
/>
<GridFormGroup
fieldId="template-option-checkboxes"
isInline
label={i18n._(t`Options`)}
css="margin-top: 20px"
>
<CheckboxField
id="option-privilege-escalation"
name="become_enabled"
label={i18n._(t`Privilege Escalation`)}
tooltip={i18n._(
t`If enabled, run this playbook as an administrator.`
)}
/>
<Checkbox
aria-label={i18n._(t`Provisioning Callbacks`)}
label={
<span>
{i18n._(t`Provisioning Callbacks`)}
&nbsp;
<Tooltip
position="right"
content={i18n._(
t`Enables creation of a provisioning callback URL. Using
the URL a host can contact BRAND_NAME and request a
configuration update using this job template.`
)}
>
<QuestionCircleIcon />
</Tooltip>
</span>
}
id="option-callbacks"
isChecked={allowCallbacks}
onChange={checked => {
this.setState({ allowCallbacks: checked });
}}
/>
<CheckboxField
id="option-concurrent"
name="allow_simultaneous"
label={i18n._(t`Concurrent Jobs`)}
tooltip={i18n._(
t`If enabled, simultaneous runs of this job template will
be allowed.`
)}
/>
<CheckboxField
id="option-fact-cache"
name="use_fact_cache"
label={i18n._(t`Fact Cache`)}
tooltip={i18n._(
t`If enabled, use cached facts if available and store
discovered facts in the cache.`
)}
/>
</GridFormGroup>
<div
css={`
${allowCallbacks ? '' : 'display: none'}
margin-top: 20px;
`}
>
<FormRow>
{callbackUrl && (
<FormGroup
label={i18n._(t`Provisioning Callback URL`)}
fieldId="template-callback-url"
>
<TextInput
id="template-callback-url"
isDisabled
value={callbackUrl}
/>
</FormGroup>
)}
<FormField
id="template-host-config-key"
name="host_config_key"
label={i18n._(t`Host Config Key`)}
validate={allowCallbacks ? required(null, i18n) : null}
/>
</FormRow>
</div>
</AdvancedFieldsWrapper>
<FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} />
</Form>
);
@ -429,8 +740,21 @@ const FormikApp = withFormik({
description = '',
job_type = 'run',
inventory = '',
playbook = '',
project = '',
playbook = '',
forks,
limit,
verbosity,
job_slice_count,
timeout,
diff_mode,
job_tags,
skip_tags,
become_enabled,
allow_callbacks,
allow_simultaneous,
use_fact_cache,
host_config_key,
summary_fields = { labels: { results: [] } },
} = { ...template };
@ -442,6 +766,19 @@ const FormikApp = withFormik({
project: project || '',
playbook: playbook || '',
labels: summary_fields.labels.results,
forks: forks || 0,
limit: limit || '',
verbosity: verbosity || '0',
job_slice_count: job_slice_count || 1,
timeout: timeout || 0,
diff_mode: diff_mode || false,
job_tags: job_tags || '',
skip_tags: skip_tags || '',
become_enabled: become_enabled || false,
allow_callbacks: allow_callbacks || false,
allow_simultaneous: allow_simultaneous || false,
use_fact_cache: use_fact_cache || false,
host_config_key: host_config_key || '',
};
},
handleSubmit: (values, bag) => bag.props.handleSubmit(values),

View File

@ -2,7 +2,7 @@ import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import JobTemplateForm, { _JobTemplateForm } from './JobTemplateForm';
import { LabelsAPI } from '@api';
import { LabelsAPI, JobTemplatesAPI } from '@api';
jest.mock('@api');
@ -29,17 +29,45 @@ describe('<JobTemplateForm />', () => {
labels: { results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }] },
},
};
const mockInstanceGroups = [
{
id: 1,
type: 'instance_group',
url: '/api/v2/instance_groups/1/',
related: {
jobs: '/api/v2/instance_groups/1/jobs/',
instances: '/api/v2/instance_groups/1/instances/',
},
name: 'tower',
capacity: 59,
committed_capacity: 0,
consumed_capacity: 0,
percent_capacity_remaining: 100.0,
jobs_running: 0,
jobs_total: 3,
instances: 1,
controller: null,
is_controller: false,
is_isolated: false,
policy_instance_percentage: 100,
policy_instance_minimum: 0,
policy_instance_list: [],
},
];
beforeEach(() => {
LabelsAPI.read.mockReturnValue({
data: mockData.summary_fields.labels,
});
JobTemplatesAPI.readInstanceGroups.mockReturnValue({
data: { results: mockInstanceGroups },
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', async done => {
test('should render labels MultiSelect', async () => {
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
@ -47,19 +75,18 @@ describe('<JobTemplateForm />', () => {
handleCancel={jest.fn()}
/>
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
await waitForElement(wrapper, 'Form', el => el.length === 0);
expect(LabelsAPI.read).toHaveBeenCalled();
expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalled();
wrapper.update();
expect(
wrapper
.find('FormGroup[fieldId="template-labels"] MultiSelect Chip')
.first()
.text()
).toEqual('Sushi');
done();
.find('FormGroup[fieldId="template-labels"] MultiSelect')
.prop('associatedItems')
).toEqual(mockData.summary_fields.labels.results);
});
test('should update form values on input changes', async done => {
test('should update form values on input changes', async () => {
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
@ -96,10 +123,9 @@ describe('<JobTemplateForm />', () => {
target: { value: 'new baz type', name: 'playbook' },
});
expect(form.state('values').playbook).toEqual('new baz type');
done();
});
test('should call handleSubmit when Submit button is clicked', async done => {
test('should call handleSubmit when Submit button is clicked', async () => {
const handleSubmit = jest.fn();
const wrapper = mountWithContexts(
<JobTemplateForm
@ -113,10 +139,9 @@ describe('<JobTemplateForm />', () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1);
expect(handleSubmit).toBeCalled();
done();
});
test('should call handleCancel when Cancel button is clicked', async done => {
test('should call handleCancel when Cancel button is clicked', async () => {
const handleCancel = jest.fn();
const wrapper = mountWithContexts(
<JobTemplateForm
@ -129,10 +154,9 @@ describe('<JobTemplateForm />', () => {
expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled();
done();
});
test('should call loadRelatedProjectPlaybooks when project value changes', async done => {
test('should call loadRelatedProjectPlaybooks when project value changes', async () => {
const loadRelatedProjectPlaybooks = jest.spyOn(
_JobTemplateForm.prototype,
'loadRelatedProjectPlaybooks'
@ -150,14 +174,9 @@ describe('<JobTemplateForm />', () => {
name: 'project',
});
expect(loadRelatedProjectPlaybooks).toHaveBeenCalledWith(10);
done();
});
test('handleNewLabel should arrange new labels properly', async done => {
const handleNewLabel = jest.spyOn(
_JobTemplateForm.prototype,
'handleNewLabel'
);
test('handleNewLabel should arrange new labels properly', async () => {
const event = { key: 'Enter', preventDefault: () => {} };
const wrapper = mountWithContexts(
<JobTemplateForm
@ -167,22 +186,25 @@ describe('<JobTemplateForm />', () => {
/>
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const multiSelect = wrapper.find('MultiSelect');
const multiSelect = wrapper.find(
'FormGroup[fieldId="template-labels"] MultiSelect'
);
const component = wrapper.find('JobTemplateForm');
wrapper.setState({ newLabels: [], loadedLabels: [], removedLabels: [] });
multiSelect.setState({ input: 'Foo' });
component.find('input[aria-label="labels"]').prop('onKeyDown')(event);
expect(handleNewLabel).toHaveBeenCalledWith('Foo');
component
.find('FormGroup[fieldId="template-labels"] input[aria-label="labels"]')
.prop('onKeyDown')(event);
component.instance().handleNewLabel({ name: 'Bar', id: 2 });
expect(component.state().newLabels).toEqual([
{ name: 'Foo', organization: 1 },
{ associate: true, id: 2, name: 'Bar' },
]);
done();
const newLabels = component.state('newLabels');
expect(newLabels).toHaveLength(2);
expect(newLabels[0].name).toEqual('Foo');
expect(newLabels[0].organization).toEqual(1);
});
test('disassociateLabel should arrange new labels properly', async done => {
test('disassociateLabel should arrange new labels properly', async () => {
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
@ -203,6 +225,5 @@ describe('<JobTemplateForm />', () => {
component.instance().removeLabel({ name: 'Sushi', id: 1 });
expect(component.state().newLabels.length).toBe(0);
expect(component.state().removedLabels.length).toBe(1);
done();
});
});

View File

@ -0,0 +1,16 @@
import React from 'react';
/*
* Prevents styled-components from passing down an unsupported
* props to children, resulting in console warnings.
* https://github.com/styled-components/styled-components/issues/439
*/
export default function omitProps(Component, ...omit) {
return function Omit(props) {
const clean = { ...props };
omit.forEach(key => {
delete clean[key];
});
return <Component {...clean} />;
};
}

View File

@ -0,0 +1,35 @@
import React from 'react';
import { mount } from 'enzyme';
import omitProps from './omitProps';
describe('omitProps', () => {
test('should render child component', () => {
const Omit = omitProps('div');
const wrapper = mount(<Omit foo="one" bar="two" />);
const div = wrapper.find('div');
expect(div).toHaveLength(1);
expect(div.prop('foo')).toEqual('one');
expect(div.prop('bar')).toEqual('two');
});
test('should not pass omitted props to child component', () => {
const Omit = omitProps('div', 'foo', 'bar');
const wrapper = mount(<Omit foo="one" bar="two" />);
const div = wrapper.find('div');
expect(div).toHaveLength(1);
expect(div.prop('foo')).toEqual(undefined);
expect(div.prop('bar')).toEqual(undefined);
});
test('should support mix of omitted and non-omitted props', () => {
const Omit = omitProps('div', 'foo');
const wrapper = mount(<Omit foo="one" bar="two" />);
const div = wrapper.find('div');
expect(div).toHaveLength(1);
expect(div.prop('foo')).toEqual(undefined);
expect(div.prop('bar')).toEqual('two');
});
});