diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 94de799d6..192acfb44 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1069,6 +1069,66 @@ "@hapi/hoek": "^8.3.0" } }, + "@istanbuljs/load-nyc-config": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz", + "integrity": "sha512-ZR0rq/f/E4f4XcgnDvtMWXCUJpi8eO0rssVhmztsZqLIEFA9UUP9zmpE0VxlM+kv/E1ul2I876Fwil2ayptDVg==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz", + "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", + "dev": true + }, "@jest/console": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/@jest/console/-/console-24.9.0.tgz", @@ -1519,6 +1579,12 @@ "@types/node": "*" } }, + "@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, "@types/connect": { "version": "3.4.32", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", @@ -1697,6 +1763,12 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "@types/prettier": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-1.19.0.tgz", + "integrity": "sha512-gDE8JJEygpay7IjA/u3JiIURvwZW08f0cZSZLAzFoX/ZmeqvS0Sqv+97aKuHpNsalAMMhwPe+iAS6fQbfmbt7A==", + "dev": true + }, "@types/prop-types": { "version": "15.7.1", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.1.tgz", @@ -3498,6 +3570,12 @@ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, + "collect-v8-coverage": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.0.tgz", + "integrity": "sha512-VKIhJgvk8E1W28m5avZ2Gv2Ruv5YiF56ug2oclvaG9md69BuZImMG2sk9g7QNKLUbtYAKQjXjYxbYZVUlMMKmQ==", + "dev": true + }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -7408,7 +7486,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -7426,11 +7505,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7443,15 +7524,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -7554,7 +7638,8 @@ }, "inherits": { "version": "2.0.4", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -7564,6 +7649,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -7576,17 +7662,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.9.0", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -7603,6 +7692,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -7683,7 +7773,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -7693,6 +7784,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -7768,7 +7860,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -7798,6 +7891,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -7815,6 +7909,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -7853,11 +7948,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.1.1", - "bundled": true + "bundled": true, + "optional": true } } } @@ -10768,6 +10865,12 @@ "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=" }, + "prettier": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", + "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", + "dev": true + }, "pretty-bytes": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.3.0.tgz", @@ -12612,6 +12715,485 @@ "kind-of": "^3.2.0" } }, + "snapshot-diff": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/snapshot-diff/-/snapshot-diff-0.7.0.tgz", + "integrity": "sha512-KzFWZ02eu2BLAloZ4FlaMitUy7kw49Av8CGy50nZrl9mjqMAiibpYWgJppHDQwVcbPqt5HrdegDzF2J8Cmc+yg==", + "dev": true, + "requires": { + "jest-diff": "^25.1.0", + "jest-snapshot": "^25.1.0", + "pretty-format": "^25.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "@jest/console": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-25.1.0.tgz", + "integrity": "sha512-3P1DpqAMK/L07ag/Y9/Jup5iDEG9P4pRAuZiMQnU0JB3UOvCyYCjCoxr7sIA80SeyUCUKrr24fKAxVpmBgQonA==", + "dev": true, + "requires": { + "@jest/source-map": "^25.1.0", + "chalk": "^3.0.0", + "jest-util": "^25.1.0", + "slash": "^3.0.0" + } + }, + "@jest/source-map": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-25.1.0.tgz", + "integrity": "sha512-ohf2iKT0xnLWcIUhL6U6QN+CwFWf9XnrM2a6ybL9NXxJjgYijjLSitkYHIdzkd8wFliH73qj/+epIpTiWjRtAA==", + "dev": true, + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.3", + "source-map": "^0.6.0" + } + }, + "@jest/test-result": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-25.1.0.tgz", + "integrity": "sha512-FZzSo36h++U93vNWZ0KgvlNuZ9pnDnztvaM7P/UcTx87aPDotG18bXifkf1Ji44B7k/eIatmMzkBapnAzjkJkg==", + "dev": true, + "requires": { + "@jest/console": "^25.1.0", + "@jest/transform": "^25.1.0", + "@jest/types": "^25.1.0", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + } + }, + "@jest/transform": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-25.1.0.tgz", + "integrity": "sha512-4ktrQ2TPREVeM+KxB4zskAT84SnmG1vaz4S+51aTefyqn3zocZUnliLLm5Fsl85I3p/kFPN4CRp1RElIfXGegQ==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^25.1.0", + "babel-plugin-istanbul": "^6.0.0", + "chalk": "^3.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.3", + "jest-haste-map": "^25.1.0", + "jest-regex-util": "^25.1.0", + "jest-util": "^25.1.0", + "micromatch": "^4.0.2", + "pirates": "^4.0.1", + "realpath-native": "^1.1.0", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + } + }, + "@jest/types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-25.1.0.tgz", + "integrity": "sha512-VpOtt7tCrgvamWZh1reVsGADujKigBUFTi19mlRjqEGsE8qH4r3s+skY33dNdXOwyZIvuftZ5tqdF1IgsMejMA==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^15.0.0", + "chalk": "^3.0.0" + } + }, + "@types/yargs": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.4.tgz", + "integrity": "sha512-9T1auFmbPZoxHz0enUFlUuKRy3it01R+hlggyVUMtnCTQRunsQYifnSGb8hET4Xo8yiC0o0r1paW3ud5+rbURg==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "anymatch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", + "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "babel-plugin-istanbul": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz", + "integrity": "sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^4.0.0", + "test-exclude": "^6.0.0" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "diff-sequences": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.1.0.tgz", + "integrity": "sha512-nFIfVk5B/NStCsJ+zaPO4vYuLjlzQ6uFvPxzYyHlejNZ/UGa7G/n7peOXVrVNvRuyfstt+mZQYGpjxg9Z6N8Kw==", + "dev": true + }, + "expect": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-25.1.0.tgz", + "integrity": "sha512-wqHzuoapQkhc3OKPlrpetsfueuEiMf3iWh0R8+duCu9PIjXoP7HgD5aeypwTnXUAjC8aMsiVDaWwlbJ1RlQ38g==", + "dev": true, + "requires": { + "@jest/types": "^25.1.0", + "ansi-styles": "^4.0.0", + "jest-get-type": "^25.1.0", + "jest-matcher-utils": "^25.1.0", + "jest-message-util": "^25.1.0", + "jest-regex-util": "^25.1.0" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "graceful-fs": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", + "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.1.tgz", + "integrity": "sha512-imIchxnodll7pvQBYOqUu88EufLCU56LMeFPZZM/fJZ1irYcYdqroaV+ACK1Ila8ls09iEYArp+nqyC6lW1Vfg==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@babel/parser": "^7.7.5", + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "jest-diff": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.1.0.tgz", + "integrity": "sha512-nepXgajT+h017APJTreSieh4zCqnSHEJ1iT8HDlewu630lSJ4Kjjr9KNzm+kzGwwcpsDE6Snx1GJGzzsefaEHw==", + "dev": true, + "requires": { + "chalk": "^3.0.0", + "diff-sequences": "^25.1.0", + "jest-get-type": "^25.1.0", + "pretty-format": "^25.1.0" + } + }, + "jest-get-type": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.1.0.tgz", + "integrity": "sha512-yWkBnT+5tMr8ANB6V+OjmrIJufHtCAqI5ic2H40v+tRqxDmE0PGnIiTyvRWFOMtmVHYpwRqyazDbTnhpjsGvLw==", + "dev": true + }, + "jest-haste-map": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-25.1.0.tgz", + "integrity": "sha512-/2oYINIdnQZAqyWSn1GTku571aAfs8NxzSErGek65Iu5o8JYb+113bZysRMcC/pjE5v9w0Yz+ldbj9NxrFyPyw==", + "dev": true, + "requires": { + "@jest/types": "^25.1.0", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.1.2", + "graceful-fs": "^4.2.3", + "jest-serializer": "^25.1.0", + "jest-util": "^25.1.0", + "jest-worker": "^25.1.0", + "micromatch": "^4.0.2", + "sane": "^4.0.3", + "walker": "^1.0.7" + } + }, + "jest-matcher-utils": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.1.0.tgz", + "integrity": "sha512-KGOAFcSFbclXIFE7bS4C53iYobKI20ZWleAdAFun4W1Wz1Kkej8Ng6RRbhL8leaEvIOjGXhGf/a1JjO8bkxIWQ==", + "dev": true, + "requires": { + "chalk": "^3.0.0", + "jest-diff": "^25.1.0", + "jest-get-type": "^25.1.0", + "pretty-format": "^25.1.0" + } + }, + "jest-message-util": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-25.1.0.tgz", + "integrity": "sha512-Nr/Iwar2COfN22aCqX0kCVbXgn8IBm9nWf4xwGr5Olv/KZh0CZ32RKgZWMVDXGdOahicM10/fgjdimGNX/ttCQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@jest/test-result": "^25.1.0", + "@jest/types": "^25.1.0", + "@types/stack-utils": "^1.0.1", + "chalk": "^3.0.0", + "micromatch": "^4.0.2", + "slash": "^3.0.0", + "stack-utils": "^1.0.1" + } + }, + "jest-regex-util": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-25.1.0.tgz", + "integrity": "sha512-9lShaDmDpqwg+xAd73zHydKrBbbrIi08Kk9YryBEBybQFg/lBWR/2BDjjiSE7KIppM9C5+c03XiDaZ+m4Pgs1w==", + "dev": true + }, + "jest-resolve": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-25.1.0.tgz", + "integrity": "sha512-XkBQaU1SRCHj2Evz2Lu4Czs+uIgJXWypfO57L7JYccmAXv4slXA6hzNblmcRmf7P3cQ1mE7fL3ABV6jAwk4foQ==", + "dev": true, + "requires": { + "@jest/types": "^25.1.0", + "browser-resolve": "^1.11.3", + "chalk": "^3.0.0", + "jest-pnp-resolver": "^1.2.1", + "realpath-native": "^1.1.0" + } + }, + "jest-serializer": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-25.1.0.tgz", + "integrity": "sha512-20Wkq5j7o84kssBwvyuJ7Xhn7hdPeTXndnwIblKDR2/sy1SUm6rWWiG9kSCgJPIfkDScJCIsTtOKdlzfIHOfKA==", + "dev": true + }, + "jest-snapshot": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-25.1.0.tgz", + "integrity": "sha512-xZ73dFYN8b/+X2hKLXz4VpBZGIAn7muD/DAg+pXtDzDGw3iIV10jM7WiHqhCcpDZfGiKEj7/2HXAEPtHTj0P2A==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0", + "@jest/types": "^25.1.0", + "chalk": "^3.0.0", + "expect": "^25.1.0", + "jest-diff": "^25.1.0", + "jest-get-type": "^25.1.0", + "jest-matcher-utils": "^25.1.0", + "jest-message-util": "^25.1.0", + "jest-resolve": "^25.1.0", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^25.1.0", + "semver": "^7.1.1" + } + }, + "jest-util": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-25.1.0.tgz", + "integrity": "sha512-7did6pLQ++87Qsj26Fs/TIwZMUFBXQ+4XXSodRNy3luch2DnRXsSnmpVtxxQ0Yd6WTipGpbhh2IFP1mq6/fQGw==", + "dev": true, + "requires": { + "@jest/types": "^25.1.0", + "chalk": "^3.0.0", + "is-ci": "^2.0.0", + "mkdirp": "^0.5.1" + } + }, + "jest-worker": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-25.1.0.tgz", + "integrity": "sha512-ZHhHtlxOWSxCoNOKHGbiLzXnl42ga9CxDr27H36Qn+15pQZd3R/F24jrmjDelw9j/iHUIWMWs08/u2QN50HHOg==", + "dev": true, + "requires": { + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + } + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "pretty-format": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.1.0.tgz", + "integrity": "sha512-46zLRSGLd02Rp+Lhad9zzuNZ+swunitn8zIpfD2B4OPCRLXbM87RJT2aBLBWYOznNUML/2l/ReMyWNC80PJBUQ==", + "dev": true, + "requires": { + "@jest/types": "^25.1.0", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^16.12.0" + } + }, + "react-is": { + "version": "16.13.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.0.tgz", + "integrity": "sha512-GFMtL0vHkiBv9HluwNZTggSn/sCyEt9n02aM0dSAjGGyqyNlAyftYm4phPxdvCigG15JreC5biwxCgTAJZ7yAA==", + "dev": true + }, + "semver": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.1.3.tgz", + "integrity": "sha512-ekM0zfiA9SCBlsKa2X1hxyxiI4L3B6EbVJkkdgQXnSEEaHlGdvyodMruTiulSRWMMB4NeIuYNMC9rTKTz97GxA==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + } + } + }, "sockjs": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.19.tgz", @@ -13551,6 +14133,15 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, "typescript": { "version": "3.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.5.tgz", @@ -13976,7 +14567,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -13994,11 +14586,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -14011,15 +14605,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -14122,7 +14719,8 @@ }, "inherits": { "version": "2.0.4", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -14132,6 +14730,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -14144,17 +14743,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.9.0", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -14171,6 +14773,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -14251,7 +14854,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -14261,6 +14865,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -14336,7 +14941,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -14366,6 +14972,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -14383,6 +14990,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -14421,11 +15029,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.1.1", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -14717,7 +15327,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -14735,11 +15346,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -14752,15 +15365,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -14863,7 +15479,8 @@ }, "inherits": { "version": "2.0.4", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -14873,6 +15490,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -14885,17 +15503,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.9.0", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -14912,6 +15533,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -14992,7 +15614,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -15002,6 +15625,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -15077,7 +15701,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -15107,6 +15732,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -15124,6 +15750,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -15162,11 +15789,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.1.1", - "bundled": true + "bundled": true, + "optional": true } } }, diff --git a/frontend/package.json b/frontend/package.json index 0f52a9738..e8dd1d19c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@types/lodash.debounce": "^4.0.6", "@types/lodash.isfunction": "^3.0.6", "@types/node": "^12.0.2", + "@types/prettier": "^1.19.0", "@types/react": "^16.8.18", "@types/react-dom": "^16.8.4", "@types/react-router-dom": "^4.3.1", @@ -31,7 +32,9 @@ "enzyme-to-json": "^3.3.5", "eslint-plugin-import": "^2.18.2", "license-checker": "^25.0.1", + "prettier": "^1.19.1", "react-router-test-context": "^0.1.0", + "snapshot-diff": "^0.7.0", "typescript": "^3.4.5" }, "scripts": { diff --git a/frontend/src/Css.tsx b/frontend/src/Css.tsx index 353cf79c0..3204775dc 100644 --- a/frontend/src/Css.tsx +++ b/frontend/src/Css.tsx @@ -15,8 +15,8 @@ */ import createMuiTheme from '@material-ui/core/styles/createMuiTheme'; -import {NestedCSSProperties} from 'typestyle/lib/types'; import {style, stylesheet} from 'typestyle'; +import {NestedCSSProperties} from 'typestyle/lib/types'; export const color = { activeBg: '#eaf1fd', @@ -72,6 +72,7 @@ export const fontsize = { medium: 16, large: 18, title: 18, + pageTitle: 24, }; // tslint:enable:object-literal-sort-keys @@ -122,7 +123,7 @@ export const theme = createMuiTheme({ }, color: color.theme, marginRight: 10, - padding: '0 8px' + padding: '0 8px', }, }, MuiDialogActions: { @@ -162,7 +163,7 @@ export const theme = createMuiTheme({ }, MuiInput: { input: { padding: 0 }, - root: { padding: 0 } + root: { padding: 0 }, }, MuiInputAdornment: { positionEnd: { @@ -175,13 +176,13 @@ export const theme = createMuiTheme({ backgroundColor: '#666', color: '#f1f1f1', fontSize: 12, - } + }, }, }, palette, typography: { fontFamily: fonts.main, - fontSize: fontsize.base + ' !important' as any, + fontSize: (fontsize.base + ' !important') as any, useNextVariants: true, }, }); @@ -219,15 +220,6 @@ export const commonCss = stylesheet({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, - fit: { - bottom: 0, - height: '100%', - left: 0, - position: 'absolute', - right: 0, - top: 0, - width: '100%', - }, flex: { alignItems: 'center !important', display: 'flex !important', @@ -256,7 +248,7 @@ export const commonCss = stylesheet({ infoIcon: { color: color.lowContrast, height: 16, - width: 16 + width: 16, }, link: { $nest: { @@ -289,8 +281,7 @@ export const commonCss = stylesheet({ whiteSpace: 'pre-wrap', }, scrollContainer: { - background: - `linear-gradient(white 30%, rgba(255,255,255,0)), + background: `linear-gradient(white 30%, rgba(255,255,255,0)), linear-gradient(rgba(255,255,255,0), white 70%) 0 100%, radial-gradient(farthest-corner at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)), radial-gradient(farthest-corner at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%`, diff --git a/frontend/src/TestUtils.ts b/frontend/src/TestUtils.ts deleted file mode 100644 index 8dbbc963e..000000000 --- a/frontend/src/TestUtils.ts +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2018 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {mount, ReactWrapper} from 'enzyme'; -import {Artifact, ArtifactCustomProperties, ArtifactProperties, Value} from '@kubeflow/frontend'; -import {object} from 'prop-types'; -import * as React from 'react'; -import {match} from 'react-router'; -// @ts-ignore -import createRouterContext from 'react-router-test-context'; -import {ToolbarActionConfig} from './components/Toolbar'; -import {Page, PageProps} from './pages/Page'; - - -/** - * Mounts the given component with a fake router and returns the mounted tree - */ -export function mountWithRouter(component: React.ReactElement): - ReactWrapper { - const childContextTypes = { - router: object, - }; - const context = createRouterContext(); - const tree = mount(component, {context, childContextTypes}); - return tree; -} - -/** - * Flushes all already queued promises and returns a promise. Note this will - * only work if the promises have already been queued, so it cannot be used to - * wait on a promise that hasn't been dispatched yet. - */ -export function flushPromises(): Promise { - return new Promise(resolve => setImmediate(resolve)); -} - -// /** -// * Adds a one-time mock implementation to the provided spy that mimics an -// * error network response -// */ -// export function makeErrorResponseOnce( -// spy: jest.MockInstance<{}>, message: string): void { -// spy.mockImplementationOnce(() => { -// throw { -// text: () => Promise.resolve(message), -// }; -// }); -// } - -/** - * Generates a customizable PageProps object that can be passed to initialize - * Page components, taking care of setting ToolbarProps properly, which have - * to be set after component initialization. - */ -// tslint:disable-next-line:variable-name -export function generatePageProps( - PageElement: new (_: PageProps) => Page, location: Location, - matchValue: match, historyPushSpy: jest.SpyInstance | null, - updateBannerSpy: jest.SpyInstance | null, - updateDialogSpy: jest.SpyInstance | null, - updateToolbarSpy: jest.SpyInstance | null, - updateSnackbarSpy: jest.SpyInstance | null): PageProps { - const pageProps = { - history: {push: historyPushSpy} as any, - location: location as any, - match: matchValue, - toolbarProps: {actions: [], breadcrumbs: [], pageTitle: ''}, - updateBanner: updateBannerSpy as any, - updateDialog: updateDialogSpy as any, - updateSnackbar: updateSnackbarSpy as any, - updateToolbar: updateToolbarSpy as any, - } as PageProps; - pageProps.toolbarProps = new PageElement(pageProps).getInitialToolbarState(); - // The toolbar spy gets called in the getInitialToolbarState method, reset - // it in order to simplify tests - if (updateToolbarSpy) { - updateToolbarSpy.mockReset(); - } - return pageProps; -} - -export function getToolbarButton( - updateToolbarSpy: jest.SpyInstance, title: string): ToolbarActionConfig { - const lastCallIdx = updateToolbarSpy.mock.calls.length - 1; - const lastCall = updateToolbarSpy.mock.calls[lastCallIdx][0]; - return lastCall.actions.find((b: any) => b.title === title); -} - -export const doubleValue = (number: number) => { - const value = new Value(); - value.setDoubleValue(number); - return value; -}; - -export const intValue = (number: number) => { - const value = new Value(); - value.setIntValue(number); - return value; -}; - -export const stringValue = (string: String) => { - const value = new Value(); - value.setStringValue(String(string)); - return value; -}; - -export const buildTestModel = () => { - const model = new Artifact(); - model.setId(1); - model.setTypeId(1); - model.setUri('gs://my-bucket/mnist'); - model.getPropertiesMap().set(ArtifactProperties.NAME, stringValue('test model')); - model.getPropertiesMap().set(ArtifactProperties.DESCRIPTION, stringValue('A really great model')); - model.getPropertiesMap().set(ArtifactProperties.VERSION, stringValue('v1')); - model.getPropertiesMap().set(ArtifactProperties.CREATE_TIME, stringValue('2019-06-12T01:21:48.259263Z')); - model.getPropertiesMap().set(ArtifactProperties.ALL_META, stringValue( - '{"hyperparameters": {"early_stop": true, ' + - '"layers": [10, 3, 1], "learning_rate": 0.5}, ' + - '"model_type": "neural network", ' + - '"training_framework": {"name": "tensorflow", "version": "v1.0"}}')); - model.getCustomPropertiesMap().set(ArtifactCustomProperties.WORKSPACE, stringValue('workspace-1')); - model.getCustomPropertiesMap().set(ArtifactCustomProperties.RUN, stringValue('1')); - return model -}; - -export const testModel = buildTestModel(); diff --git a/frontend/src/TestUtils.tsx b/frontend/src/TestUtils.tsx new file mode 100644 index 000000000..ee0620140 --- /dev/null +++ b/frontend/src/TestUtils.tsx @@ -0,0 +1,154 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable */ +// Because this is test utils. + +import * as React from 'react'; +// @ts-ignore +import createRouterContext from 'react-router-test-context'; +import { PageProps, Page } from './pages/Page'; +import { ToolbarActionConfig } from './components/Toolbar'; +import { match } from 'react-router'; +import { mount, ReactWrapper } from 'enzyme'; +import { object } from 'prop-types'; +import { format } from 'prettier'; +import snapshotDiff from 'snapshot-diff'; + +export default class TestUtils { + /** + * Mounts the given component with a fake router and returns the mounted tree + */ + // tslint:disable-next-line:variable-name + public static mountWithRouter(component: React.ReactElement): ReactWrapper { + const childContextTypes = { + router: object, + }; + const context = createRouterContext(); + const tree = mount(component, { context, childContextTypes }); + return tree; + } + + /** + * Flushes all already queued promises and returns a promise. Note this will + * only work if the promises have already been queued, so it cannot be used to + * wait on a promise that hasn't been dispatched yet. + */ + public static flushPromises(): Promise { + return new Promise(resolve => setImmediate(resolve)); + } + + /** + * Adds a one-time mock implementation to the provided spy that mimics an error + * network response + */ + public static makeErrorResponseOnce(spy: jest.MockInstance, message: string): void { + spy.mockImplementationOnce(() => { + throw { + text: () => Promise.resolve(message), + }; + }); + } + + /** + * Generates a customizable PageProps object that can be passed to initialize + * Page components, taking care of setting ToolbarProps properly, which have + * to be set after component initialization. + */ + // tslint:disable-next-line:variable-name + public static generatePageProps( + PageElement: new (_: PageProps) => Page, + location: Location, + matchValue: match, + historyPushSpy: jest.SpyInstance | null, + updateBannerSpy: jest.SpyInstance | null, + updateDialogSpy: jest.SpyInstance | null, + updateToolbarSpy: jest.SpyInstance | null, + updateSnackbarSpy: jest.SpyInstance | null, + ): PageProps { + const pageProps = { + history: { push: historyPushSpy } as any, + location: location as any, + match: matchValue, + toolbarProps: { actions: {}, breadcrumbs: [], pageTitle: '' }, + updateBanner: updateBannerSpy as any, + updateDialog: updateDialogSpy as any, + updateSnackbar: updateSnackbarSpy as any, + updateToolbar: updateToolbarSpy as any, + } as PageProps; + pageProps.toolbarProps = new PageElement(pageProps).getInitialToolbarState(); + // The toolbar spy gets called in the getInitialToolbarState method, reset it + // in order to simplify tests + if (updateToolbarSpy) { + updateToolbarSpy.mockReset(); + } + return pageProps; + } + + public static getToolbarButton( + updateToolbarSpy: jest.SpyInstance, + buttonKey: string, + ): ToolbarActionConfig { + const lastCallIdx = updateToolbarSpy.mock.calls.length - 1; + const lastCall = updateToolbarSpy.mock.calls[lastCallIdx][0]; + return lastCall.actions[buttonKey]; + } +} + +/** + * Generate diff text for two HTML strings. + * Recommend providing base and update annotations to clarify context in the diff directly. + */ +export function diffHTML({ + base, + update, + baseAnnotation, + updateAnnotation, +}: { + base: string; + baseAnnotation?: string; + update: string; + updateAnnotation?: string; +}) { + return diff({ + base: formatHTML(base), + update: formatHTML(update), + baseAnnotation, + updateAnnotation, + }); +} + +export function diff({ + base, + update, + baseAnnotation, + updateAnnotation, +}: { + base: string; + baseAnnotation?: string; + update: string; + updateAnnotation?: string; +}) { + return snapshotDiff(base, update, { + stablePatchmarks: true, // Avoid line numbers in diff, so that diffs are stable against irrelevant changes + aAnnotation: baseAnnotation, + bAnnotation: updateAnnotation, + }); +} + +function formatHTML(html: string): string { + return format(html, { parser: 'html' }); +} diff --git a/frontend/src/components/ArtifactLink.tsx b/frontend/src/components/ArtifactLink.tsx new file mode 100644 index 000000000..8730cadb1 --- /dev/null +++ b/frontend/src/components/ArtifactLink.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +/** + * A component that renders an artifact URL as clickable link if URL is correct + */ +export const ArtifactLink: React.FC<{ artifactUri?: string }> = ({ artifactUri }) => { + let clickableUrl: string | undefined; + if (artifactUri) { + if (artifactUri.startsWith('http:') || artifactUri.startsWith('https:')) { + clickableUrl = artifactUri; + } + } + + if (clickableUrl) { + // Opens in new window safely + return ( + + {artifactUri} + + ); + } else { + return <>{artifactUri}; + } +}; diff --git a/frontend/src/components/CustomTable.test.tsx b/frontend/src/components/CustomTable.test.tsx index a04ab63c2..5956d1a49 100644 --- a/frontend/src/components/CustomTable.test.tsx +++ b/frontend/src/components/CustomTable.test.tsx @@ -15,8 +15,8 @@ */ import * as React from 'react'; -import CustomTable, {Column, ExpandState, Row, css, CustomTableRow, CustomRendererProps} from './CustomTable'; -import * as TestUtils from '../TestUtils'; +import CustomTable, {Column, ExpandState, Row, css} from './CustomTable'; +import TestUtils from '../TestUtils'; import {shallow} from 'enzyme'; const props = { @@ -60,80 +60,6 @@ class CustomTableTest extends CustomTable { } } -describe('CustomTableRow', () => { - - it('Renders cells with equal widths', () => { - const columns: Column[] = [ - {label: 'name'}, - {label: 'description'}, - {label: 'price'}, - {label: 'percentage'}, - ] - const row: Row = { - id: '1', - otherFields: [ - 'test row', - 'a great value', - '$56.00', - '25%', - ], - }; - const props = {row, columns}; - const tree = shallow(); - expect(tree).toMatchSnapshot(); - }); - - it('Renders cells with explicit widths', () => { - const columns: Column[] = [ - {label: 'name'}, - {label: 'description', flex: 2}, - {label: 'price'}, - {label: 'percentage'}, - ] - const row: Row = { - id: '1', - otherFields: [ - 'test row', - 'a great value', - '$56.00', - '25%', - ], - }; - const props = {row, columns}; - const tree = shallow(); - expect(tree).toMatchSnapshot(); - }); - - it('Renders with an error and custom renderer', () => { - const columns: Column[] = [ - { - label: 'name', - flex: 1, - customRenderer: (props: CustomRendererProps) => ( -

custom rendered: {props.id} {props.value}

- ) - }, - {label: 'description', flex: 1, }, - {label: 'price', flex: 1, }, - {label: 'percentage', flex: 1, }, - ] - const row: Row = { - id: '1', - error: 'Something bad happened', - otherFields: [ - 'test row', - 'a great value', - '$56.00', - '25%', - ], - }; - const props = {row, columns}; - const tree = shallow(); - expect(tree).toMatchSnapshot(); - }); - -}); - describe('CustomTable', () => { beforeAll(() => { consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => null); @@ -175,33 +101,44 @@ describe('CustomTable', () => { }); it('renders some columns with equal widths without rows', async () => { - const tree = shallow(); + const tree = shallow( + , + ); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); it('renders without the checkboxes if disableSelection is true', async () => { - const tree = shallow(); + const tree = shallow( + , + ); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); it('renders some columns with descending sort order on first column', async () => { - const tree = shallow(); + const tree = shallow( + , + ); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); it('renders columns with specified widths', async () => { - const testcolumns = [{ - flex: 3, - label: 'col1', - }, { - flex: 1, - label: 'col2', - }]; + const testcolumns = [ + { + flex: 3, + label: 'col1', + }, + { + flex: 1, + label: 'col2', + }, + ]; const tree = shallow(); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); @@ -220,15 +157,18 @@ describe('CustomTable', () => { }); it('calls reload function with sort key of clicked column, while keeping same page', () => { - const testcolumns = [{ - flex: 3, - label: 'col1', - sortKey: 'col1sortkey', - }, { - flex: 1, - label: 'col2', - sortKey: 'col2sortkey', - }]; + const testcolumns = [ + { + flex: 3, + label: 'col1', + sortKey: 'col1sortkey', + }, + { + flex: 1, + label: 'col2', + sortKey: 'col2sortkey', + }, + ]; const reload = jest.fn(); const tree = shallow(); expect(reload).toHaveBeenLastCalledWith({ @@ -236,10 +176,13 @@ describe('CustomTable', () => { orderAscending: false, pageSize: 10, pageToken: '', - sortBy: 'col1sortkey', + sortBy: 'col1sortkey desc', }); - tree.find('WithStyles(TableSortLabel)').at(1).simulate('click'); + tree + .find('WithStyles(TableSortLabel)') + .at(1) + .simulate('click'); expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: true, @@ -250,15 +193,18 @@ describe('CustomTable', () => { }); it('calls reload function with same sort key in reverse order if same column is clicked twice', () => { - const testcolumns = [{ - flex: 3, - label: 'col1', - sortKey: 'col1sortkey', - }, { - flex: 1, - label: 'col2', - sortKey: 'col2sortkey', - }]; + const testcolumns = [ + { + flex: 3, + label: 'col1', + sortKey: 'col1sortkey', + }, + { + flex: 1, + label: 'col2', + sortKey: 'col2sortkey', + }, + ]; const reload = jest.fn(); const tree = shallow(); expect(reload).toHaveBeenLastCalledWith({ @@ -266,10 +212,13 @@ describe('CustomTable', () => { orderAscending: false, pageSize: 10, pageToken: '', - sortBy: 'col1sortkey', + sortBy: 'col1sortkey desc', }); - tree.find('WithStyles(TableSortLabel)').at(1).simulate('click'); + tree + .find('WithStyles(TableSortLabel)') + .at(1) + .simulate('click'); expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: true, @@ -277,25 +226,31 @@ describe('CustomTable', () => { pageToken: '', sortBy: 'col2sortkey', }); - tree.setProps({sortBy: 'col1sortkey'}); - tree.find('WithStyles(TableSortLabel)').at(1).simulate('click'); + tree.setProps({ sortBy: 'col1sortkey' }); + tree + .find('WithStyles(TableSortLabel)') + .at(1) + .simulate('click'); expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, pageSize: 10, pageToken: '', - sortBy: 'col2sortkey', + sortBy: 'col2sortkey desc', }); }); it('does not call reload if clicked column has no sort key', () => { - const testcolumns = [{ - flex: 3, - label: 'col1', - }, { - flex: 1, - label: 'col2', - }]; + const testcolumns = [ + { + flex: 3, + label: 'col1', + }, + { + flex: 1, + label: 'col2', + }, + ]; const reload = jest.fn(); const tree = shallow(); expect(reload).toHaveBeenLastCalledWith({ @@ -306,7 +261,10 @@ describe('CustomTable', () => { sortBy: '', }); - tree.find('WithStyles(TableSortLabel)').at(0).simulate('click'); + tree + .find('WithStyles(TableSortLabel)') + .at(0) + .simulate('click'); expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, @@ -319,14 +277,16 @@ describe('CustomTable', () => { it('logs error if row has more cells than columns', () => { shallow(); expect(consoleSpy).toHaveBeenLastCalledWith( - 'Rows must have the same number of cells defined in columns'); + 'Rows must have the same number of cells defined in columns', + ); }); it('logs error if row has fewer cells than columns', () => { - const testcolumns = [{label: 'col1'}, {label: 'col2'}, {label: 'col3'}]; + const testcolumns = [{ label: 'col1' }, { label: 'col2' }, { label: 'col3' }]; shallow(); expect(consoleSpy).toHaveBeenLastCalledWith( - 'Rows must have the same number of cells defined in columns'); + 'Rows must have the same number of cells defined in columns', + ); }); it('renders some rows', async () => { @@ -335,22 +295,6 @@ describe('CustomTable', () => { expect(tree).toMatchSnapshot(); }); - it('renders some rows using a custom renderer', async () => { - columns[0].customRenderer = () => (this is custom output) as any; - const tree = shallow(); - await TestUtils.flushPromises(); - expect(tree).toMatchSnapshot(); - columns[0].customRenderer = undefined; - }); - - it('displays warning icon with tooltip if row has error', async () => { - rows[0].error = 'dummy error'; - const tree = shallow(); - await TestUtils.flushPromises(); - expect(tree).toMatchSnapshot(); - rows[0].error = undefined; - }); - it('starts out with no selected rows', () => { const spy = jest.fn(); shallow(); @@ -359,18 +303,30 @@ describe('CustomTable', () => { it('calls update selection callback when items are selected', () => { const spy = jest.fn(); - const tree = shallow(); - - tree.find('.row').at(0).simulate('click', {stopPropagation: () => null}); + const tree = shallow( + , + ); + tree + .find('.row') + .at(0) + .simulate('click', { stopPropagation: () => null }); expect(spy).toHaveBeenLastCalledWith(['row1']); }); it('does not add items to selection when multiple rows are clicked', () => { // Keeping track of selection is the parent's job. const spy = jest.fn(); - const tree = shallow(); - tree.find('.row').at(0).simulate('click', {stopPropagation: () => null}); - tree.find('.row').at(1).simulate('click', {stopPropagation: () => null}); + const tree = shallow( + , + ); + tree + .find('.row') + .at(0) + .simulate('click', { stopPropagation: () => null }); + tree + .find('.row') + .at(1) + .simulate('click', { stopPropagation: () => null }); expect(spy).toHaveBeenLastCalledWith(['row2']); }); @@ -378,57 +334,108 @@ describe('CustomTable', () => { // Keeping track of selection is the parent's job. const selectedIds = ['previouslySelectedRow']; const spy = jest.fn(); - const tree = shallow(); - tree.find('.row').at(0).simulate('click', {stopPropagation: () => null}); + const tree = shallow( + , + ); + tree + .find('.row') + .at(0) + .simulate('click', { stopPropagation: () => null }); expect(spy).toHaveBeenLastCalledWith(['previouslySelectedRow', 'row1']); }); it('does not call selectionCallback if disableSelection is true', () => { const spy = jest.fn(); - const tree = shallow(); - tree.find('.row').at(0).simulate('click', {stopPropagation: () => null}); - tree.find('.row').at(1).simulate('click', {stopPropagation: () => null}); + const tree = shallow( + , + ); + tree + .find('.row') + .at(0) + .simulate('click', { stopPropagation: () => null }); + tree + .find('.row') + .at(1) + .simulate('click', { stopPropagation: () => null }); expect(spy).not.toHaveBeenCalled(); }); it('handles no updateSelection method being passed', () => { const tree = shallow(); - tree.find('.row').at(0).simulate('click', {stopPropagation: () => null}); - tree.find('.columnName WithStyles(Checkbox)').at(0).simulate('change', { - target: {checked: true}, - }); + tree + .find('.row') + .at(0) + .simulate('click', { stopPropagation: () => null }); + tree + .find('.columnName WithStyles(Checkbox)') + .at(0) + .simulate('change', { + target: { checked: true }, + }); }); it('selects all items when head checkbox is clicked', () => { const spy = jest.fn(); - const tree = shallow(); - tree.find('.columnName WithStyles(Checkbox)').at(0).simulate('change', { - target: {checked: true}, - }); + const tree = shallow( + , + ); + tree + .find('.columnName WithStyles(Checkbox)') + .at(0) + .simulate('change', { + target: { checked: true }, + }); expect(spy).toHaveBeenLastCalledWith(['row1', 'row2']); }); it('unselects all items when head checkbox is clicked and all items are selected', () => { const spy = jest.fn(); - const tree = shallow(); - tree.find('.columnName WithStyles(Checkbox)').at(0).simulate('change', { - target: {checked: true}, - }); + const tree = shallow( + , + ); + tree + .find('.columnName WithStyles(Checkbox)') + .at(0) + .simulate('change', { + target: { checked: true }, + }); expect(spy).toHaveBeenLastCalledWith(['row1', 'row2']); - tree.find('.columnName WithStyles(Checkbox)').at(0).simulate('change', { - target: {checked: false}, - }); + tree + .find('.columnName WithStyles(Checkbox)') + .at(0) + .simulate('change', { + target: { checked: false }, + }); expect(spy).toHaveBeenLastCalledWith([]); }); it('selects all items if one item was checked then the head checkbox is clicked', () => { const spy = jest.fn(); - const tree = shallow(); - tree.find('.row').at(0).simulate('click', {stopPropagation: () => null}); - tree.find('.columnName WithStyles(Checkbox)').at(0).simulate('change', { - target: {checked: true}, - }); + const tree = shallow( + , + ); + tree + .find('.row') + .at(0) + .simulate('click', { stopPropagation: () => null }); + tree + .find('.columnName WithStyles(Checkbox)') + .at(0) + .simulate('change', { + target: { checked: true }, + }); expect(spy).toHaveBeenLastCalledWith(['row1', 'row2']); }); @@ -437,8 +444,20 @@ describe('CustomTable', () => { // work here because the parent is where the selectedIds state is kept const selectedIds = ['previouslySelectedRow']; const spy = jest.fn(); - const tree = shallow(); - tree.find('.row').at(0).simulate('click', {stopPropagation: () => null}); + const tree = shallow( + , + ); + tree + .find('.row') + .at(0) + .simulate('click', { stopPropagation: () => null }); expect(spy).toHaveBeenLastCalledWith(['row1']); }); @@ -448,8 +467,18 @@ describe('CustomTable', () => { const tree = shallow(); await TestUtils.flushPromises(); expect(tree.state()).toHaveProperty('maxPageIndex', 0); - expect(tree.find('WithStyles(IconButton)').at(0).prop('disabled')).toBeTruthy(); - expect(tree.find('WithStyles(IconButton)').at(1).prop('disabled')).toBeTruthy(); + expect( + tree + .find('WithStyles(IconButton)') + .at(0) + .prop('disabled'), + ).toBeTruthy(); + expect( + tree + .find('WithStyles(IconButton)') + .at(1) + .prop('disabled'), + ).toBeTruthy(); }); it('enables next page button if next page token is given', async () => { @@ -458,8 +487,18 @@ describe('CustomTable', () => { const tree = shallow(); await reloadResult; expect(tree.state()).toHaveProperty('maxPageIndex', Number.MAX_SAFE_INTEGER); - expect(tree.find('WithStyles(IconButton)').at(0).prop('disabled')).toBeTruthy(); - expect(tree.find('WithStyles(IconButton)').at(1).prop('disabled')).not.toBeTruthy(); + expect( + tree + .find('WithStyles(IconButton)') + .at(0) + .prop('disabled'), + ).toBeTruthy(); + expect( + tree + .find('WithStyles(IconButton)') + .at(1) + .prop('disabled'), + ).not.toBeTruthy(); }); it('calls reload with next page token when next page button is clicked', async () => { @@ -468,7 +507,10 @@ describe('CustomTable', () => { const tree = shallow(); await TestUtils.flushPromises(); - tree.find('WithStyles(IconButton)').at(1).simulate('click'); + tree + .find('WithStyles(IconButton)') + .at(1) + .simulate('click'); expect(spy).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, @@ -484,7 +526,10 @@ describe('CustomTable', () => { const tree = shallow(); await TestUtils.flushPromises(); - tree.find('WithStyles(IconButton)').at(1).simulate('click'); + tree + .find('WithStyles(IconButton)') + .at(1) + .simulate('click'); await TestUtils.flushPromises(); expect(spy).toHaveBeenLastCalledWith({ filter: '', @@ -494,9 +539,14 @@ describe('CustomTable', () => { sortBy: '', }); expect(tree.state()).toHaveProperty('currentPage', 1); - tree.setProps({rows: [rows[1]]}); + tree.setProps({ rows: [rows[1]] }); expect(tree).toMatchSnapshot(); - expect(tree.find('WithStyles(IconButton)').at(0).prop('disabled')).not.toBeTruthy(); + expect( + tree + .find('WithStyles(IconButton)') + .at(0) + .prop('disabled'), + ).not.toBeTruthy(); }); it('renders new rows after clicking previous page, and enables next page button', async () => { @@ -505,10 +555,16 @@ describe('CustomTable', () => { const tree = shallow(); await reloadResult; - tree.find('WithStyles(IconButton)').at(1).simulate('click'); + tree + .find('WithStyles(IconButton)') + .at(1) + .simulate('click'); await reloadResult; - tree.find('WithStyles(IconButton)').at(0).simulate('click'); + tree + .find('WithStyles(IconButton)') + .at(0) + .simulate('click'); await TestUtils.flushPromises(); expect(spy).toHaveBeenLastCalledWith({ filter: '', @@ -518,8 +574,13 @@ describe('CustomTable', () => { sortBy: '', }); - tree.setProps({rows}); - expect(tree.find('WithStyles(IconButton)').at(0).prop('disabled')).toBeTruthy(); + tree.setProps({ rows }); + expect( + tree + .find('WithStyles(IconButton)') + .at(0) + .prop('disabled'), + ).toBeTruthy(); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); @@ -529,7 +590,7 @@ describe('CustomTable', () => { const spy = jest.fn(() => reloadResult); const tree = shallow(); - tree.find('.' + css.rowsPerPage).simulate('change', {target: {value: 1234}}); + tree.find('.' + css.rowsPerPage).simulate('change', { target: { value: 1234 } }); await TestUtils.flushPromises(); expect(spy).toHaveBeenLastCalledWith({ filter: '', @@ -546,7 +607,7 @@ describe('CustomTable', () => { const spy = jest.fn(() => reloadResult); const tree = shallow(); - tree.find('.' + css.rowsPerPage).simulate('change', {target: {value: 1234}}); + tree.find('.' + css.rowsPerPage).simulate('change', { target: { value: 1234 } }); await reloadResult; expect(spy).toHaveBeenLastCalledWith({ filter: '', @@ -559,25 +620,33 @@ describe('CustomTable', () => { }); it('renders a collapsed row', async () => { - const row = {...rows[0]}; + const row = { ...rows[0] }; row.expandState = ExpandState.COLLAPSED; - const tree = shallow( null} />); + const tree = shallow( + null} />, + ); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); it('renders a collapsed row when selection is disabled', async () => { - const row = {...rows[0]}; + const row = { ...rows[0] }; row.expandState = ExpandState.COLLAPSED; - const tree = shallow( null} disableSelection={true} />); + const tree = shallow( + null} + disableSelection={true} + />, + ); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); it('renders an expanded row', async () => { - const row = {...rows[0]}; + const row = { ...rows[0] }; row.expandState = ExpandState.EXPANDED; const tree = shallow(); await TestUtils.flushPromises(); @@ -585,35 +654,53 @@ describe('CustomTable', () => { }); it('renders an expanded row with expanded component below it', async () => { - const row = {...rows[0]}; + const row = { ...rows[0] }; row.expandState = ExpandState.EXPANDED; - const tree = shallow( Hello World} />); + const tree = shallow( + Hello World} + />, + ); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); it('calls prop to toggle expansion', () => { - const row = {...rows[0]}; + const row = { ...rows[0] }; const toggleSpy = jest.fn(); const stopPropagationSpy = jest.fn(); row.expandState = ExpandState.EXPANDED; - const tree = shallow( Hello World} toggleExpansion={toggleSpy} />); - tree.find('.' + css.expandButton).at(1).simulate('click', {stopPropagation: stopPropagationSpy}); + const tree = shallow( + Hello World} + toggleExpansion={toggleSpy} + />, + ); + tree + .find('.' + css.expandButton) + .at(1) + .simulate('click', { stopPropagation: stopPropagationSpy }); expect(toggleSpy).toHaveBeenCalledWith(1); expect(stopPropagationSpy).toHaveBeenCalledWith(); }); it('renders a table with sorting disabled', async () => { - const tree = shallow(); + const tree = shallow( + , + ); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); it('updates the filter string in state when the filter box input changes', async () => { const tree = shallow(); - (tree.instance() as CustomTable).handleFilterChange({target: {value: 'test filter'}}); + (tree.instance() as CustomTable).handleFilterChange({ target: { value: 'test filter' } }); await TestUtils.flushPromises(); expect(tree.state('filterString')).toEqual('test filter'); expect(tree).toMatchSnapshot(); @@ -621,13 +708,26 @@ describe('CustomTable', () => { it('reloads the table with the encoded filter object', async () => { const reload = jest.fn(); - const tree = shallow(); + const tree = shallow( + , + ); // lodash's debounce function doesn't play nice with Jest, so we skip the handleChange function // and call _requestFilter directly. (tree.instance() as CustomTableTest)._requestFilter('test filter'); - expect(tree.state('filterStringEncoded')).toEqual('test filter'); + const expectedEncodedFilter = encodeURIComponent( + JSON.stringify({ + predicates: [ + { + key: 'name', + op: PredicateOp.ISSUBSTRING, + string_value: 'test filter', + }, + ], + }), + ); + expect(tree.state('filterStringEncoded')).toEqual(expectedEncodedFilter); expect(reload).toHaveBeenLastCalledWith({ - filter: 'test filter', + filter: expectedEncodedFilter, orderAscending: false, pageSize: 10, pageToken: '', diff --git a/frontend/src/components/CustomTable.tsx b/frontend/src/components/CustomTable.tsx index 18b20f9b0..ad289f3a5 100644 --- a/frontend/src/components/CustomTable.tsx +++ b/frontend/src/components/CustomTable.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2019 Google LLC + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,6 @@ */ import {ListRequest, logger} from '@kubeflow/frontend'; -import debounce from 'lodash.debounce'; import * as React from 'react'; import ArrowRight from '@material-ui/icons/ArrowRight'; import Checkbox, {CheckboxProps} from '@material-ui/core/Checkbox'; @@ -31,10 +30,11 @@ import Separator from '../atoms/Separator'; import TableSortLabel from '@material-ui/core/TableSortLabel'; import TextField, {TextFieldProps} from '@material-ui/core/TextField'; import Tooltip from '@material-ui/core/Tooltip'; -import WarningIcon from '@material-ui/icons/WarningRounded'; import {classes, stylesheet} from 'typestyle'; import {fonts, fontsize, dimension, commonCss, color, padding, zIndex} from '../Css'; +import {debounce} from 'lodash'; import {InputAdornment} from '@material-ui/core'; +import {CustomTableRow} from './CustomTableRow'; export enum ExpandState { COLLAPSED, @@ -46,7 +46,7 @@ export interface Column { flex?: number; label: string; sortKey?: string; - customRenderer?: React.FC>; + customRenderer?: React.FC>; } export interface CustomRendererProps { @@ -87,6 +87,9 @@ export const css = stylesheet({ fontWeight: 'bold', letterSpacing: 0.25, marginRight: 20, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', }, emptyMessage: { padding: 20, @@ -107,9 +110,12 @@ export const css = stylesheet({ transition: 'margin 0.2s', }, expandedContainer: { + borderRadius: 10, + boxShadow: '0 1px 2px 0 rgba(60,64,67,0.30), 0 1px 3px 1px rgba(60,64,67,0.15)', margin: '16px 2px', }, expandedRow: { + borderBottom: '1px solid transparent !important', boxSizing: 'border-box', height: '40px !important', }, @@ -132,13 +138,6 @@ export const css = stylesheet({ flex: '0 0 40px', lineHeight: '40px', // must declare px }, - icon: { - color: color.alert, - height: 18, - paddingRight: 4, - verticalAlign: 'sub', - width: 18, - }, noLeftPadding: { paddingLeft: 0, }, @@ -167,7 +166,7 @@ export const css = stylesheet({ }, selectionToggle: { marginRight: 12, - minWidth: 32, + overflow: 'initial', // Resets overflow from 'hidden' }, verticalAlignInitial: { verticalAlign: 'initial', @@ -205,41 +204,13 @@ interface CustomTableState { tokenList: string[]; } -interface CustomTableRowProps { - row: Row; - columns: Column[]; -} - -function calculateColumnWidths(columns: Column[]): number[] { - const totalFlex = columns.reduce((total, c) => total += (c.flex || 1), 0); - return columns.map(c => (c.flex || 1) / totalFlex * 100); -} - -export const CustomTableRow: React.FC = (props: CustomTableRowProps) => { - const {row, columns} = props; - const widths = calculateColumnWidths(columns); - return ( - - { - row.otherFields.map((cell, i) => ( -
- {i === 0 && row.error && ( - - )} - {columns[i].customRenderer ? - columns[i].customRenderer!({value: cell, id: row.id}) : cell} -
- )) - } -
- ); -}; - export default class CustomTable extends React.Component { private _isMounted = true; - private _debouncedFilterRequest = - debounce((filterString: string) => this._requestFilter(filterString), 300); + private _debouncedFilterRequest = debounce( + (filterString: string) => this._requestFilter(filterString), + 300, + ); constructor(props: CustomTableProps) { super(props); @@ -251,8 +222,8 @@ export default class CustomTable extends React.Component v.id) : []; + const selectedIds = (event.target as CheckboxProps).checked + ? this.props.rows.map(v => v.id) + : []; if (this.props.updateSelection) { this.props.updateSelection(selectedIds); } @@ -281,9 +253,10 @@ export default class CustomTable extends React.Component (total += c.flex || 1), 0); + const widths = this.props.columns.map(c => ((c.flex || 1) / totalFlex) * 100); return (
- {/* Filter/Search bar */} {!this.props.noFilterBox && (
- - + - ) - }} /> + ), + }} + />
)} {/* Header */} -
- {(this.props.disableSelection !== true && this.props.useRadioButtons !== true) && ( -
- -
- )} - {/* Shift cells to account for expand button */} - {!!this.props.getExpandComponent && ( - - )} +
+ {// Called as function to avoid breaking shallow rendering tests. + HeaderRowSelectionSection({ + disableSelection: this.props.disableSelection, + indeterminate: !!numSelected && numSelected < this.props.rows.length, + isSelected: !!numSelected && numSelected === this.props.rows.length, + onSelectAll: this.handleSelectAllClick.bind(this), + showExpandButton: !!this.props.getExpandComponent, + useRadioButtons: this.props.useRadioButtons, + })} {this.props.columns.map((col, i) => { const isColumnSortable = !!this.props.columns[i].sortKey; const isCurrentSortColumn = sortBy === this.props.columns[i].sortKey; return ( -
- {this.props.disableSorting === true &&
{col.label}
} +
+ {this.props.disableSorting === true && col.label} {!this.props.disableSorting && ( - - + this._requestSort(this.props.columns[i].sortKey)}> + onClick={() => this._requestSort(this.props.columns[i].sortKey)} + > {col.label} @@ -372,13 +362,18 @@ export default class CustomTable extends React.Component {/* Body */} -
+
{/* Busy experience */} - {this.state.isBusy && ( -
- - )} + {this.state.isBusy && ( + +
+ + + )} {/* Empty experience */} {this.props.rows.length === 0 && !!this.props.emptyMessage && !this.state.isBusy && ( @@ -389,50 +384,44 @@ export default class CustomTable extends React.Component -
this.handleClick(e, row.id)}> - - {/* Expansion toggle button */} - {((this.props.disableSelection !== true || !!this.props.getExpandComponent) && row.expandState !== ExpandState.NONE) && ( -
- {/* If using checkboxes */} - {(this.props.disableSelection !== true && this.props.useRadioButtons !== true) && ( - )} - {/* If using radio buttons */} - {(this.props.disableSelection !== true && this.props.useRadioButtons) && ( - )} - {!!this.props.getExpandComponent && ( - this._expandButtonToggled(e, i)}> - - - )} -
+ const selected = this.isSelected(row.id); + return ( +
+ key={i} + > +
this.handleClick(e, row.id)} + > + {// Called as function to avoid breaking shallow rendering tests. + BodyRowSelectionSection({ + disableSelection: this.props.disableSelection, + expandState: row.expandState, + isSelected: selected, + onExpand: e => this._expandButtonToggled(e, i), + showExpandButton: !!this.props.getExpandComponent, + useRadioButtons: this.props.useRadioButtons, + })} + +
+ {row.expandState === ExpandState.EXPANDED && this.props.getExpandComponent && ( +
{this.props.getExpandComponent(i)}
)} - - {}
- {row.expandState === ExpandState.EXPANDED && this.props.getExpandComponent && ( -
- {this.props.getExpandComponent(i)} -
- )} -
); + ); })}
@@ -440,20 +429,29 @@ export default class CustomTable extends React.Component Rows per page: - + {[10, 20, 50, 100].map((size, i) => ( - {size} + + {size} + ))} this._pageChanged(-1)} disabled={!this.state.currentPage}> - this._pageChanged(1)} - disabled={this.state.currentPage >= this.state.maxPageIndex}> + this._pageChanged(1)} + disabled={this.state.currentPage >= this.state.maxPageIndex} + >
@@ -464,13 +462,16 @@ export default class CustomTable extends React.Component { // Override the current state with incoming request - const request: ListRequest = Object.assign({ - filter: this.state.filterStringEncoded, - orderAscending: this.state.sortOrder === 'asc', - pageSize: this.state.pageSize, - pageToken: this.state.tokenList[this.state.currentPage], - sortBy: this.state.sortBy, - }, loadRequest); + const request: ListRequest = Object.assign( + { + filter: this.state.filterStringEncoded, + orderAscending: this.state.sortOrder === 'asc', + pageSize: this.state.pageSize, + pageToken: this.state.tokenList[this.state.currentPage], + sortBy: this.state.sortBy, + }, + loadRequest, + ); let result = ''; try { @@ -482,9 +483,13 @@ export default class CustomTable extends React.Component await this._debouncedFilterRequest(value as string) + { filterString: value } as any, + async () => await this._debouncedFilterRequest(value as string), ); - } + }; // Exposed for testing protected async _requestFilter(filterString?: string): Promise { @@ -514,12 +519,11 @@ export default class CustomTable extends React.Component { + this.state.sortBy === sortBy ? (this.state.sortOrder === 'asc' ? 'desc' : 'asc') : 'asc'; + this.setStateSafe({ sortOrder, sortBy }, async () => { this._resetToFirstPage( - await this.reload({pageToken: '', orderAscending: sortOrder === 'asc', sortBy})); + await this.reload({ pageToken: '', orderAscending: sortOrder === 'asc', sortBy }), + ); }); } } @@ -543,13 +547,13 @@ export default class CustomTable extends React.Component { const pageSize = (event.target as TextFieldProps).value as number; - this._resetToFirstPage(await this.reload({pageSize, pageToken: ''})); + this._resetToFirstPage(await this.reload({ pageSize, pageToken: '' })); } private _resetToFirstPage(newPageToken?: string): void { @@ -577,3 +581,92 @@ export default class CustomTable extends React.Component = ({ + disableSelection, + indeterminate, + isSelected, + onSelectAll, + showExpandButton, + useRadioButtons, +}) => { + const nonEmpty = disableSelection !== true || showExpandButton; + if (!nonEmpty) { + return null; + } + + return ( +
+ {/* If using checkboxes */} + {disableSelection !== true && useRadioButtons !== true && ( + + )} + {/* If using radio buttons */} + {disableSelection !== true && useRadioButtons && ( + // Placeholder for radio button horizontal space. + + )} + {showExpandButton && } +
+ ); +}; + +interface BodyRowSelectionSectionProps extends SelectionSectionCommonProps { + expandState?: ExpandState; + onExpand: React.MouseEventHandler; +} +const BodyRowSelectionSection: React.FC = ({ + disableSelection, + expandState, + isSelected, + onExpand, + showExpandButton, + useRadioButtons, +}) => ( + <> + {/* Expansion toggle button */} + {(disableSelection !== true || showExpandButton) && expandState !== ExpandState.NONE && ( +
+ {/* If using checkboxes */} + {disableSelection !== true && useRadioButtons !== true && ( + + )} + {/* If using radio buttons */} + {disableSelection !== true && useRadioButtons && ( + + )} + {showExpandButton && ( + + + + )} +
+ )} + + {/* Placeholder for non-expandable rows */} + {expandState === ExpandState.NONE &&
} + +); diff --git a/frontend/src/components/CustomTableRow.test.tsx b/frontend/src/components/CustomTableRow.test.tsx new file mode 100644 index 000000000..88cdf08b0 --- /dev/null +++ b/frontend/src/components/CustomTableRow.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import { Column, Row } from './CustomTable'; +import TestUtils from '../TestUtils'; +import { shallow } from 'enzyme'; +import { CustomTableRow } from './CustomTableRow'; + +describe('CustomTable', () => { + const props = { + columns: [], + row: [], + }; + + const columns: Column[] = [ + { + customRenderer: undefined, + label: 'col1', + }, + { + customRenderer: undefined, + label: 'col2', + }, + ]; + + const row: Row = { + id: 'row', + otherFields: ['cell1', 'cell2'], + }; + + it('renders some rows using a custom renderer', async () => { + columns[0].customRenderer = () => (this is custom output) as any; + const tree = shallow(); + await TestUtils.flushPromises(); + expect(tree).toMatchSnapshot(); + columns[0].customRenderer = undefined; + }); + + it('displays warning icon with tooltip if row has error', async () => { + row.error = 'dummy error'; + const tree = shallow(); + await TestUtils.flushPromises(); + expect(tree).toMatchSnapshot(); + row.error = undefined; + }); +}); diff --git a/frontend/src/components/CustomTableRow.tsx b/frontend/src/components/CustomTableRow.tsx new file mode 100644 index 000000000..773ee0315 --- /dev/null +++ b/frontend/src/components/CustomTableRow.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import Tooltip from '@material-ui/core/Tooltip'; +import WarningIcon from '@material-ui/icons/WarningRounded'; +import { Row, Column } from './CustomTable'; +import { color, fonts, fontsize } from '../Css'; +import { stylesheet } from 'typestyle'; + +export const css = stylesheet({ + cell: { + $nest: { + '&:not(:nth-child(2))': { + color: color.inactive, + }, + }, + alignSelf: 'center', + borderBottom: 'initial', + color: color.foreground, + fontFamily: fonts.secondary, + fontSize: fontsize.base, + letterSpacing: 0.25, + marginRight: 20, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + icon: { + color: color.alert, + height: 18, + paddingRight: 4, + verticalAlign: 'sub', + width: 18, + }, + row: { + $nest: { + '&:hover': { + backgroundColor: '#f3f3f3', + }, + }, + borderBottom: '1px solid #ddd', + display: 'flex', + flexShrink: 0, + height: 40, + outline: 'none', + }, +}); + +interface CustomTableRowProps { + row: Row; + columns: Column[]; +} + +function calculateColumnWidths(columns: Column[]): number[] { + const totalFlex = columns.reduce((total, c) => (total += c.flex || 1), 0); + return columns.map(c => ((c.flex || 1) / totalFlex) * 100); +} + +// tslint:disable-next-line:variable-name +export const CustomTableRow: React.FC = (props: CustomTableRowProps) => { + const { row, columns } = props; + const widths = calculateColumnWidths(columns); + return ( + + {row.otherFields.map((cell, i) => ( +
+ {i === 0 && row.error && ( + + + + )} + {columns[i].customRenderer + ? columns[i].customRenderer!({ value: cell, id: row.id }) + : cell} +
+ ))} +
+ ); +}; diff --git a/frontend/src/components/ResourceInfo.tsx b/frontend/src/components/ResourceInfo.tsx index b0ee15dcc..1fc606514 100644 --- a/frontend/src/components/ResourceInfo.tsx +++ b/frontend/src/components/ResourceInfo.tsx @@ -13,21 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {Artifact, Execution, getMetadataValue} from '@kubeflow/frontend'; import * as React from 'react'; +import {Artifact, Execution, getMetadataValue} from '@kubeflow/frontend'; import {stylesheet} from 'typestyle'; import {color, commonCss} from '../Css'; +import {ArtifactLink} from './ArtifactLink'; export const css = stylesheet({ + field: { + flexBasis: '300px', + marginBottom: '32px', + }, resourceInfo: { display: 'flex', flexDirection: 'row', flexWrap: 'wrap', }, - field: { - flexBasis: '300px', - marginBottom: '32px', - }, term: { color: color.grey, fontSize: '12px', @@ -39,17 +40,29 @@ export const css = stylesheet({ fontSize: '14px', letterSpacing: '0.2px', lineHeight: '20px', - } + }, }); -export interface ResourceInfoProps { - resource: Artifact | Execution; - // TODO: Pass in ResourceType and render for Artifacts +export enum ResourceType { + ARTIFACT = 'ARTIFACT', + EXECUTION = 'EXECUTION', +} + +interface ArtifactProps { + resourceType: ResourceType.ARTIFACT; + resource: Artifact; typeName: string; } -export class ResourceInfo extends React.Component { +interface ExecutionProps { + resourceType: ResourceType.EXECUTION; + resource: Execution; + typeName: string; +} + +export type ResourceInfoProps = ArtifactProps | ExecutionProps; +export class ResourceInfo extends React.Component { public render(): JSX.Element { const { resource } = this.props; const propertyMap = resource.getPropertiesMap(); @@ -57,34 +70,61 @@ export class ResourceInfo extends React.Component { return (

Type: {this.props.typeName}

+ {(() => { + if (this.props.resourceType === ResourceType.ARTIFACT) { + return ( + <> +
URI
+
+ +
+ + ); + } + return null; + })()}

Properties

- {propertyMap.getEntryList() + {propertyMap + .getEntryList() // TODO: __ALL_META__ is something of a hack, is redundant, and can be ignored - .filter((k:any) => k[0] !== '__ALL_META__') - // @ts-ignore - .map((k: any) => + .filter((k: string) => k[0] !== '__ALL_META__') + .map((k: string) => (
{k[0]}
- {propertyMap && getMetadataValue(propertyMap.get(k[0]))} + {propertyMap && prettyPrintJsonValue(getMetadataValue(propertyMap.get(k[0])))}
- ) - } + ))}

Custom Properties

- {customPropertyMap.getEntryList().map((k: any) => + {customPropertyMap.getEntryList().map((k: any) => (
{k[0]}
- {customPropertyMap && getMetadataValue(customPropertyMap.get(k[0]))} + {customPropertyMap && + prettyPrintJsonValue(getMetadataValue(customPropertyMap.get(k[0])))}
- )} + ))}
); } } + +function prettyPrintJsonValue(value: string | number): JSX.Element | number | string { + if (typeof value === 'number') { + return value; + } + + try { + const jsonValue = JSON.parse(value); + return
{JSON.stringify(jsonValue, null, 2)}
; + } catch { + // not JSON, return directly + return value; + } +} diff --git a/frontend/src/components/Router.tsx b/frontend/src/components/Router.tsx index edb10747a..0f2df0636 100644 --- a/frontend/src/components/Router.tsx +++ b/frontend/src/components/Router.tsx @@ -15,6 +15,8 @@ */ import * as React from 'react'; +import ArtifactList from '../pages/ArtifactList'; +import ArtifactDetails from '../pages/ArtifactDetails'; import Banner, {BannerProps} from '../components/Banner'; import Button from '@material-ui/core/Button'; import Dialog from '@material-ui/core/Dialog'; @@ -22,16 +24,23 @@ import DialogActions from '@material-ui/core/DialogActions'; import DialogContent from '@material-ui/core/DialogContent'; import DialogTitle from '@material-ui/core/DialogTitle'; import Page404 from '../pages/404'; +import ExecutionList from '../pages/ExecutionList'; +import SideNav from '../pages/SideNav'; +import ExecutionDetails from '../pages/ExecutionDetails'; import Snackbar, {SnackbarProps} from '@material-ui/core/Snackbar'; import Toolbar, {ToolbarProps} from './Toolbar'; import {Route, Switch, Redirect, HashRouter} from 'react-router-dom'; import {classes, stylesheet} from 'typestyle'; import {commonCss} from '../Css'; -import ArtifactList from '../pages/ArtifactList'; -import ArtifactDetails from '../pages/ArtifactDetails'; -import ExecutionList from '../pages/ExecutionList'; -import SideNav from '../pages/SideNav'; -import ExecutionDetails from '../pages/ExecutionDetails'; + + +// tslint:disable: variable-name +export type RouteConfig = { + path: string; + Component: React.ComponentType; + view?: any; + notExact?: boolean; +}; const css = stylesheet({ dialog: { @@ -42,9 +51,15 @@ const css = stylesheet({ export enum RouteParams { ARTIFACT_TYPE = 'artifactType', EXECUTION_TYPE = 'executionType', + // TODO: create one of these for artifact and execution? ID = 'id', } +export const RoutePrefix = { + ARTIFACT: '/artifact', + EXECUTION: '/execution', +}; + export const RoutePage = { ARTIFACTS: '/artifacts', ARTIFACT_DETAILS: `/artifact_types/:${RouteParams.ARTIFACT_TYPE}+/artifacts/:${RouteParams.ID}`, @@ -52,8 +67,23 @@ export const RoutePage = { EXECUTION_DETAILS: `/execution_types/:${RouteParams.EXECUTION_TYPE}+/executions/:${RouteParams.ID}`, }; +export const RoutePageFactory = { + artifactDetails: (artifactType: string, artifactId: number) => { + return RoutePage.ARTIFACT_DETAILS.replace( + `:${RouteParams.ARTIFACT_TYPE}+`, + artifactType, + ).replace(`:${RouteParams.ID}`, '' + artifactId); + }, +}; + +export const ExternalLinks = { + AI_HUB: 'https://aihub.cloud.google.com/u/0/s?category=pipeline', + DOCUMENTATION: 'https://www.kubeflow.org/docs/pipelines/', + GITHUB: 'https://github.com/kubeflow/pipelines', +}; + export interface DialogProps { - buttons?: Array<{onClick?: () => any, text: string}>; + buttons?: Array<{onClick?: () => any; text: string}>; // TODO: This should be generalized to any react component. content?: string; onClose?: () => any; @@ -61,12 +91,6 @@ export interface DialogProps { title?: string; } -interface RouteConfig { - path: string, - Component: React.ComponentClass, - view?: any -} - interface RouteComponentState { bannerProps: BannerProps; dialogProps: DialogProps; @@ -74,15 +98,59 @@ interface RouteComponentState { toolbarProps: ToolbarProps; } -class Router extends React.Component<{}, RouteComponentState> { +export interface RouterProps { + configs?: RouteConfig[]; // only used in tests +} + +const DEFAULT_ROUTE = RoutePage.ARTIFACTS; - private routes: RouteConfig[] = [ +// This component is made as a wrapper to separate toolbar state for different pages. +const Router: React.FC = ({configs}) => { + const routes: RouteConfig[] = configs || [ {path: RoutePage.ARTIFACTS, Component: ArtifactList}, - {path: RoutePage.ARTIFACT_DETAILS, Component: ArtifactDetails}, + {path: RoutePage.ARTIFACT_DETAILS, Component: ArtifactDetails, notExact: true}, {path: RoutePage.EXECUTIONS, Component: ExecutionList}, {path: RoutePage.EXECUTION_DETAILS, Component: ExecutionDetails}, ]; + return ( + // There will be only one instance of SideNav, throughout UI usage. + + + } + /> + + {/* Normal routes */} + {routes.map((route, i) => { + const { path } = { ...route }; + return ( + // Setting a key here, so that two different routes are considered two instances from + // react. Therefore, they don't share toolbar state. This avoids many bugs like dangling + // network response handlers. + } + /> + ); + })} + + {/* 404 */} + { + + + + } + + + ); +}; + +class RoutedPage extends React.Component<{route?: RouteConfig}, RouteComponentState> { constructor(props: any) { super(props); this.state = { @@ -101,65 +169,75 @@ class Router extends React.Component<{}, RouteComponentState> { updateSnackbar: this._updateSnackbar.bind(this), updateToolbar: this._updateToolbar.bind(this), }; + const route = this.props.route; + return ( - -
-
- ()} /> -
- ()} /> - {this.state.bannerProps.message - && } - - ( - - )} /> - {this.routes.map((route, i) => { - const {path, Component, ...otherProps} = {...route}; - return ( +
+ } /> + {this.state.bannerProps.message && ( + + )} + + {route && + (() => { + const { path, Component, ...otherProps } = { ...route }; + return ( + ( - )} />; - })} - - {/* 404 */} - { } />} - - - -
-
- - this._handleDialogClosed()}> - {this.state.dialogProps.title && ( - {this.state.dialogProps.title} - )} - {this.state.dialogProps.content && ( - - {this.state.dialogProps.content} - - )} - {this.state.dialogProps.buttons && ( - - {this.state.dialogProps.buttons.map((b, i) => - )} - - )} - -
- + )} + /> + ); + })()} + + {/* 404 */} + {!!route && } />} + + + + + this._handleDialogClosed()} + > + {this.state.dialogProps.title && ( + {this.state.dialogProps.title} + )} + {this.state.dialogProps.content && ( + + {this.state.dialogProps.content} + + )} + {this.state.dialogProps.buttons && ( + + {this.state.dialogProps.buttons.map((b, i) => ( + + ))} + + )} + +
); } @@ -204,3 +282,14 @@ class Router extends React.Component<{}, RouteComponentState> { // TODO: loading/error experience until backend is reachable export default Router; + +const SideNavLayout: React.FC<{}> = ({children}) => ( + +
+
+ } /> + {children} +
+
+
+); diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx new file mode 100644 index 000000000..db91b4145 --- /dev/null +++ b/frontend/src/components/SideNav.tsx @@ -0,0 +1,429 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Button from '@material-ui/core/Button'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import ArtifactsIcon from '@material-ui/icons/BubbleChart'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ExecutionsIcon from '@material-ui/icons/PlayArrow'; +import * as React from 'react'; +import {RoutePage, RoutePrefix} from '../components/Router'; +import {RouterProps} from 'react-router'; +import {Link} from 'react-router-dom'; +import {classes, stylesheet} from 'typestyle'; +import {commonCss, fontsize} from '../Css'; +import {LocalStorage, LocalStorageKey} from '../lib/LocalStorage'; +import {logger} from '../lib/Utils'; + +export interface BuildInfo { + apiServerCommitHash?: string; + apiServerReady?: boolean; + buildDate?: string; + frontendCommitHash?: string; +} + +const v1beta1Prefix = 'apis/v1beta1'; +async function getBuildInfo(): Promise { + return await _fetchAndParse('/healthz', v1beta1Prefix); +} +async function _fetchAndParse( + path: string, + apisPrefix?: string, + query?: string, + init?: RequestInit, +): Promise { + const responseText = await _fetch(path, apisPrefix, query, init); + try { + return JSON.parse(responseText) as T; + } catch (err) { + throw new Error( + `Error parsing response for path: ${path}\n\n` + + `Response was: ${responseText}\n\nError was: ${JSON.stringify(err)}`, + ); + } +} + +async function _fetch( + path: string, + apisPrefix?: string, + query?: string, + init?: RequestInit, +): Promise { + init = Object.assign(init || {}, { credentials: 'same-origin' }); + const response = await fetch((apisPrefix || '') + path + (query ? '?' + query : ''), init); + const responseText = await response.text(); + if (response.ok) { + return responseText; + } else { + logger.error( + `Response for path: ${path} was not 'ok'\n\nResponse was: ${responseText}`, + ); + throw new Error(responseText); + } +} + +export const sideNavColors = { + bg: '#f8fafb', + fgActive: '#0d6de7', + fgActiveInvisible: 'rgb(227, 233, 237, 0)', + fgDefault: '#9aa0a6', + hover: '#f1f3f4', + separator: '#bdc1c6', + sideNavBorder: '#e8eaed', +}; + +const COLLAPSED_SIDE_NAV_SIZE = 72; +const EXPANDED_SIDE_NAV_SIZE = 220; + +export const css = stylesheet({ + active: { + color: sideNavColors.fgActive + ' !important', + }, + button: { + $nest: { + '&::hover': { + backgroundColor: sideNavColors.hover, + }, + }, + borderRadius: 0, + color: sideNavColors.fgDefault, + display: 'block', + fontSize: fontsize.medium, + fontWeight: 'bold', + height: 44, + marginBottom: 16, + maxWidth: EXPANDED_SIDE_NAV_SIZE, + overflow: 'hidden', + padding: '12px 10px 10px 26px', + textAlign: 'left', + textTransform: 'none', + transition: 'max-width 0.3s', + whiteSpace: 'nowrap', + width: EXPANDED_SIDE_NAV_SIZE, + }, + chevron: { + color: sideNavColors.fgDefault, + marginLeft: 16, + padding: 6, + transition: 'transform 0.3s', + }, + collapsedButton: { + maxWidth: COLLAPSED_SIDE_NAV_SIZE, + minWidth: COLLAPSED_SIDE_NAV_SIZE, + padding: '12px 10px 10px 26px', + }, + collapsedChevron: { + transform: 'rotate(180deg)', + }, + collapsedExternalLabel: { + // Hide text when collapsing, but do it with a transition of both height and + // opacity + height: 0, + opacity: 0, + }, + collapsedLabel: { + // Hide text when collapsing, but do it with a transition + opacity: 0, + }, + collapsedRoot: { + width: `${COLLAPSED_SIDE_NAV_SIZE}px !important`, + }, + collapsedSeparator: { + margin: '20px !important', + }, + envMetadata: { + color: sideNavColors.fgDefault, + marginBottom: 16, + marginLeft: 30, + }, + icon: { + height: 20, + width: 20, + }, + iconImage: { + opacity: 0.6, // Images are too colorful there by default, reduce their color. + }, + indicator: { + borderBottom: '3px solid transparent', + borderLeft: `3px solid ${sideNavColors.fgActive}`, + borderTop: '3px solid transparent', + height: 38, + left: 0, + position: 'absolute', + zIndex: 1, + }, + indicatorHidden: { + opacity: 0, + }, + infoHidden: { + opacity: 0, + transition: 'opacity 0s', + transitionDelay: '0s', + }, + infoVisible: { + opacity: 'initial', + transition: 'opacity 0.2s', + transitionDelay: '0.3s', + }, + label: { + fontSize: fontsize.base, + letterSpacing: 0.25, + marginLeft: 20, + transition: 'opacity 0.3s', + verticalAlign: 'super', + }, + link: { + color: '#77abda', + }, + openInNewTabIcon: { + height: 12, + marginBottom: 8, + marginLeft: 5, + width: 12, + }, + root: { + background: sideNavColors.bg, + borderRight: `1px ${sideNavColors.sideNavBorder} solid`, + paddingTop: 15, + transition: 'width 0.3s', + width: EXPANDED_SIDE_NAV_SIZE, + }, + separator: { + border: '0px none transparent', + borderTop: `1px solid ${sideNavColors.separator}`, + margin: 20, + }, +}); + +interface DisplayBuildInfo { + commitHash: string; + commitUrl: string; + date: string; +} + +interface SideNavProps extends RouterProps { + page: string; +} + +interface SideNavState { + displayBuildInfo?: DisplayBuildInfo; + collapsed: boolean; + jupyterHubAvailable: boolean; + manualCollapseState: boolean; +} + +export class SideNav extends React.Component { + private _isMounted = true; + private readonly _AUTO_COLLAPSE_WIDTH = 800; + + constructor(props: any) { + super(props); + + const collapsed = LocalStorage.isNavbarCollapsed(); + + this.state = { + collapsed, + // Set jupyterHubAvailable to false so UI don't show Jupyter Hub link + jupyterHubAvailable: false, + manualCollapseState: LocalStorage.hasKey(LocalStorageKey.navbarCollapsed), + }; + } + + public async componentDidMount(): Promise { + window.addEventListener('resize', this._maybeResize.bind(this)); + this._maybeResize(); + + async function fetchBuildInfo() { + const buildInfo = await getBuildInfo(); + const commitHash = buildInfo.apiServerCommitHash || buildInfo.frontendCommitHash || ''; + return { + commitHash: commitHash ? commitHash.substring(0, 7) : 'unknown', + commitUrl: + 'https://www.github.com/kubeflow/pipelines' + (commitHash ? `/commit/${commitHash}` : ''), + date: buildInfo.buildDate + ? new Date(buildInfo.buildDate).toLocaleDateString('en-US') + : 'unknown', + }; + } + const displayBuildInfo = await fetchBuildInfo().catch(err => { + logger.error('Failed to retrieve build info', err); + return undefined; + }); + + this.setStateSafe({ displayBuildInfo }); + } + + public componentWillUnmount(): void { + this._isMounted = false; + } + + public render(): JSX.Element { + const page = this.props.page; + const { collapsed, displayBuildInfo } = this.state; + return ( +
+
+
+ + + + + +
+ + + + + +
+ + + +
+
+ {displayBuildInfo && ( + + + + )} + + + +
+
+ ); + } + + private _highlightArtifactsButton(page: string): boolean { + return page.startsWith(RoutePrefix.ARTIFACT); + } + + private _highlightExecutionsButton(page: string): boolean { + return page.startsWith(RoutePrefix.EXECUTION); + } + + private _toggleNavClicked(): void { + this.setStateSafe( + { + collapsed: !this.state.collapsed, + manualCollapseState: true, + }, + () => LocalStorage.saveNavbarCollapsed(this.state.collapsed), + ); + this._toggleNavCollapsed(); + } + + private _toggleNavCollapsed(shouldCollapse?: boolean): void { + this.setStateSafe({ + collapsed: shouldCollapse !== undefined ? shouldCollapse : !this.state.collapsed, + }); + } + + private _maybeResize(): void { + if (!this.state.manualCollapseState) { + this._toggleNavCollapsed(window.innerWidth < this._AUTO_COLLAPSE_WIDTH); + } + } + + private setStateSafe(newState: Partial, cb?: () => void): void { + if (this._isMounted) { + this.setState(newState as any, cb); + } + } +} + +export default SideNav; diff --git a/frontend/src/components/Toolbar.test.tsx b/frontend/src/components/Toolbar.test.tsx index db270da4b..bf3b34420 100644 --- a/frontend/src/components/Toolbar.test.tsx +++ b/frontend/src/components/Toolbar.test.tsx @@ -17,14 +17,14 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import { createBrowserHistory, createMemoryHistory } from 'history'; -import Toolbar from './Toolbar'; +import Toolbar, { ToolbarActionMap } from './Toolbar'; import HelpIcon from '@material-ui/icons/Help'; import InfoIcon from '@material-ui/icons/Info'; const action1 = jest.fn(); const action2 = jest.fn(); -const actions = [ - { +const actions: ToolbarActionMap = { + action1: { action: action1, disabledTitle: 'test disabled title', icon: HelpIcon, @@ -32,7 +32,7 @@ const actions = [ title: 'test title', tooltip: 'test tooltip', }, - { + action2: { action: action2, disabled: true, disabledTitle: 'test disabled title2', @@ -41,7 +41,7 @@ const actions = [ title: 'test title2', tooltip: 'test tooltip2', }, -]; +}; const breadcrumbs = [ { @@ -62,100 +62,160 @@ describe('Toolbar', () => { }); it('renders nothing when there are no breadcrumbs or actions', () => { - const tree = shallow(); + const tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders without breadcrumbs and a string page title', () => { - const tree = shallow(); + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); it('renders without breadcrumbs and a component page title', () => { - const tree = shallow(test page title
} />); + const tree = shallow( + test page title
} + />, + ); expect(tree).toMatchSnapshot(); }); it('renders without breadcrumbs and one action', () => { - const tree = shallow(); + const singleAction = { + action1: { + action: action1, + disabledTitle: 'test disabled title', + icon: HelpIcon, + id: 'test id', + title: 'test title', + tooltip: 'test tooltip', + }, + }; + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); it('renders without actions and one breadcrumb', () => { - const tree = shallow(); + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); it('renders without actions, one breadcrumb, and a page name', () => { - const tree = shallow(); + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); it('renders without breadcrumbs and two actions', () => { - const tree = shallow(); + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); it('fires the right action function when button is clicked', () => { - const tree = shallow(); - tree.find('BusyButton').at(0).simulate('click'); + const tree = shallow( + , + ); + tree + .find('BusyButton') + .at(0) + .simulate('click'); expect(action1).toHaveBeenCalled(); - action2.mockClear(); + action1.mockClear(); }); it('renders outlined action buttons', () => { - const outlinedActions = [{ - action: jest.fn(), - id: 'test id', - outlined: true, - title: 'test title', - tooltip: 'test tooltip', - }]; - - const tree = shallow(); + const outlinedActions = { + action1: { + action: jest.fn(), + id: 'test outlined id', + outlined: true, + title: 'test outlined title', + tooltip: 'test outlined tooltip', + }, + }; + + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); it('renders primary action buttons', () => { - const outlinedActions = [{ - action: jest.fn(), - id: 'test id', - primary: true, - title: 'test title', - tooltip: 'test tooltip', - }]; - - const tree = shallow(); + const primaryActions = { + action1: { + action: jest.fn(), + id: 'test primary id', + primary: true, + title: 'test primary title', + tooltip: 'test primary tooltip', + }, + }; + + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); it('renders primary action buttons without outline, even if outline is true', () => { - const outlinedActions = [{ - action: jest.fn(), - id: 'test id', - outlined: true, - primary: true, - title: 'test title', - tooltip: 'test tooltip', - }]; - - const tree = shallow(); + const outlinedPrimaryActions = { + action1: { + action: jest.fn(), + id: 'test id', + outlined: true, + primary: true, + title: 'test title', + tooltip: 'test tooltip', + }, + }; + + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); it('renders with two breadcrumbs and two actions', () => { - const tree = shallow(); + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); @@ -163,7 +223,9 @@ describe('Toolbar', () => { // This test uses createMemoryHistory because createBroweserHistory returns a singleton, and // there is no way to clear its entries which this test requires. const emptyHistory = createMemoryHistory(); - const tree = shallow(); + const tree = shallow( + , + ); expect(tree).toMatchSnapshot(); }); -}); +}); \ No newline at end of file diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index a5a0b314a..9575b1e28 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -14,16 +14,21 @@ * limitations under the License. */ -import * as React from 'react'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; -import BusyButton from '../atoms/BusyButton'; -import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import IconButton from '@material-ui/core/IconButton'; import Tooltip from '@material-ui/core/Tooltip'; -import { History } from 'history'; -import { Link } from 'react-router-dom'; -import { classes, stylesheet } from 'typestyle'; -import { spacing, fonts, fontsize, color, dimension, commonCss } from '../Css'; +import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import {History} from 'history'; +import * as React from 'react'; +import {CSSProperties} from 'react'; +import {Link} from 'react-router-dom'; +import {classes, stylesheet} from 'typestyle'; +import BusyButton from '../atoms/BusyButton'; +import {color, commonCss, dimension, fonts, fontsize, spacing} from '../Css'; + +export interface ToolbarActionMap { + [key: string]: ToolbarActionConfig; +} export interface ToolbarActionConfig { action: () => void; @@ -34,6 +39,7 @@ export interface ToolbarActionConfig { id?: string; outlined?: boolean; primary?: boolean; + style?: CSSProperties; title: string; tooltip: string; } @@ -79,15 +85,15 @@ const css = stylesheet({ $nest: { '&:hover': { background: color.lightGrey, - } + }, }, borderRadius: 3, padding: 3, }, pageName: { color: color.strong, - fontSize: fontsize.title, - lineHeight: `${backIconHeight}px`, + fontSize: fontsize.pageTitle, + lineHeight: '28px', }, root: { alignItems: 'center', @@ -104,7 +110,7 @@ const css = stylesheet({ }); export interface ToolbarProps { - actions: ToolbarActionConfig[]; + actions: ToolbarActionMap; breadcrumbs: Breadcrumb[]; history?: History; pageTitle: string | JSX.Element; @@ -113,25 +119,27 @@ export interface ToolbarProps { } class Toolbar extends React.Component { - public render(): JSX.Element | null { - const { breadcrumbs, pageTitle, pageTitleTooltip } = { ...this.props }; + const { actions, breadcrumbs, pageTitle, pageTitleTooltip } = { ...this.props }; - if (!this.props.actions.length && !this.props.breadcrumbs.length && !this.props.pageTitle) { + if (!actions.length && !breadcrumbs.length && !pageTitle) { return null; } return ( -
-
+
+
{/* Breadcrumb */}
{breadcrumbs.map((crumb, i) => ( {i !== 0 && } - + {crumb.displayName} @@ -139,40 +147,69 @@ class Toolbar extends React.Component {
{/* Back Arrow */} - {breadcrumbs.length > 0 && + {breadcrumbs.length > 0 && ( -
{/* Div needed because we sometimes disable a button within a tooltip */} - + {' '} + {/* Div needed because we sometimes disable a button within a tooltip */} + - + onClick={this.props.history!.goBack} + > +
-
} + + )} {/* Resource Name */} - + {pageTitle}
{/* Actions / Buttons */}
- {this.props.actions.map((b, i) => ( - -
{/* Extra level needed by tooltip when child is disabled */} - -
-
- ))} + {Object.keys(actions).map((buttonKey, i) => { + const button = actions[buttonKey]; + return ( + +
+ {/* Extra level needed by tooltip when child is disabled */} + +
+
+ ); + })}
); } } -export default Toolbar; +export default Toolbar; \ No newline at end of file diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 1680a7bf5..de1ea93ba 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -19,8 +19,8 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider'; import Router from './components/Router'; -import { cssRule } from 'typestyle'; -import { theme, fonts } from './Css'; +import {cssRule} from 'typestyle'; +import {theme, fonts} from './Css'; // TODO: license headers diff --git a/frontend/src/lib/Utils.tsx b/frontend/src/lib/Utils.tsx index e03457702..621bcc808 100644 --- a/frontend/src/lib/Utils.tsx +++ b/frontend/src/lib/Utils.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2019 Google LLC + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,15 +14,49 @@ * limitations under the License. */ -import { - ListRequest, -} from '@kubeflow/frontend'; import * as React from 'react'; -import isFunction from 'lodash.isfunction'; -import {Column, css as customTableCss, CustomTableRow, ExpandState, Row} from '../components/CustomTable'; -import {classes} from 'typestyle'; +import {isFunction} from 'lodash'; +import {ListRequest} from '@kubeflow/frontend'; +import {Row, Column, ExpandState, css} from '../components/CustomTable'; import {padding} from '../Css'; +import {classes} from 'typestyle'; +import {CustomTableRow} from '../components/CustomTableRow'; + +export const logger = { + error: (...args: any[]) => { + // tslint:disable-next-line:no-console + console.error(...args); + }, + warn: (...args: any[]) => { + // tslint:disable-next-line:no-console + console.warn(...args); + }, + verbose: (...args: any[]) => { + // tslint:disable-next-line:no-console + console.log(...args); + }, +}; + +export function extendError(err: any, extraMessage?: string): any { + if (err.message && typeof err.message === 'string') { + err.message = extraMessage + ': ' + err.message; + } + return err; +} +export function rethrow(err: any, extraMessage?: string): never { + throw extendError(err, extraMessage); +} + +export function formatDateString(date: Date | string | undefined): string { + if (typeof date === 'string') { + return new Date(date).toLocaleString(); + } else { + return date ? date.toLocaleString() : '-'; + } +} + +// TODO: add tests export async function errorToMessage(error: any): Promise { if (error instanceof Error) { return error.message; @@ -35,6 +69,20 @@ export async function errorToMessage(error: any): Promise { return JSON.stringify(error) || ''; } +export function s(items: any[] | number): string { + const length = Array.isArray(items) ? items.length : items; + return length === 1 ? '' : 's'; +} + +interface ServiceError { + message: string; + code?: number; +} + +export function serviceErrorToString(error: ServiceError): string { + return `Error: ${error.message}.${error.code ? ` Code: ${error.code}` : ''}`; +} + /** * Returns true if no filter is specified, or if the filter string matches any of the row's columns, * case insensitively. @@ -43,17 +91,51 @@ export async function errorToMessage(error: any): Promise { export function rowFilterFn(request: ListRequest): (r: Row) => boolean { // TODO: We are currently searching across all properties of all artifacts. We should figure // what the most useful fields are and limit filtering to those - return (r) => !request.filter - || (r.otherFields.join('').toLowerCase().indexOf(request.filter.toLowerCase()) > -1); + return r => { + if (!request.filter) { + return true; + } + + const decodedFilter = decodeURIComponent(request.filter); + try { + const filter = JSON.parse(decodedFilter); + if (!filter.predicates || filter.predicates.length === 0) { + return true; + } + // TODO: Extend this to look at more than a single predicate + const filterString = + '' + + (filter.predicates[0].int_value || + filter.predicates[0].long_value || + filter.predicates[0].string_value); + return ( + r.otherFields + .join('') + .toLowerCase() + .indexOf(filterString.toLowerCase()) > -1 + ); + } catch (err) { + logger.error('Error parsing request filter!', err); + return true; + } + }; } -export function rowCompareFn(request: ListRequest, columns: Column[]): (r1: Row, r2: Row) => number { +export function rowCompareFn( + request: ListRequest, + columns: Column[], +): (r1: Row, r2: Row) => number { return (r1, r2) => { if (!request.sortBy) { return -1; } - const sortIndex = columns.findIndex((c) => request.sortBy === c.sortKey); + const descSuffix = ' desc'; + const cleanedSortBy = request.sortBy.endsWith(descSuffix) + ? request.sortBy.substring(0, request.sortBy.length - descSuffix.length) + : request.sortBy; + + const sortIndex = columns.findIndex(c => cleanedSortBy === c.sortKey); // Convert null to string to avoid null comparison behavior const compare = (r1.otherFields[sortIndex] || '') < (r2.otherFields[sortIndex] || ''); @@ -81,9 +163,9 @@ export interface CollapsedAndExpandedRows { export function groupRows(rows: Row[]): CollapsedAndExpandedRows { const flattenedRows = rows.reduce((map, r) => { const stringKey = r.otherFields[0]; - const rows = map.get(stringKey); - if (rows) { - rows.push(r); + const rowsForKey = map.get(stringKey); + if (rowsForKey) { + rowsForKey.push(r); } else { map.set(stringKey, [r]); } @@ -93,26 +175,26 @@ export function groupRows(rows: Row[]): CollapsedAndExpandedRows { const collapsedAndExpandedRows: CollapsedAndExpandedRows = { collapsedRows: [], expandedRows: new Map(), - } + }; // collapsedRows are the first row of each group, what the user sees before expanding a group. Array.from(flattenedRows.entries()) // entries() returns in insertion order .forEach((entry, index) => { // entry[0] is a grouping key, entry[1] is a list of rows - const rows = entry[1]; + const rowsInGroup = entry[1]; // If there is only one row in the group, don't allow expansion. // Only the first row is displayed when collapsed - if (rows.length === 1) { - rows[0].expandState = ExpandState.NONE; + if (rowsInGroup.length === 1) { + rowsInGroup[0].expandState = ExpandState.NONE; } // Add the first row in this group to be displayed as collapsed row - collapsedAndExpandedRows.collapsedRows.push(rows[0]); + collapsedAndExpandedRows.collapsedRows.push(rowsInGroup[0]); // Remove the grouping column text for all but the first row in the group because it will be // redundant within an expanded group. - const hiddenRows = rows.slice(1); - hiddenRows.forEach(row => row.otherFields[0] = ''); + const hiddenRows = rowsInGroup.slice(1); + hiddenRows.forEach(row => (row.otherFields[0] = '')); // Add this group of rows sharing a pipeline to the list of grouped rows collapsedAndExpandedRows.expandedRows.set(index, hiddenRows); @@ -126,20 +208,21 @@ export function groupRows(rows: Row[]): CollapsedAndExpandedRows { * row. * @param index */ -export function getExpandedRow(expandedRows: Map, columns: Column[]): (index: number) => React.ReactNode { +export function getExpandedRow( + expandedRows: Map, + columns: Column[], +): (index: number) => React.ReactNode { return (index: number) => { const rows = expandedRows.get(index) || []; return (
- { - rows.map((r, rindex) => ( -
- -
- )) - } + {rows.map((r, rindex) => ( +
+ +
+ ))}
); - } + }; } diff --git a/frontend/src/pages/404.tsx b/frontend/src/pages/404.tsx index 57f76d579..1e208a8b0 100644 --- a/frontend/src/pages/404.tsx +++ b/frontend/src/pages/404.tsx @@ -20,7 +20,7 @@ import { ToolbarProps } from '../components/Toolbar'; export default class Page404 extends Page<{}, {}> { public getInitialToolbarState(): ToolbarProps { - return { actions: [], breadcrumbs: [], pageTitle: '' }; + return {actions: {}, breadcrumbs: [], pageTitle: ''}; } public async refresh(): Promise { @@ -29,9 +29,9 @@ export default class Page404 extends Page<{}, {}> { public render(): JSX.Element { return ( -
-
404
-
Page Not Found: {this.props.location.pathname}
+
+
404
+
Page Not Found: {this.props.location.pathname}
); } diff --git a/frontend/src/pages/ArtifactDetails.tsx b/frontend/src/pages/ArtifactDetails.tsx index 1ea690d10..d204d8c53 100644 --- a/frontend/src/pages/ArtifactDetails.tsx +++ b/frontend/src/pages/ArtifactDetails.tsx @@ -17,114 +17,142 @@ import { Api, Artifact, + ArtifactCustomProperties, ArtifactProperties, GetArtifactsByIDRequest, - LineageView, - titleCase, getResourceProperty, LineageResource, + LineageView, + titleCase, } from '@kubeflow/frontend'; import * as React from 'react'; -import {Page, PageProps} from './Page'; -import {ToolbarProps} from '../components/Toolbar'; -import {RoutePage, RouteParams} from '../components/Router'; -import {classes} from 'typestyle'; -import {commonCss, padding} from '../Css'; import {CircularProgress} from '@material-ui/core'; -import {ResourceInfo} from "../components/ResourceInfo"; +import {Route, Switch} from 'react-router-dom'; +import {classes} from 'typestyle'; import MD2Tabs from '../atoms/MD2Tabs'; +import {ResourceInfo, ResourceType} from '../components/ResourceInfo'; +import {RoutePage, RoutePageFactory, RouteParams} from '../components/Router'; +import {ToolbarProps} from '../components/Toolbar'; +import {commonCss, padding} from '../Css'; +import {logger, serviceErrorToString} from '../lib/Utils'; +import {Page, PageProps} from './Page'; export enum ArtifactDetailsTab { OVERVIEW = 0, LINEAGE_EXPLORER = 1, } -const tabs = { +const LINEAGE_PATH = 'lineage'; + +const TABS = { [ArtifactDetailsTab.OVERVIEW]: {name: 'Overview'}, [ArtifactDetailsTab.LINEAGE_EXPLORER]: {name: 'Lineage Explorer'}, }; -const tabNames = Object.values(tabs).map(tabConfig => tabConfig.name); +const TAB_NAMES = [ArtifactDetailsTab.OVERVIEW, ArtifactDetailsTab.LINEAGE_EXPLORER].map( + tabConfig => TABS[tabConfig].name, +); interface ArtifactDetailsState { artifact?: Artifact; - selectedTab: ArtifactDetailsTab; } -export default class ArtifactDetails extends Page<{}, ArtifactDetailsState> { - private api = Api.getInstance(); - - constructor(props: {}) { - super(props); - this.state = { - selectedTab: ArtifactDetailsTab.OVERVIEW - }; - this.load = this.load.bind(this); - } - - componentDidUpdate( - prevProps: Readonly<{} & PageProps>, - prevState: Readonly, - snapshot?: any): void { - if (this.props.match.params[RouteParams.ID] === prevProps.match.params[RouteParams.ID]) return; - - this.setState({ - artifact: undefined, - selectedTab: ArtifactDetailsTab.OVERVIEW, - }); - this.load(); - } - +class ArtifactDetails extends Page<{}, ArtifactDetailsState> { private get fullTypeName(): string { return this.props.match.params[RouteParams.ARTIFACT_TYPE] || ''; } private get properTypeName(): string { const parts = this.fullTypeName.split('/'); - if (!parts.length) return ''; - + if (!parts.length) { + return ''; + } return titleCase(parts[parts.length - 1]); } - private get id(): string { - return this.props.match.params[RouteParams.ID]; + private get id(): number { + return Number(this.props.match.params[RouteParams.ID]); + } + + private static buildResourceDetailsPageRoute( + resource: LineageResource, + typeName: string, + ): string { + let route; + if (resource instanceof Artifact) { + route = RoutePageFactory.artifactDetails(typeName, resource.getId()); + } else { + route = RoutePage.EXECUTION_DETAILS.replace(`:${RouteParams.EXECUTION_TYPE}+`, typeName); + } + return route.replace(`:${RouteParams.ID}`, String(resource.getId())); } + public state: ArtifactDetailsState = {}; + + private api = Api.getInstance(); + public async componentDidMount(): Promise { return this.load(); } public render(): JSX.Element { - if (!this.state.artifact) return ; + if (!this.state.artifact) { + return ( +
+ +
+ ); + } return (
-
- -
- {this.state.selectedTab === ArtifactDetailsTab.OVERVIEW && ( -
- -
- )} - {this.state.selectedTab === ArtifactDetailsTab.LINEAGE_EXPLORER && ( - - )} + + {/* + ** This is react-router's nested route feature. + ** reference: https://reacttraining.com/react-router/web/example/nesting + */} + + <> +
+ +
+
+ +
+ +
+ + <> +
+ +
+ + +
+
); } public getInitialToolbarState(): ToolbarProps { return { - actions: [], - breadcrumbs: [{displayName: 'Artifacts', href: RoutePage.ARTIFACTS}], - pageTitle: `${this.properTypeName} ${this.id} details` + actions: {}, + breadcrumbs: [{ displayName: 'Artifacts', href: RoutePage.ARTIFACTS }], + pageTitle: `${this.properTypeName} ${this.id} details`, }; } @@ -132,50 +160,57 @@ export default class ArtifactDetails extends Page<{}, ArtifactDetailsState> { return this.load(); } - private async load(): Promise { + private load = async (): Promise => { const request = new GetArtifactsByIDRequest(); request.setArtifactIdsList([Number(this.id)]); - const response = await this.api.metadataStoreService.getArtifactsByID(request); - - if (!response) { - this.showPageError(`Unable to retrieve ${this.fullTypeName} ${this.id}.`); - return - } - - if (!response!.getArtifactsList()!.length) { - this.showPageError(`No ${this.fullTypeName} identified by id: ${this.id}`); - return; + try { + const response = await this.api.metadataStoreService.getArtifactsByID(request); + + if (response.getArtifactsList().length === 0) { + this.showPageError(`No ${this.fullTypeName} identified by id: ${this.id}`); + return; + } + + if (response.getArtifactsList().length > 1) { + this.showPageError(`Found multiple artifacts with ID: ${this.id}`); + return; + } + + const artifact = response.getArtifactsList()[0]; + + const artifactName = + getResourceProperty(artifact, ArtifactProperties.NAME) || + getResourceProperty(artifact, ArtifactCustomProperties.NAME, true); + let title = artifactName ? artifactName.toString() : ''; + const version = getResourceProperty(artifact, ArtifactProperties.VERSION); + if (version) { + title += ` (version: ${version})`; + } + this.props.updateToolbar({ + pageTitle: title, + }); + this.setState({ artifact }); + } catch (err) { + this.showPageError(serviceErrorToString(err)); } - - if (response!.getArtifactsList().length > 1) { - this.showPageError(`Found multiple artifacts with ID: ${this.id}`); - return; - } - - const artifact = response!.getArtifactsList()[0]; - - const artifactName = getResourceProperty(artifact, ArtifactProperties.NAME); - let title = artifactName ? artifactName.toString() : ''; - const version = getResourceProperty(artifact, ArtifactProperties.VERSION); - if (version) { - title += ` (version: ${version})`; + }; + + private switchTab = (selectedTab: number) => { + switch (selectedTab) { + case ArtifactDetailsTab.LINEAGE_EXPLORER: + return this.props.history.push(`${this.props.match.url}/${LINEAGE_PATH}`); + case ArtifactDetailsTab.OVERVIEW: + return this.props.history.push(this.props.match.url); + default: + logger.error(`Unknown selected tab ${selectedTab}.`); } - this.props.updateToolbar({ - pageTitle: title - }); - this.setState({artifact}); - } + }; +} - private switchTab(selectedTab: number) { - this.setState({selectedTab}); - } +// This guarantees that each artifact renders a different instance. +const EnhancedArtifactDetails = (props: PageProps) => { + return ; +}; - private static buildResourceDetailsPageRoute( - resource: LineageResource, typeName: string): string { - let route = resource instanceof Artifact ? - RoutePage.ARTIFACT_DETAILS.replace(`:${RouteParams.ARTIFACT_TYPE}+`, typeName) : - RoutePage.EXECUTION_DETAILS.replace(`:${RouteParams.EXECUTION_TYPE}+`, typeName); - return route.replace(`:${RouteParams.ID}`, String(resource.getId())); - } -} +export default EnhancedArtifactDetails; diff --git a/frontend/src/pages/ArtifactList.tsx b/frontend/src/pages/ArtifactList.tsx index 37706bfcc..e54c318cb 100644 --- a/frontend/src/pages/ArtifactList.tsx +++ b/frontend/src/pages/ArtifactList.tsx @@ -81,7 +81,7 @@ class ArtifactList extends Page<{}, ArtifactListState> { public getInitialToolbarState(): ToolbarProps { return { - actions: [], + actions: {}, breadcrumbs: [], pageTitle: 'Artifacts', }; diff --git a/frontend/src/pages/ExecutionDetails.tsx b/frontend/src/pages/ExecutionDetails.tsx index b1e54363c..d9d1c8318 100644 --- a/frontend/src/pages/ExecutionDetails.tsx +++ b/frontend/src/pages/ExecutionDetails.tsx @@ -16,28 +16,42 @@ import { Api, + ArtifactCustomProperties, + ArtifactProperties, + ArtifactType, + Event, Execution, + ExecutionCustomProperties, ExecutionProperties, + GetArtifactsByIDRequest, GetExecutionsByIDRequest, + GetEventsByExecutionIDsRequest, + GetEventsByExecutionIDsResponse, + getArtifactTypes, getResourceProperty, - titleCase + logger, + titleCase, } from '@kubeflow/frontend'; -import * as React from 'react'; +import { CircularProgress } from '@material-ui/core'; +import React, { Component } from 'react'; +import { Link } from 'react-router-dom'; +import { classes, stylesheet } from 'typestyle'; import { Page } from './Page'; import { ToolbarProps } from '../components/Toolbar'; -import { RoutePage, RouteParams } from '../components/Router'; -import { classes } from 'typestyle'; +import { RoutePage, RouteParams, RoutePageFactory } from '../components/Router'; import { commonCss, padding } from '../Css'; -import { CircularProgress } from '@material-ui/core'; -import { ResourceInfo } from '../components/ResourceInfo'; +import { ResourceInfo, ResourceType } from '../components/ResourceInfo'; +import { serviceErrorToString } from '../lib/Utils'; + +type ArtifactIdList = number[]; interface ExecutionDetailsState { execution?: Execution; + events?: Record; + artifactTypeMap?: Map; } export default class ExecutionDetails extends Page<{}, ExecutionDetailsState> { - private api = Api.getInstance(); - constructor(props: {}) { super(props); this.state = {}; @@ -50,7 +64,9 @@ export default class ExecutionDetails extends Page<{}, ExecutionDetailsState> { private get properTypeName(): string { const parts = this.fullTypeName.split('/'); - if (!parts.length) return ''; + if (!parts.length) { + return ''; + } return titleCase(parts[parts.length - 1]); } @@ -64,20 +80,48 @@ export default class ExecutionDetails extends Page<{}, ExecutionDetailsState> { } public render(): JSX.Element { - if (!this.state.execution) return ; + if (!this.state.execution || !this.state.events) { + return ; + } + return (
- {} -
+ { + + } + + + + +
); } public getInitialToolbarState(): ToolbarProps { return { - actions: [], + actions: {}, breadcrumbs: [{ displayName: 'Executions', href: RoutePage.EXECUTIONS }], - pageTitle: `${this.properTypeName} ${this.id} details` + pageTitle: `${this.properTypeName} ${this.id} details`, }; } @@ -86,34 +130,217 @@ export default class ExecutionDetails extends Page<{}, ExecutionDetailsState> { } private async load(): Promise { - const request = new GetExecutionsByIDRequest(); - request.addExecutionIds(Number(this.id)); + const metadataStoreServiceClient = Api.getInstance().metadataStoreService; - const response = await this.api.metadataStoreService.getExecutionsByID(request); + // this runs parallelly because it's not a critical resource + getArtifactTypes(metadataStoreServiceClient) + .then(artifactTypeMap => { + this.setState({ + artifactTypeMap, + }); + }) + .catch(err => { + this.showPageError('Failed to fetch artifact types', err); + }); - if (!response) { - this.showPageError(`Unable to retrieve ${this.fullTypeName} ${this.id}.`); - return + const numberId = parseInt(this.id, 10); + if (isNaN(numberId) || numberId < 0) { + const error = new Error(`Invalid execution id: ${this.id}`); + this.showPageError(error.message, error); + return; } - if (!response!.getExecutionsList()!.length) { - this.showPageError(`No ${this.fullTypeName} identified by id: ${this.id}`); - return; + const getExecutionsRequest = new GetExecutionsByIDRequest(); + getExecutionsRequest.setExecutionIdsList([numberId]); + const getEventsRequest = new GetEventsByExecutionIDsRequest(); + getEventsRequest.setExecutionIdsList([numberId]); + + try { + const [executionResponse, eventResponse] = await Promise.all([ + metadataStoreServiceClient.getExecutionsByID(getExecutionsRequest), + metadataStoreServiceClient.getEventsByExecutionIDs(getEventsRequest), + ]); + + if (!executionResponse.getExecutionsList().length) { + this.showPageError(`No ${this.fullTypeName} identified by id: ${this.id}`); + } + + if (executionResponse.getExecutionsList().length > 1) { + this.showPageError(`Found multiple executions with ID: ${this.id}`); + } + + const execution = executionResponse.getExecutionsList()[0]; + const executionName = + getResourceProperty(execution, ExecutionProperties.COMPONENT_ID) || + getResourceProperty(execution, ExecutionCustomProperties.TASK_ID, true); + this.props.updateToolbar({ + pageTitle: executionName ? executionName.toString() : '', + }); + + const events = parseEventsByType(eventResponse); + + this.setState({ + events, + execution, + }); + } catch (err) { + this.showPageError(serviceErrorToString(err)); } + } +} + +function parseEventsByType( + response: GetEventsByExecutionIDsResponse | null, +): Record { + const events: Record = { + [Event.Type.UNKNOWN]: [], + [Event.Type.DECLARED_INPUT]: [], + [Event.Type.INPUT]: [], + [Event.Type.DECLARED_OUTPUT]: [], + [Event.Type.OUTPUT]: [], + [Event.Type.INTERNAL_INPUT]: [], + [Event.Type.INTERNAL_OUTPUT]: [], + }; - if (response!.getExecutionsList().length > 1) { - this.showPageError(`Found multiple executions with ID: ${this.id}`); + if (!response) { + return events; + } + + response.getEventsList().forEach(event => { + const type = event.getType(); + const id = event.getArtifactId(); + if (type != null && id != null) { + events[type].push(id); + } + }); + + return events; +} + +interface ArtifactInfo { + id: number; + name: string; + typeId?: number; + uri: string; +} + +interface SectionIOProps { + title: string; + artifactIds: number[]; + artifactTypeMap?: Map; +} +class SectionIO extends Component< + SectionIOProps, + { artifactDataMap: { [id: number]: ArtifactInfo } } +> { + constructor(props: any) { + super(props); + + this.state = { + artifactDataMap: {}, + }; + } + + public async componentDidMount(): Promise { + // loads extra metadata about artifacts + const request = new GetArtifactsByIDRequest(); + request.setArtifactIdsList(this.props.artifactIds); + + try { + const response = await Api.getInstance().metadataStoreService.getArtifactsByID(request); + + const artifactDataMap = {}; + response.getArtifactsList().forEach(artifact => { + const id = artifact.getId(); + if (!id) { + logger.error('Artifact has empty id', artifact.toObject()); + return; + } + artifactDataMap[id] = { + id, + name: (getResourceProperty(artifact, ArtifactProperties.NAME) || + getResourceProperty(artifact, ArtifactCustomProperties.NAME, true) || + '') as string, // TODO: assert name is string + typeId: artifact.getTypeId(), + uri: artifact.getUri() || '', + }; + }); + this.setState({ + artifactDataMap, + }); + } catch (err) { return; } + } - const execution = response!.getExecutionsList()[0]; - - const executionName = getResourceProperty(execution, ExecutionProperties.NAME); - let title = executionName ? executionName.toString() : ''; + public render(): JSX.Element | null { + const { title, artifactIds } = this.props; + if (artifactIds.length === 0) { + return null; + } - this.props.updateToolbar({ - pageTitle: title - }); - this.setState({execution}); + return ( +
+

{title}

+ + + + + + + + + + + {artifactIds.map(id => { + const data = this.state.artifactDataMap[id] || {}; + const type = + this.props.artifactTypeMap && data.typeId + ? this.props.artifactTypeMap.get(data.typeId) + : null; + return ( + + ); + })} + +
Artifact IDNameTypeURI
+
+ ); } } + +// tslint:disable-next-line:variable-name +const ArtifactRow: React.FC<{ id: number; name: string; type?: string; uri: string }> = ({ + id, + name, + type, + uri, +}) => ( + + + {type && id ? ( + + {id} + + ) : ( + id + )} + + {name} + {type} + {uri} + +); + +const css = stylesheet({ + tableCell: { + padding: 6, + textAlign: 'left', + }, +}); diff --git a/frontend/src/pages/ExecutionList.tsx b/frontend/src/pages/ExecutionList.tsx index 3a9317e6a..d4dbe46c1 100644 --- a/frontend/src/pages/ExecutionList.tsx +++ b/frontend/src/pages/ExecutionList.tsx @@ -17,9 +17,9 @@ import { Api, Execution, - ExecutionCustomProperties, - ExecutionProperties, ExecutionType, + ExecutionProperties, + ExecutionCustomProperties, GetExecutionsRequest, GetExecutionTypesRequest, ListRequest, @@ -28,12 +28,24 @@ import { import * as React from 'react'; import { Link } from 'react-router-dom'; import { classes } from 'typestyle'; -import CustomTable, { Column, Row, ExpandState, CustomRendererProps } from '../components/CustomTable'; +import CustomTable, { + Column, + Row, + ExpandState, + CustomRendererProps, +} from '../components/CustomTable'; +import { Page } from './Page'; import { ToolbarProps } from '../components/Toolbar'; import { commonCss, padding } from '../Css'; -import { rowCompareFn, rowFilterFn, groupRows, getExpandedRow } from '../lib/Utils'; +import { + rowCompareFn, + rowFilterFn, + groupRows, + getExpandedRow, + CollapsedAndExpandedRows, + serviceErrorToString, +} from '../lib/Utils'; import { RoutePage, RouteParams } from '../components/Router'; -import { Page } from './Page'; interface ExecutionListState { executions: Execution[]; @@ -45,46 +57,31 @@ interface ExecutionListState { class ExecutionList extends Page<{}, ExecutionListState> { private tableRef = React.createRef(); private api = Api.getInstance(); - private executionTypes: Map; - private nameCustomRenderer: React.FC> = - (props: CustomRendererProps) => { - const [executionType, executionId] = props.id.split(':'); - const link = RoutePage.EXECUTION_DETAILS - .replace(`:${RouteParams.EXECUTION_TYPE}+`, executionType) - .replace(`:${RouteParams.ID}`, executionId); - return ( - e.stopPropagation()} - className={commonCss.link} - to={link}> - {props.value} - - ); - } - + private executionTypesMap: Map; constructor(props: any) { super(props); this.state = { - executions: [], columns: [ { - label: 'Pipeline/Workspace', - flex: 2, customRenderer: this.nameCustomRenderer, - sortKey: 'pipelineName' + flex: 2, + label: 'Pipeline/Workspace', + sortKey: 'pipelineName', }, { - label: 'Name', - flex: 1, customRenderer: this.nameCustomRenderer, + flex: 1, + label: 'Name', sortKey: 'name', }, - { label: 'State', flex: 1, sortKey: 'state', }, + { label: 'State', flex: 1, sortKey: 'state' }, { label: 'ID', flex: 1, sortKey: 'id' }, { label: 'Type', flex: 2, sortKey: 'type' }, ], - rows: [], + executions: [], expandedRows: new Map(), + rows: [], }; this.reload = this.reload.bind(this); this.toggleRowExpand = this.toggleRowExpand.bind(this); @@ -93,7 +90,7 @@ class ExecutionList extends Page<{}, ExecutionListState> { public getInitialToolbarState(): ToolbarProps { return { - actions: [], + actions: {}, breadcrumbs: [], pageTitle: 'Executions', }; @@ -103,7 +100,8 @@ class ExecutionList extends Page<{}, ExecutionListState> { const { rows, columns } = this.state; return (
- { initialSortOrder='asc' getExpandComponent={this.getExpandedExecutionsRow} toggleExpansion={this.toggleRowExpand} - emptyMessage='No executions found.' /> + emptyMessage='No executions found.' + />
); } @@ -125,78 +124,113 @@ class ExecutionList extends Page<{}, ExecutionListState> { } private async reload(request: ListRequest): Promise { - // TODO: Consider making an Api method for returning and caching types - if (!this.executionTypes || !this.executionTypes.size) { - this.executionTypes = await this.getExecutionTypes(); + try { + // TODO: Consider making an Api method for returning and caching types + if (!this.executionTypesMap || !this.executionTypesMap.size) { + this.executionTypesMap = await this.getExecutionTypes(); + } + if (!this.state.executions.length) { + const executions = await this.getExecutions(); + this.setState({ executions }); + this.clearBanner(); + const collapsedAndExpandedRows = this.getRowsFromExecutions(request, executions); + this.setState({ + expandedRows: collapsedAndExpandedRows.expandedRows, + rows: collapsedAndExpandedRows.collapsedRows, + }); + } + } catch (err) { + this.showPageError(serviceErrorToString(err)); } - if (!this.state.executions.length) { - const executions = await this.getExecutions(); - this.setState({ executions }); - this.clearBanner(); - } - this.setState({ - rows: this.getRowsFromExecutions(request), - }); return ''; } - private async getExecutionTypes(): Promise> { - const response = - await this.api.metadataStoreService.getExecutionTypes(new GetExecutionTypesRequest()); - - if (!response) { - this.showPageError('Unable to retrieve Execution Types, some features may not work.'); - return new Map(); + private async getExecutions(): Promise { + try { + const response = await this.api.metadataStoreService.getExecutions( + new GetExecutionsRequest(), + ); + return response.getExecutionsList(); + } catch (err) { + // Code === 5 means no record found in backend. This is a temporary workaround. + // TODO: remove err.code !== 5 check when backend is fixed. + if (err.code !== 5) { + err.message = 'Failed getting executions: ' + err.message; + throw err; + } } + return []; + } - const executionTypesMap = new Map(); - - (response!.getExecutionTypesList() || []).forEach((executionType) => { - executionTypesMap.set(executionType.getId()!, executionType); - }); + private async getExecutionTypes(): Promise> { + try { + const response = await this.api.metadataStoreService.getExecutionTypes( + new GetExecutionTypesRequest(), + ); - return executionTypesMap; - } + const executionTypesMap = new Map(); - private async getExecutions(): Promise { - const response = await this.api.metadataStoreService.getExecutions(new GetExecutionsRequest()); + response.getExecutionTypesList().forEach(executionType => { + executionTypesMap.set(executionType.getId(), executionType); + }); - if (!response) { - this.showPageError('Unable to retrieve Executions.'); - return [] + return executionTypesMap; + } catch (err) { + this.showPageError(serviceErrorToString(err)); } - - return response!.getExecutionsList() || []; + return new Map(); } + private nameCustomRenderer: React.FC> = ( + props: CustomRendererProps, + ) => { + const [executionType, executionId] = props.id.split(':'); + const link = RoutePage.EXECUTION_DETAILS.replace( + `:${RouteParams.EXECUTION_TYPE}+`, + executionType, + ).replace(`:${RouteParams.ID}`, executionId); + return ( + e.stopPropagation()} className={commonCss.link} to={link}> + {props.value} + + ); + }; + /** * Temporary solution to apply sorting, filtering, and pagination to the * local list of executions until server-side handling is available * TODO: Replace once https://github.com/kubeflow/metadata/issues/73 is done. * @param request + * @param executions */ - private getRowsFromExecutions(request: ListRequest): Row[] { - const collapsedAndExpandedRows = groupRows(this.state.executions - .map((execution) => { // Flattens - const type = this.executionTypes && this.executionTypes.get(execution.getTypeId()!) ? - this.executionTypes.get(execution.getTypeId()!)!.getName() : execution.getTypeId(); - return { - id: `${type}:${execution.getId()}`, // Join with colon so we can build the link - otherFields: [ - getResourceProperty(execution, ExecutionProperties.PIPELINE_NAME) - || getResourceProperty(execution, ExecutionCustomProperties.WORKSPACE, true), - getResourceProperty(execution, ExecutionProperties.COMPONENT_ID), - getResourceProperty(execution, ExecutionProperties.STATE), - execution.getId(), - type, - ], - }; - }) - .filter(rowFilterFn(request)) - .sort(rowCompareFn(request, this.state.columns))); - - this.setState({ expandedRows: collapsedAndExpandedRows.expandedRows }); - return collapsedAndExpandedRows.collapsedRows; + private getRowsFromExecutions( + request: ListRequest, + executions: Execution[], + ): CollapsedAndExpandedRows { + return groupRows( + executions + .map(execution => { + // Flattens + const executionType = this.executionTypesMap!.get(execution.getTypeId()); + const type = executionType ? executionType.getName() : execution.getTypeId(); + return { + id: `${type}:${execution.getId()}`, // Join with colon so we can build the link + otherFields: [ + getResourceProperty(execution, ExecutionProperties.PIPELINE_NAME) || + getResourceProperty(execution, ExecutionCustomProperties.WORKSPACE, true) || + getResourceProperty(execution, ExecutionCustomProperties.RUN_ID, true), + getResourceProperty(execution, ExecutionProperties.NAME) || + getResourceProperty(execution, ExecutionProperties.COMPONENT_ID) || + getResourceProperty(execution, ExecutionCustomProperties.TASK_ID, true), + getResourceProperty(execution, ExecutionProperties.STATE), + execution.getId(), + type, + ], + }; + }) + .filter(rowFilterFn(request)) + .sort(rowCompareFn(request, this.state.columns)), + ); } /** @@ -208,10 +242,11 @@ class ExecutionList extends Page<{}, ExecutionListState> { if (!rows[index]) { return; } - rows[index].expandState = rows[index].expandState === ExpandState.EXPANDED - ? ExpandState.COLLAPSED - : ExpandState.EXPANDED; - this.setState({rows}); + rows[index].expandState = + rows[index].expandState === ExpandState.EXPANDED + ? ExpandState.COLLAPSED + : ExpandState.EXPANDED; + this.setState({ rows }); } private getExpandedExecutionsRow(index: number): React.ReactNode {