diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json
index 79dae38600..db64b4ef90 100644
--- a/awx/ui_next/package-lock.json
+++ b/awx/ui_next/package-lock.json
@@ -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": {
diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json
index 18236a002d..44f3e2926d 100644
--- a/awx/ui_next/package.json
+++ b/awx/ui_next/package.json
@@ -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",
diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx
index d5ea358915..1de791ab58 100644
--- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx
+++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx
@@ -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 };
diff --git a/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx
new file mode 100644
index 0000000000..184106a177
--- /dev/null
+++ b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx
@@ -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 (
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
+CollapsibleSection.propTypes = {
+ label: string.isRequired,
+ startExpanded: bool,
+};
+CollapsibleSection.defaultProps = {
+ startExpanded: false,
+};
+
+export default CollapsibleSection;
diff --git a/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.test.jsx b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.test.jsx
new file mode 100644
index 0000000000..d3b5d09930
--- /dev/null
+++ b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.test.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import CollapsibleSection from './CollapsibleSection';
+
+describe('', () => {
+ it('should render contents', () => {
+ const wrapper = shallow(
+ foo
+ );
+ 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(
+ foo
+ );
+ 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);
+ });
+});
diff --git a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx
new file mode 100644
index 0000000000..5d8c91ef0d
--- /dev/null
+++ b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
+ExpandingContainer.propTypes = {
+ isExpanded: bool,
+};
+ExpandingContainer.defaultProps = {
+ isExpanded: false,
+};
+
+export default ExpandingContainer;
diff --git a/awx/ui_next/src/components/CollapsibleSection/index.js b/awx/ui_next/src/components/CollapsibleSection/index.js
new file mode 100644
index 0000000000..a4623e90ed
--- /dev/null
+++ b/awx/ui_next/src/components/CollapsibleSection/index.js
@@ -0,0 +1 @@
+export { default } from './CollapsibleSection';
diff --git a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx
index 6d06c8acd6..7ffce947d8 100644
--- a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx
+++ b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx
@@ -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;
diff --git a/awx/ui_next/src/components/FormField/CheckboxField.jsx b/awx/ui_next/src/components/FormField/CheckboxField.jsx
new file mode 100644
index 0000000000..185a40347f
--- /dev/null
+++ b/awx/ui_next/src/components/FormField/CheckboxField.jsx
@@ -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 (
+ (
+
+ {label}
+
+ {tooltip && (
+
+
+
+ )}
+
+ }
+ 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;
diff --git a/awx/ui_next/src/components/FormField/FormField.jsx b/awx/ui_next/src/components/FormField/FormField.jsx
index 999ccd531a..3bd6370c3c 100644
--- a/awx/ui_next/src/components/FormField/FormField.jsx
+++ b/awx/ui_next/src/components/FormField/FormField.jsx
@@ -57,7 +57,7 @@ FormField.propTypes = {
type: PropTypes.string,
validate: PropTypes.func,
isRequired: PropTypes.bool,
- tooltip: PropTypes.string,
+ tooltip: PropTypes.node,
};
FormField.defaultProps = {
diff --git a/awx/ui_next/src/components/FormField/index.js b/awx/ui_next/src/components/FormField/index.js
index 06dabb2d71..c592b7c800 100644
--- a/awx/ui_next/src/components/FormField/index.js
+++ b/awx/ui_next/src/components/FormField/index.js
@@ -1 +1,2 @@
export { default } from './FormField';
+export { default as CheckboxField } from './CheckboxField';
diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx
new file mode 100644
index 0000000000..b348356db1
--- /dev/null
+++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx
@@ -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 added to workaround PF bug:
+ https://github.com/patternfly/patternfly-react/issues/2855
+ */
+ return (
+
+
+ {i18n._(t`Instance Groups`)}{' '}
+ {tooltip && (
+
+
+
+ )}
+
+ }
+ fieldId="org-instance-groups"
+ >
+
+
+
+ );
+ }
+}
+
+InstanceGroupsLookup.propTypes = {
+ value: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tooltip: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+};
+
+InstanceGroupsLookup.defaultProps = {
+ tooltip: '',
+};
+
+export default withI18n()(InstanceGroupsLookup);
diff --git a/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx b/awx/ui_next/src/components/Lookup/InventoriesLookup.jsx
similarity index 98%
rename from awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx
rename to awx/ui_next/src/components/Lookup/InventoriesLookup.jsx
index 3bef9f782a..f4ef84771d 100644
--- a/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/InventoriesLookup.jsx
@@ -27,6 +27,7 @@ class InventoriesLookup extends React.Component {
)}
}
+ isRequired={required}
fieldId="inventories-lookup"
>
{},
+ 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;
diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx
index 66996fcdb7..e52e56b3c1 100644
--- a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx
+++ b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx
@@ -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('', () => {
const associatedItems = [
@@ -11,11 +11,7 @@ describe('', () => {
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(
', () => {
);
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(
', () => {
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(
@@ -68,14 +67,18 @@ describe('', () => {
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(
@@ -89,5 +92,6 @@ describe('', () => {
.removeChip(event, { name: 'Foo', id: 1, organization: 1 });
expect(component.state().chipItems.length).toBe(1);
expect(onRemoveItem).toBeCalled();
+ expect(onChange).toBeCalled();
});
});
diff --git a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx
new file mode 100644
index 0000000000..472327aaad
--- /dev/null
+++ b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx
@@ -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 (
+ {
+ 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;
diff --git a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx
new file mode 100644
index 0000000000..41f4aa5540
--- /dev/null
+++ b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import TagMultiSelect from './TagMultiSelect';
+
+describe('', () => {
+ it('should render MultiSelect', () => {
+ const wrapper = mount(
+
+ );
+ 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();
+ expect(wrapper.find('MultiSelect').prop('options')).toEqual([]);
+ });
+
+ it('should trigger onChange', () => {
+ const onChange = jest.fn();
+ const wrapper = mount(
+
+ );
+
+ const select = wrapper.find('MultiSelect');
+ select.invoke('onChange')([
+ { name: 'foo' },
+ { name: 'bar' },
+ { name: 'baz' },
+ ]);
+ expect(onChange).toHaveBeenCalledWith('foo,bar,baz');
+ });
+});
diff --git a/awx/ui_next/src/components/MultiSelect/index.js b/awx/ui_next/src/components/MultiSelect/index.js
index 8cda42c7cb..d983765272 100644
--- a/awx/ui_next/src/components/MultiSelect/index.js
+++ b/awx/ui_next/src/components/MultiSelect/index.js
@@ -1 +1,2 @@
export { default } from './MultiSelect';
+export { default as TagMultiSelect } from './TagMultiSelect';
diff --git a/awx/ui_next/src/screens/Organization/shared/InstanceGroupsLookup.jsx b/awx/ui_next/src/screens/Organization/shared/InstanceGroupsLookup.jsx
deleted file mode 100644
index a85e184f76..0000000000
--- a/awx/ui_next/src/screens/Organization/shared/InstanceGroupsLookup.jsx
+++ /dev/null
@@ -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 (
-
- {i18n._(t`Instance Groups`)}{' '}
- {tooltip && (
-
-
-
- )}
-
- }
- fieldId="org-instance-groups"
- >
-
-
- );
- }
-}
-
-InstanceGroupsLookup.propTypes = {
- value: PropTypes.arrayOf(PropTypes.object).isRequired,
- tooltip: PropTypes.string,
- onChange: PropTypes.func.isRequired,
-};
-
-InstanceGroupsLookup.defaultProps = {
- tooltip: '',
-};
-
-export default withI18n()(InstanceGroupsLookup);
diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx
index 360a195048..2bc0dcdc4d 100644
--- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx
+++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx
@@ -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);
diff --git a/awx/ui_next/src/screens/Organization/shared/index.js b/awx/ui_next/src/screens/Organization/shared/index.js
index 7f931cff31..2ddcf675b7 100644
--- a/awx/ui_next/src/screens/Organization/shared/index.js
+++ b/awx/ui_next/src/screens/Organization/shared/index.js
@@ -1,2 +1,2 @@
-export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
+/* eslint-disable-next-line import/prefer-default-export */
export { default as OrganizationForm } from './OrganizationForm';
diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
index b3b4d12f92..85bec5dfff 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
@@ -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() {
diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx
index f5c68c7cf5..506b97958e 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx
@@ -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('', () => {
const defaultProps = {
description: '',
@@ -63,14 +84,6 @@ describe('', () => {
});
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('', () => {
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('', () => {
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('', () => {
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();
});
diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
index 51e5a3ff65..8771b7c3bf 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
@@ -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);
diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
index 682d29b01a..3f43faf279 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
@@ -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('', () => {
- 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(
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
- done();
});
test('handleSubmit should call api update', async done => {
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
index d7a699c387..26fdd4c6e1 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
@@ -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 (
@@ -270,7 +342,7 @@ class JobTemplateForm extends Component {
);
}
-
+ const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div';
return (
);
@@ -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),
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
index 1d6a46adb2..cc079ef9c1 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
@@ -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('', () => {
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(
', () => {
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(
', () => {
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(
', () => {
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(
', () => {
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('', () => {
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(
', () => {
/>
);
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(
', () => {
component.instance().removeLabel({ name: 'Sushi', id: 1 });
expect(component.state().newLabels.length).toBe(0);
expect(component.state().removedLabels.length).toBe(1);
- done();
});
});
diff --git a/awx/ui_next/src/util/omitProps.jsx b/awx/ui_next/src/util/omitProps.jsx
new file mode 100644
index 0000000000..0184706d23
--- /dev/null
+++ b/awx/ui_next/src/util/omitProps.jsx
@@ -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 ;
+ };
+}
diff --git a/awx/ui_next/src/util/omitProps.test.jsx b/awx/ui_next/src/util/omitProps.test.jsx
new file mode 100644
index 0000000000..03fedcfcb1
--- /dev/null
+++ b/awx/ui_next/src/util/omitProps.test.jsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ const div = wrapper.find('div');
+ expect(div).toHaveLength(1);
+ expect(div.prop('foo')).toEqual(undefined);
+ expect(div.prop('bar')).toEqual('two');
+ });
+});