* Refactor topic creation

* Remove unused thunk

* Remove excess interface

* Add New page snapshot test

* Refactor new component tests

* Remove excess function

* Add typography variables and classes

* Add font families

* Implement custom button

* Get rid of enums

* Add theme

* Separate styles from logic

* Feature/layout redesign (#862)

* Refactor pages general layout

* Refactor breadcrumbs

* Refactor brokers metrics

* Fix toggle position

Co-authored-by: azat.belgibayev <azat.belgibayev@almatech.dev>

* add redesigned new menu item

* remove styles from theme

* update tests

* fix local and app wide styles

* add tests

* Add theme

* Add types to the styles

* update menu item prop prefixes, minor fixes

* add theme styles, move interface, update test, snapshot

* add optional styling

* add isActive props, propagate component, update tests

* remove button

* Revert "remove button"

This reverts commit 4a9c87d8d8.

* add tests for styled button

* remove ternary operator from style

* import styled from lib/

* Custom Inputs  (#890)

* Implement and test custom input

* Custom select (#896)

* Implement custom select

* Fix Metrics component (#914)

* Add styled table header cell component (#901)

* Redesign menu (#918)

* Finish styling menu

* Styled Table

* Fix styled table

* Allow custom buttons work as links

* Restyle Breadcrumb

* Topics list (#946)

* Redesign pagination

* Fix styled components usage

* Topic messages (#959)

* Topic Consumer Groups

* Message settings

* Finish styling indicators

* Style the dashboard

* Finish with the topics page

* Style consumer groups list

* Restyle the consumer group details

* Style alerts

* Style confirmation modal

* Update DangerZone.spec.tsx

* redesign schema registry

* Add Topic details snapshot

* Style Page Loader

* Style connectors list

* Style KSQL

* Remove all the classes from the styled components (#1049)

* Redesign topic form (#1051)

* Redesign connect details (#1053)

* Update types for styled-components (#1054)

* Redesign some minor forms in the app (#1062)

* Fix alert styles

* Get rid of bulma/layout styles

* Fix form styling

* Custom Switch component

* fix border-radius property of metrick widgets

* get rid of warnings in tests

* use jest-styled-components

* cleanup

* get rid of some bulma modules

* refactor metrics component

* get rid of JSON-tree. Json Editor redesign

* update proxy config

* Refactor Alerts component (#1124)

* Refactor tests (#1129)

* App layout update (#1127)

* ‘App-layout-update’

* toBeNull changed to toBeInDocument

* scss file removed

* App navbar layout update

* navbar test

* code smells local refactoring

* StyledMenuItem code smells refactoring

* StyledClusterTab code smells refactoring

* ConfirmationModalWrapper code smells refactoring

* input icon and label code smells refactoring

* navburger displaying fixed

* Get rid of classes

* fix code smells

* refactor styles

* refactor styles

Co-authored-by: Oleg Shuralev <workshur@gmail.com>

* Refactored Cluster nav (#1147)

* Update caniuse

* refactor Nav component

* Update sonars config

* refactor Nav component + specs

* Specs

* Feature/code smells removing (#1148)

* StyledSelect code smell refactoring

* SecondaryTabs code smell refactoring

* TextareaStyled code smell refactoring

* TableStyled code smell refactoring

* StyledTableHeaderCell code smell refactoring

* Add custom render with theme provider wrapper for testing lib (#1152)

* Added cleanupPolicy to topic details. Closes #999 (#1067)

* Rename "latest first" to "oldest first"

* Switch to redux-toolkit. Refactoring (#1171)

* Switch to redux-toolkit

* Fix #1207 (cherry-pick)

* refactor metrics (#1253)

Co-authored-by: Azat Belgibayev <belg.azat@gmail.com>
Co-authored-by: Alexander <mr.afigitelniychuvak@gmail.com>
Co-authored-by: azat.belgibayev <azat.belgibayev@almatech.dev>
Co-authored-by: sergei <scheremnov@provectus.com>
Co-authored-by: Alexander Krivonosov <31561808+GneyHabub@users.noreply.github.com>
Co-authored-by: Alina Miryuk <alinamiryuk@mail.ru>
Co-authored-by: Roman Zabaluev <rzabaluev@provectus.com>
This commit is contained in:
Oleg Shur 2021-12-15 12:10:36 +03:00 committed by GitHub
parent f5d421d9f0
commit 7e5e8d9268
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
332 changed files with 14161 additions and 11785 deletions

View file

@ -1699,12 +1699,7 @@ components:
underReplicatedPartitions:
type: integer
cleanUpPolicy:
type: string
enum:
- DELETE
- COMPACT
- COMPACT_DELETE
- UNKNOWN
$ref: '#/components/schemas/CleanUpPolicy'
partitions:
type: array
items:
@ -1752,6 +1747,8 @@ components:
type: integer
underReplicatedPartitions:
type: integer
cleanUpPolicy:
$ref: '#/components/schemas/CleanUpPolicy'
required:
- name
@ -2630,4 +2627,12 @@ components:
value:
type: string
source:
$ref: '#/components/schemas/ConfigSource'
$ref: '#/components/schemas/ConfigSource'
CleanUpPolicy:
type: string
enum:
- DELETE
- COMPACT
- COMPACT_DELETE
- UNKNOWN

View file

@ -24,6 +24,7 @@
"extends": [
"airbnb-typescript",
"plugin:@typescript-eslint/recommended",
"plugin:jest-dom/recommended",
"plugin:prettier/recommended",
"prettier"
],
@ -41,7 +42,8 @@
}],
"import/no-relative-parent-imports": "error",
"no-debugger": "warn",
"react/jsx-props-no-spreading": "off"
"react/jsx-props-no-spreading": "off",
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["state"] }]
},
"overrides": [
{

View file

@ -15,14 +15,12 @@
"@babel/compat-data": {
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.5.tgz",
"integrity": "sha512-kixrYn4JwfAVPa0f2yfzc2AWti6WRRyO3XjWW5PJAvtE11qhSayrrcrEnee05KAtNaPC+EwehE8Qt1UedEVB8w==",
"dev": true
"integrity": "sha512-kixrYn4JwfAVPa0f2yfzc2AWti6WRRyO3XjWW5PJAvtE11qhSayrrcrEnee05KAtNaPC+EwehE8Qt1UedEVB8w=="
},
"@babel/core": {
"version": "7.14.6",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.6.tgz",
"integrity": "sha512-gJnOEWSqTk96qG5BoIrl5bVtc23DCycmIePPYnamY9RboYdI4nFy5vAQMSl81O5K/W0sLDWfGysnOECC+KUUCA==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.14.5",
"@babel/generator": "^7.14.5",
@ -45,7 +43,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"dev": true,
"requires": {
"ms": "2.1.2"
}
@ -54,7 +51,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
"integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
"dev": true,
"requires": {
"minimist": "^1.2.5"
}
@ -62,20 +58,17 @@
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"dev": true
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
},
"source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
"dev": true
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
}
}
},
@ -83,7 +76,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.5.tgz",
"integrity": "sha512-y3rlP+/G25OIX3mYKKIOlQRcqj7YgrvHxOLbVmyLJ9bPmi5ttvUmpydVjcFjZphOktWuA7ovbx91ECloWTfjIA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.5",
"jsesc": "^2.5.1",
@ -93,8 +85,7 @@
"source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
"dev": true
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
}
}
},
@ -102,7 +93,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz",
"integrity": "sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.5"
}
@ -121,7 +111,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.5.tgz",
"integrity": "sha512-v+QtZqXEiOnpO6EYvlImB6zCD2Lel06RzOPzmkz/D/XgQiUu3C/Jb1LOqSt/AIA34TYi/Q+KlT8vTQrgdxkbLw==",
"dev": true,
"requires": {
"@babel/compat-data": "^7.14.5",
"@babel/helper-validator-option": "^7.14.5",
@ -132,8 +121,7 @@
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"dev": true
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
}
}
},
@ -213,7 +201,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz",
"integrity": "sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==",
"dev": true,
"requires": {
"@babel/helper-get-function-arity": "^7.14.5",
"@babel/template": "^7.14.5",
@ -224,7 +211,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz",
"integrity": "sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==",
"dev": true,
"requires": {
"@babel/types": "^7.14.5"
}
@ -233,7 +219,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz",
"integrity": "sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==",
"dev": true,
"requires": {
"@babel/types": "^7.14.5"
}
@ -242,7 +227,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.14.5.tgz",
"integrity": "sha512-UxUeEYPrqH1Q/k0yRku1JE7dyfyehNwT6SVkMHvYvPDv4+uu627VXBckVj891BO8ruKBkiDoGnZf4qPDD8abDQ==",
"dev": true,
"requires": {
"@babel/types": "^7.14.5"
}
@ -251,7 +235,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz",
"integrity": "sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==",
"dev": true,
"requires": {
"@babel/types": "^7.14.5"
}
@ -260,7 +243,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.14.5.tgz",
"integrity": "sha512-iXpX4KW8LVODuAieD7MzhNjmM6dzYY5tfRqT+R9HDXWl0jPn/djKmA+G9s/2C2T9zggw5tK1QNqZ70USfedOwA==",
"dev": true,
"requires": {
"@babel/helper-module-imports": "^7.14.5",
"@babel/helper-replace-supers": "^7.14.5",
@ -276,7 +258,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz",
"integrity": "sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.5"
}
@ -302,7 +283,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.14.5.tgz",
"integrity": "sha512-3i1Qe9/8x/hCHINujn+iuHy+mMRLoc77b2nI9TB0zjH1hvn9qGlXjWlggdwUcju36PkPCy/lpM7LLUdcTyH4Ow==",
"dev": true,
"requires": {
"@babel/helper-member-expression-to-functions": "^7.14.5",
"@babel/helper-optimise-call-expression": "^7.14.5",
@ -314,7 +294,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.14.5.tgz",
"integrity": "sha512-nfBN9xvmCt6nrMZjfhkl7i0oTV3yxR4/FztsbOASyTvVcoYd0TRHh7eMLdlEcCqobydC0LAF3LtC92Iwxo0wyw==",
"dev": true,
"requires": {
"@babel/types": "^7.14.5"
}
@ -332,7 +311,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz",
"integrity": "sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.5"
}
@ -345,8 +323,7 @@
"@babel/helper-validator-option": {
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz",
"integrity": "sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==",
"dev": true
"integrity": "sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow=="
},
"@babel/helper-wrap-function": {
"version": "7.14.5",
@ -364,7 +341,6 @@
"version": "7.14.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.14.6.tgz",
"integrity": "sha512-yesp1ENQBiLI+iYHSJdoZKUtRpfTlL1grDIX9NRlAVppljLw/4tTyYupIB7uIYmC3stW/imAv8EqaKaS/ibmeA==",
"dev": true,
"requires": {
"@babel/template": "^7.14.5",
"@babel/traverse": "^7.14.5",
@ -417,8 +393,7 @@
"@babel/parser": {
"version": "7.14.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.6.tgz",
"integrity": "sha512-oG0ej7efjEXxb4UgE+klVx+3j4MVo+A2vCzm7OUN4CLo6WhQ+vSOD2yJ8m7B+DghObxtLxt3EfgMWpq+AsWehQ==",
"dev": true
"integrity": "sha512-oG0ej7efjEXxb4UgE+klVx+3j4MVo+A2vCzm7OUN4CLo6WhQ+vSOD2yJ8m7B+DghObxtLxt3EfgMWpq+AsWehQ=="
},
"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
"version": "7.14.5",
@ -1349,7 +1324,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz",
"integrity": "sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.14.5",
"@babel/parser": "^7.14.5",
@ -1360,7 +1334,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.5.tgz",
"integrity": "sha512-G3BiS15vevepdmFqmUc9X+64y0viZYygubAMO8SvBmKARuF6CPSZtH4Ng9vi/lrWlZFGe3FWdXNy835akH8Glg==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.14.5",
"@babel/generator": "^7.14.5",
@ -1377,7 +1350,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"dev": true,
"requires": {
"ms": "2.1.2"
}
@ -1385,14 +1357,12 @@
"globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}
}
},
@ -1400,7 +1370,6 @@
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.5.tgz",
"integrity": "sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.5",
"to-fast-properties": "^2.0.0"
@ -1449,6 +1418,29 @@
"integrity": "sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==",
"dev": true
},
"@emotion/is-prop-valid": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
"requires": {
"@emotion/memoize": "0.7.4"
}
},
"@emotion/memoize": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw=="
},
"@emotion/stylis": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz",
"integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ=="
},
"@emotion/unitless": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
},
"@eslint/eslintrc": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz",
@ -2144,6 +2136,7 @@
"version": "27.4.2",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.4.2.tgz",
"integrity": "sha512-j35yw0PMTPpZsUoOBiuHzr1zTYoad1cVIE0ajEjcrJONxxrko/IRGKkXx3os0Nsi4Hu3+5VmDbVfq5WhG/pWAg==",
"dev": true,
"requires": {
"@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^3.0.0",
@ -2156,6 +2149,7 @@
"version": "16.0.4",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
"integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
"dev": true,
"requires": {
"@types/yargs-parser": "*"
}
@ -2348,6 +2342,24 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz",
"integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q=="
},
"@reduxjs/toolkit": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.6.2.tgz",
"integrity": "sha512-HbfI/hOVrAcMGAYsMWxw3UJyIoAS9JTdwddsjlr5w3S50tXhWb+EMyhIw+IAvCVCLETkzdjgH91RjDSYZekVBA==",
"requires": {
"immer": "^9.0.6",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0"
},
"dependencies": {
"immer": {
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.7.tgz",
"integrity": "sha512-KGllzpbamZDvOIxnmJ0jI840g7Oikx58lBPWV0hUh7dtAyZpFqqrBZdKka5GlTwMTZ1Tjc/bKKW4VSFAt6BqMA=="
}
}
},
"@rollup/plugin-node-resolve": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz",
@ -2569,6 +2581,26 @@
"pretty-format": "^27.0.2"
},
"dependencies": {
"@jest/types": {
"version": "27.2.5",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz",
"integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==",
"requires": {
"@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^3.0.0",
"@types/node": "*",
"@types/yargs": "^16.0.0",
"chalk": "^4.0.0"
}
},
"@types/yargs": {
"version": "16.0.4",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
"integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
"requires": {
"@types/yargs-parser": "*"
}
},
"ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
@ -2593,6 +2625,20 @@
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"dependencies": {
"@jest/types": {
"version": "27.4.2",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.4.2.tgz",
"integrity": "sha512-j35yw0PMTPpZsUoOBiuHzr1zTYoad1cVIE0ajEjcrJONxxrko/IRGKkXx3os0Nsi4Hu3+5VmDbVfq5WhG/pWAg==",
"requires": {
"@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^3.0.0",
"@types/node": "*",
"@types/yargs": "^16.0.0",
"chalk": "^4.0.0"
}
}
}
},
"react-is": {
@ -2640,6 +2686,15 @@
"@testing-library/dom": "^8.0.0"
}
},
"@testing-library/user-event": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
"dev": true,
"requires": {
"@babel/runtime": "^7.12.5"
}
},
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@ -2716,11 +2771,6 @@
"@babel/types": "^7.3.0"
}
},
"@types/base16": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.2.tgz",
"integrity": "sha512-oYO/U4VD1DavwrKuCSQWdLG+5K22SLPem2OQaHmFcQuwHoVeGC+JGVRji2MUqZUAIQZHEonOeVfAX09hYiLsdg=="
},
"@types/cheerio": {
"version": "0.22.29",
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.29.tgz",
@ -2934,15 +2984,8 @@
"@types/lodash": {
"version": "4.14.177",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.177.tgz",
"integrity": "sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw=="
},
"@types/lodash.curry": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@types/lodash.curry/-/lodash.curry-4.1.6.tgz",
"integrity": "sha512-x3ctCcmOYqRrihNNnQJW6fe/yZFCgnrIa6p80AiPQRO8Jis29bBdy1dEw1FwngoF/mCZa3Bx+33fUZvOEE635Q==",
"requires": {
"@types/lodash": "*"
}
"integrity": "sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw==",
"dev": true
},
"@types/minimatch": {
"version": "3.0.4",
@ -3112,6 +3155,16 @@
"integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==",
"dev": true
},
"@types/styled-components": {
"version": "5.1.14",
"resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.14.tgz",
"integrity": "sha512-d6P1/tyNytqKwam3cQXq7a9uPtovc/mdAs7dBiz1YbDdNIT3X4WmuFU78YdSYh84TXVuhOwezZ3EeKuNBhwsHQ==",
"requires": {
"@types/hoist-non-react-statics": "*",
"@types/react": "*",
"csstype": "^3.0.2"
}
},
"@types/tapable": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.7.tgz",
@ -4491,6 +4544,22 @@
"@babel/helper-define-polyfill-provider": "^0.2.2"
}
},
"babel-plugin-styled-components": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-1.13.2.tgz",
"integrity": "sha512-Vb1R3d4g+MUfPQPVDMCGjm3cDocJEUTR7Xq7QS95JWWeksN1wdFRYpD2kulDgI3Huuaf1CZd+NK4KQmqUFh5dA==",
"requires": {
"@babel/helper-annotate-as-pure": "^7.0.0",
"@babel/helper-module-imports": "^7.0.0",
"babel-plugin-syntax-jsx": "^6.18.0",
"lodash": "^4.17.11"
}
},
"babel-plugin-syntax-jsx": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
"integrity": "sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY="
},
"babel-plugin-syntax-object-rest-spread": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz",
@ -4860,11 +4929,6 @@
}
}
},
"base16": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz",
"integrity": "sha1-4pf2DX7BAUp6lxo568ipjAtoHnA="
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -5091,7 +5155,6 @@
"version": "4.16.6",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz",
"integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==",
"dev": true,
"requires": {
"caniuse-lite": "^1.0.30001219",
"colorette": "^1.2.2",
@ -5163,11 +5226,6 @@
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.3.tgz",
"integrity": "sha512-0d7GNW1PY4ud8TWxdNcP6Cc8Bu7MxcntD/RRLGWuiw/s0a9P+XlH/6QoOIrmbj6o8WWJzJYhytiu9nFjTszk1g=="
},
"bulma-switch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bulma-switch/-/bulma-switch-2.0.0.tgz",
"integrity": "sha512-myD38zeUfjmdduq+pXabhJEe3x2hQP48l/OI+Y0fO3HdDynZUY/VJygucvEAJKRjr4HxD5DnEm4yx+oDOBXpAA=="
},
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@ -5289,6 +5347,11 @@
}
}
},
"camelize": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz",
"integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs="
},
"caniuse-api": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
@ -5302,10 +5365,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001237",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001237.tgz",
"integrity": "sha512-pDHgRndit6p1NR2GhzMbQ6CkRrp4VKuSsqbcLeOQppYPKOYkKT/6ZvZDvKJUqcmtyWIAHuZq3SVS2vc1egCZzw==",
"dev": true
"version": "1.0.30001283",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001283.tgz",
"integrity": "sha512-9RoKo841j1GQFSJz/nCXOj0sD7tHBtlowjYlrqIUS812x9/emfBLBt6IyMz1zIaYc/eRL8Cs6HPUVi2Hzq4sIg=="
},
"capture-exit": {
"version": "2.0.0",
@ -5628,6 +5690,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/color/-/color-3.1.3.tgz",
"integrity": "sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==",
"dev": true,
"requires": {
"color-convert": "^1.9.1",
"color-string": "^1.5.4"
@ -5650,6 +5713,7 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz",
"integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==",
"dev": true,
"requires": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
@ -5658,8 +5722,7 @@
"colorette": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
"integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
"dev": true
"integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w=="
},
"combined-stream": {
"version": "1.0.8",
@ -5898,7 +5961,6 @@
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz",
"integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.1"
},
@ -5906,8 +5968,7 @@
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
@ -5957,8 +6018,7 @@
"core-js": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.14.0.tgz",
"integrity": "sha512-3s+ed8er9ahK+zJpp9ZtuVcDoFzHNiZsPbNAAE4KXgrRHbjSqqNN6xGSXq6bq7TZIbKj4NLrLb6bJ5i+vSVjHA==",
"dev": true
"integrity": "sha512-3s+ed8er9ahK+zJpp9ZtuVcDoFzHNiZsPbNAAE4KXgrRHbjSqqNN6xGSXq6bq7TZIbKj4NLrLb6bJ5i+vSVjHA=="
},
"core-js-compat": {
"version": "3.14.0",
@ -6110,6 +6170,11 @@
"postcss": "^7.0.5"
}
},
"css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU="
},
"css-color-names": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
@ -6220,6 +6285,16 @@
"integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==",
"dev": true
},
"css-to-react-native": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.0.0.tgz",
"integrity": "sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==",
"requires": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"css-tree": {
"version": "1.0.0-alpha.37",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz",
@ -7037,8 +7112,7 @@
"electron-to-chromium": {
"version": "1.3.752",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.752.tgz",
"integrity": "sha512-2Tg+7jSl3oPxgsBsWKh5H83QazTkmWG/cnNwJplmyZc7KcN61+I10oUgaXSVk/NwfvN3BdkKDR4FYuRBQQ2v0A==",
"dev": true
"integrity": "sha512-2Tg+7jSl3oPxgsBsWKh5H83QazTkmWG/cnNwJplmyZc7KcN61+I10oUgaXSVk/NwfvN3BdkKDR4FYuRBQQ2v0A=="
},
"elliptic": {
"version": "6.5.4",
@ -7347,8 +7421,7 @@
"escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
"dev": true
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
},
"escape-html": {
"version": "1.0.3",
@ -7880,6 +7953,35 @@
"@typescript-eslint/experimental-utils": "^4.0.1"
}
},
"eslint-plugin-jest-dom": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-jest-dom/-/eslint-plugin-jest-dom-3.9.2.tgz",
"integrity": "sha512-DKNW6nxYkBvwv36WcYFxapCalGjOGSWUu5PREpDVuXGbEns3S5jhr+mZ5W2N6MxbOWw/2U61C1JVLH31gwVjOQ==",
"dev": true,
"requires": {
"@babel/runtime": "^7.9.6",
"@testing-library/dom": "^7.28.1",
"requireindex": "^1.2.0"
},
"dependencies": {
"@testing-library/dom": {
"version": "7.31.2",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz",
"integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^4.2.0",
"aria-query": "^4.2.2",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.6",
"lz-string": "^1.4.4",
"pretty-format": "^26.6.2"
}
}
}
},
"eslint-plugin-jsx-a11y": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.4.1.tgz",
@ -8693,7 +8795,6 @@
"version": "9.11.0",
"resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz",
"integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==",
"dev": true,
"requires": {
"@babel/core": "^7.0.0",
"@babel/runtime": "^7.0.0",
@ -8708,10 +8809,9 @@
},
"dependencies": {
"debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"dev": true,
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
"integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
"requires": {
"ms": "2.1.2"
}
@ -8719,14 +8819,12 @@
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"path-to-regexp": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz",
"integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==",
"dev": true
"integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w=="
}
}
},
@ -9351,8 +9449,7 @@
"gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="
},
"get-caller-file": {
"version": "2.0.5",
@ -9432,8 +9529,7 @@
"glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"dev": true
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="
},
"global-modules": {
"version": "2.0.0",
@ -10310,7 +10406,8 @@
"is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"dev": true
},
"is-bigint": {
"version": "1.0.2",
@ -10595,8 +10692,7 @@
"is-subset": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
"integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=",
"dev": true
"integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY="
},
"is-symbol": {
"version": "1.0.4",
@ -12105,6 +12201,15 @@
"xml": "^1.0.1"
}
},
"jest-styled-components": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/jest-styled-components/-/jest-styled-components-7.0.6.tgz",
"integrity": "sha512-asCitkJfaO1TX60nbIBXDUglblDFvKl9aKA5IV09AmO2zL6DC89MgrK73/e7zXzMOpzyVmuXQLLYlsDUSYIb7g==",
"dev": true,
"requires": {
"css": "^3.0.0"
}
},
"jest-util": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz",
@ -12328,8 +12433,7 @@
"jsesc": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
"dev": true
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="
},
"json-parse-better-errors": {
"version": "1.0.2",
@ -12670,11 +12774,6 @@
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=",
"dev": true
},
"lodash.curry": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
"integrity": "sha1-JI42By7ekGUB11lmIAqG2riyMXA="
},
"lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@ -12724,8 +12823,7 @@
"lodash.sortby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
"integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=",
"dev": true
"integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg="
},
"lodash.template": {
"version": "4.5.0",
@ -13565,8 +13663,7 @@
"node-releases": {
"version": "1.1.73",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz",
"integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==",
"dev": true
"integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg=="
},
"normalize-package-data": {
"version": "2.5.0",
@ -15416,8 +15513,7 @@
"postcss-value-parser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
"integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==",
"dev": true
"integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ=="
},
"postcss-values-parser": {
"version": "2.0.1",
@ -15673,8 +15769,7 @@
"querystring": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz",
"integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==",
"dev": true
"integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg=="
},
"querystring-es3": {
"version": "0.2.1",
@ -15789,19 +15884,6 @@
"whatwg-fetch": "^3.4.1"
}
},
"react-base16-styling": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.8.0.tgz",
"integrity": "sha512-ElvciPaL4xpWh7ISX7ugkNS/dvoh7DpVMp4t93ngnEsS2LkMd8Gu+cDDOLis2rj4889CNK662UdjOfv3wvZg9w==",
"requires": {
"@types/base16": "^1.0.2",
"@types/lodash.curry": "^4.1.6",
"base16": "^1.0.0",
"color": "^3.1.2",
"csstype": "^3.0.2",
"lodash.curry": "^4.1.1"
}
},
"react-datepicker": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.5.0.tgz",
@ -16065,16 +16147,6 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"react-json-tree": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/react-json-tree/-/react-json-tree-0.15.0.tgz",
"integrity": "sha512-/bEFXZBfLFiep6ReuzatR8mz9G7sRmejElRDgcAuqY0Jsx7llouax2DM03rlQifrUJgmvTGmPA+olyWYyGagqA==",
"requires": {
"@types/prop-types": "^15.7.3",
"prop-types": "^15.7.2",
"react-base16-styling": "^0.8.0"
}
},
"react-multi-select-component": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/react-multi-select-component/-/react-multi-select-component-4.0.6.tgz",
@ -16443,9 +16515,9 @@
}
},
"redux-thunk": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
"integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz",
"integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q=="
},
"reflect-metadata": {
"version": "0.1.13",
@ -16622,6 +16694,12 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true
},
"requireindex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
"integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==",
"dev": true
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@ -17407,6 +17485,11 @@
"safe-buffer": "^5.0.1"
}
},
"shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -17456,6 +17539,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=",
"dev": true,
"requires": {
"is-arrayish": "^0.3.1"
}
@ -18178,6 +18262,38 @@
"schema-utils": "^2.7.0"
}
},
"styled-components": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.1.tgz",
"integrity": "sha512-JThv2JRzyH0NOIURrk9iskdxMSAAtCfj/b2Sf1WJaCUsloQkblepy1jaCLX/bYE+mhYo3unmwVSI9I5d9ncSiQ==",
"requires": {
"@babel/helper-module-imports": "^7.0.0",
"@babel/traverse": "^7.4.5",
"@emotion/is-prop-valid": "^0.8.8",
"@emotion/stylis": "^0.8.4",
"@emotion/unitless": "^0.7.4",
"babel-plugin-styled-components": ">= 1.12.0",
"css-to-react-native": "^3.0.0",
"hoist-non-react-statics": "^3.0.0",
"shallowequal": "^1.1.0",
"supports-color": "^5.5.0"
},
"dependencies": {
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"requires": {
"has-flag": "^3.0.0"
}
}
}
},
"stylehacks": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.3.tgz",
@ -18769,8 +18885,7 @@
"to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
"dev": true
"integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4="
},
"to-object-path": {
"version": "0.3.0",
@ -18827,7 +18942,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
"integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=",
"dev": true,
"requires": {
"punycode": "^2.1.0"
}
@ -19733,8 +19847,7 @@
"webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
"dev": true
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
},
"webpack": {
"version": "4.44.2",
@ -20774,7 +20887,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz",
"integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==",
"dev": true,
"requires": {
"lodash.sortby": "^4.7.0",
"tr46": "^1.0.1",

View file

@ -7,18 +7,20 @@
"@fortawesome/fontawesome-free": "^5.15.4",
"@hookform/error-message": "^2.0.0",
"@hookform/resolvers": "^2.7.1",
"@reduxjs/toolkit": "^1.6.2",
"@rooks/use-outside-click-ref": "^4.10.1",
"@testing-library/react": "^12.0.0",
"@types/eventsource": "^1.1.6",
"@types/styled-components": "^5.1.14",
"@types/yup": "^0.29.13",
"ace-builds": "^1.4.12",
"ajv": "^8.6.3",
"bulma": "^0.9.3",
"bulma-switch": "^2.0.0",
"classnames": "^2.2.6",
"dayjs": "^1.10.6",
"eslint-import-resolver-node": "^0.3.5",
"eslint-import-resolver-typescript": "^2.4.0",
"fetch-mock": "^9.11.0",
"json-schema-faker": "^0.5.0-rcv.39",
"lodash": "^4.17.21",
"node-fetch": "^2.6.1",
@ -28,16 +30,15 @@
"react-datepicker": "^4.2.0",
"react-dom": "^17.0.1",
"react-hook-form": "7.6.9",
"react-json-tree": "^0.15.0",
"react-multi-select-component": "^4.0.6",
"react-redux": "^7.2.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"redux": "^4.1.1",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"rimraf": "^3.0.2",
"sass": "^1.43.4",
"styled-components": "^5.3.1",
"typesafe-actions": "^5.1.0",
"use-debounce": "^7.0.0",
"uuid": "^8.3.1",
@ -80,7 +81,9 @@
"devDependencies": {
"@jest/types": "^27.0.6",
"@openapitools/openapi-generator-cli": "^2.4.15",
"@testing-library/dom": "^8.11.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/user-event": "^13.5.0",
"@types/classnames": "^2.2.11",
"@types/enzyme": "^3.10.9",
"@types/jest": "^27.0.2",
@ -94,6 +97,7 @@
"@types/react-router-dom": "^5.1.8",
"@types/react-test-renderer": "^17.0.1",
"@types/redux-mock-store": "^1.0.3",
"@types/styled-components": "^5.1.13",
"@types/uuid": "^8.3.1",
"@typescript-eslint/eslint-plugin": "^4.29.1",
"@typescript-eslint/parser": "^4.29.1",
@ -106,6 +110,7 @@
"eslint-config-airbnb-typescript": "^12.3.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.24.0",
"eslint-plugin-jest-dom": "^3.9.2",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.21.5",
@ -116,6 +121,7 @@
"http-proxy-middleware": "^2.0.1",
"husky": "^7.0.1",
"jest-sonar-reporter": "^2.0.0",
"jest-styled-components": "^7.0.6",
"lint-staged": "^11.1.2",
"prettier": "^2.3.1",
"react-scripts": "4.0.3",

View file

@ -2,7 +2,7 @@ sonar.projectKey=provectus_kafka-ui_frontend
sonar.organization=provectus
sonar.sources=.
sonar.exclusions="**/__test?__/**,src/setupWorker.ts,src/setupTests.ts,**/fixtures.ts"
sonar.exclusions="**/__test?__/**,src/setupWorker.ts,src/setupTests.ts,**/fixtures.ts,src/lib/testHelpers.tsx"
sonar.typescript.lcov.reportPaths=./coverage/lcov.info
sonar.testExecutionReportPaths=./test-report.xml

View file

@ -1,48 +0,0 @@
import React from 'react';
import cx from 'classnames';
import { useDispatch } from 'react-redux';
import { dismissAlert } from 'redux/actions';
import { Alert as AlertProps } from 'redux/interfaces';
const Alert: React.FC<AlertProps> = ({
id,
type,
title,
message,
response,
}) => {
const classNames = React.useMemo(
() =>
cx('notification', {
'is-danger': type === 'error',
'is-success': type === 'success',
'is-info': type === 'info',
'is-warning': type === 'warning',
}),
[type]
);
const dispatch = useDispatch();
const dismiss = React.useCallback(() => {
dispatch(dismissAlert(id));
}, []);
return (
<div className={classNames}>
<button className="delete" type="button" onClick={dismiss}>
x
</button>
<div>
<h6 className="title is-6">{title}</h6>
<p className="subtitle is-6">{message}</p>
{response && (
<div className="is-flex">
<div className="mr-3">{response.status}</div>
<div>{response.body?.message || response.statusText}</div>
</div>
)}
</div>
</div>
);
};
export default Alert;

View file

@ -1,119 +0,0 @@
import React from 'react';
import { mount } from 'enzyme';
import { Alert as AlertProps } from 'redux/interfaces';
import * as actions from 'redux/actions/actions';
import Alert from 'components/Alert/Alert';
const id = 'test-id';
const title = 'My Alert Title';
const message = 'My Alert Message';
const statusCode = 123;
const serverSideMessage = 'Server Side Message';
const httpStatusText = 'My Status Text';
const dismiss = jest.fn();
describe('Alert', () => {
const setupComponent = (props: Partial<AlertProps> = {}) => (
<Alert
id={id}
type="error"
title={title}
message={message}
createdAt={1234567}
{...props}
/>
);
it('renders with initial props', () => {
const wrapper = mount(setupComponent());
expect(wrapper.exists('.title.is-6')).toBeTruthy();
expect(wrapper.find('.title.is-6').text()).toEqual(title);
expect(wrapper.exists('.subtitle.is-6')).toBeTruthy();
expect(wrapper.find('.subtitle.is-6').text()).toEqual(message);
expect(wrapper.exists('button')).toBeTruthy();
expect(wrapper.exists('.is-flex')).toBeFalsy();
});
it('renders alert with server side message', () => {
const wrapper = mount(
setupComponent({
type: 'info',
response: {
status: statusCode,
statusText: 'My Status Text',
body: {
message: serverSideMessage,
},
},
})
);
expect(wrapper.exists('.is-flex')).toBeTruthy();
expect(wrapper.find('.is-flex').text()).toEqual(
`${statusCode}${serverSideMessage}`
);
});
it('renders alert with http status text', () => {
const wrapper = mount(
setupComponent({
type: 'info',
response: {
status: statusCode,
statusText: httpStatusText,
body: {},
},
})
);
expect(wrapper.exists('.is-flex')).toBeTruthy();
expect(wrapper.find('.is-flex').text()).toEqual(
`${statusCode}${httpStatusText}`
);
});
it('matches snapshot', () => {
expect(mount(setupComponent())).toMatchSnapshot();
});
describe('types', () => {
it('renders error', () => {
const wrapper = mount(setupComponent({ type: 'error' }));
expect(wrapper.exists('.notification.is-danger')).toBeTruthy();
expect(wrapper.exists('.notification.is-warning')).toBeFalsy();
expect(wrapper.exists('.notification.is-info')).toBeFalsy();
expect(wrapper.exists('.notification.is-success')).toBeFalsy();
});
it('renders warning', () => {
const wrapper = mount(setupComponent({ type: 'warning' }));
expect(wrapper.exists('.notification.is-warning')).toBeTruthy();
expect(wrapper.exists('.notification.is-danger')).toBeFalsy();
expect(wrapper.exists('.notification.is-info')).toBeFalsy();
expect(wrapper.exists('.notification.is-success')).toBeFalsy();
});
it('renders info', () => {
const wrapper = mount(setupComponent({ type: 'info' }));
expect(wrapper.exists('.notification.is-info')).toBeTruthy();
expect(wrapper.exists('.notification.is-warning')).toBeFalsy();
expect(wrapper.exists('.notification.is-danger')).toBeFalsy();
expect(wrapper.exists('.notification.is-success')).toBeFalsy();
});
it('renders success', () => {
const wrapper = mount(setupComponent({ type: 'success' }));
expect(wrapper.exists('.notification.is-success')).toBeTruthy();
expect(wrapper.exists('.notification.is-warning')).toBeFalsy();
expect(wrapper.exists('.notification.is-info')).toBeFalsy();
expect(wrapper.exists('.notification.is-danger')).toBeFalsy();
});
});
describe('dismiss', () => {
it('handles dismiss callback', () => {
jest.spyOn(actions, 'dismissAlert').mockImplementation(dismiss);
const wrapper = mount(setupComponent());
wrapper.find('button').simulate('click');
expect(dismiss).toHaveBeenCalledWith(id);
});
});
});

View file

@ -1,35 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Alert matches snapshot 1`] = `
<Alert
createdAt={1234567}
id="test-id"
message="My Alert Message"
title="My Alert Title"
type="error"
>
<div
className="notification is-danger"
>
<button
className="delete"
onClick={[Function]}
type="button"
>
x
</button>
<div>
<h6
className="title is-6"
>
My Alert Title
</h6>
<p
className="subtitle is-6"
>
My Alert Message
</p>
</div>
</div>
</Alert>
`;

View file

@ -0,0 +1,27 @@
import { AlertType } from 'redux/interfaces';
import styled from 'styled-components';
export const Alert = styled.div<{ $type: AlertType }>`
background-color: ${({ $type, theme }) => theme.alert.color[$type]};
width: 400px;
min-height: 64px;
border-radius: 8px;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
filter: drop-shadow(0px 4px 16px rgba(0, 0, 0, 0.1));
margin-top: 10px;
line-height: 20px;
`;
export const Title = styled.div`
font-weight: 500;
font-size: 14px;
`;
export const Message = styled.p`
font-weight: normal;
font-size: 14px;
margin: 3px 0;
`;

View file

@ -0,0 +1,28 @@
import React from 'react';
import CloseIcon from 'components/common/Icons/CloseIcon';
import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper';
import { Alert as AlertType } from 'redux/interfaces';
import * as S from './Alert.styled';
export interface AlertProps {
title: AlertType['title'];
type: AlertType['type'];
message: AlertType['message'];
onDissmiss(): void;
}
const Alert: React.FC<AlertProps> = ({ title, type, message, onDissmiss }) => (
<S.Alert $type={type} role="alert">
<div>
<S.Title role="heading">{title}</S.Title>
<S.Message role="contentinfo">{message}</S.Message>
</div>
<IconButtonWrapper role="button" onClick={onDissmiss}>
<CloseIcon />
</IconButtonWrapper>
</S.Alert>
);
export default Alert;

View file

@ -0,0 +1,44 @@
import React from 'react';
import { dismissAlert } from 'redux/actions';
import { getAlerts } from 'redux/reducers/alerts/selectors';
import { alertDissmissed, selectAll } from 'redux/reducers/alerts/alertsSlice';
import { useAppSelector, useAppDispatch } from 'lib/hooks/redux';
import Alert from 'components/Alerts/Alert';
const Alerts: React.FC = () => {
const alerts = useAppSelector(selectAll);
const dispatch = useAppDispatch();
const dismiss = React.useCallback((id: string) => {
dispatch(alertDissmissed(id));
}, []);
const legacyAlerts = useAppSelector(getAlerts);
const dismissLegacy = React.useCallback((id: string) => {
dispatch(dismissAlert(id));
}, []);
return (
<>
{alerts.map(({ id, type, title, message }) => (
<Alert
key={id}
type={type}
title={title}
message={message}
onDissmiss={() => dismiss(id)}
/>
))}
{legacyAlerts.map(({ id, type, title, message }) => (
<Alert
key={id}
type={type}
title={title}
message={message}
onDissmiss={() => dismissLegacy(id)}
/>
))}
</>
);
};
export default Alerts;

View file

@ -0,0 +1,36 @@
import React from 'react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Alert as AlertProps } from 'redux/interfaces';
import Alert from 'components/Alerts/Alert';
import { render } from 'lib/testHelpers';
const id = 'test-id';
const title = 'My Alert Title';
const message = 'My Alert Message';
const dismiss = jest.fn();
describe('Alert', () => {
const setupComponent = (props: Partial<AlertProps> = {}) =>
render(
<Alert
id={id}
type="error"
title={title}
message={message}
onDissmiss={dismiss}
{...props}
/>
);
it('renders with initial props', () => {
setupComponent();
expect(screen.getByRole('heading')).toHaveTextContent(title);
expect(screen.getByRole('contentinfo')).toHaveTextContent(message);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('handles dismiss callback', () => {
setupComponent();
userEvent.click(screen.getByRole('button'));
expect(dismiss).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,59 @@
import React from 'react';
import { Action, FailurePayload, ServerResponse } from 'redux/interfaces';
import { screen } from '@testing-library/react';
import Alerts from 'components/Alerts/Alerts';
import { render } from 'lib/testHelpers';
import { store } from 'redux/store';
import { UnknownAsyncThunkRejectedWithValueAction } from '@reduxjs/toolkit/dist/matchers';
import userEvent from '@testing-library/user-event';
describe('Alerts', () => {
beforeEach(() => render(<Alerts />));
it('renders alerts', async () => {
const payload: ServerResponse = {
status: 422,
statusText: 'Unprocessable Entity',
message: 'Unprocessable Entity',
url: 'https://test.com/clusters',
};
const action: UnknownAsyncThunkRejectedWithValueAction = {
type: 'any/action/rejected',
payload,
meta: {
arg: 'test',
requestId: 'test-request-id',
requestStatus: 'rejected',
aborted: false,
condition: false,
rejectedWithValue: true,
},
error: { message: 'Rejected' },
};
store.dispatch(action);
const alert: FailurePayload = {
title: '404 - Not Found',
message: 'Item is not found',
subject: 'subject',
};
const legacyAction: Action = {
type: 'CLEAR_TOPIC_MESSAGES__FAILURE',
payload: { alert },
};
store.dispatch(legacyAction);
expect(screen.getAllByRole('alert').length).toEqual(2);
const dissmissAlertButtons = screen.getAllByRole('button');
expect(dissmissAlertButtons.length).toEqual(2);
const dissmissButton = dissmissAlertButtons[0];
const dissmissLegacyButton = dissmissAlertButtons[1];
userEvent.click(dissmissButton);
userEvent.click(dissmissLegacyButton);
expect(screen.queryAllByRole('alert').length).toEqual(0);
});
});

View file

@ -1,98 +0,0 @@
$header-height: 52px;
$navbar-width: 250px;
.Layout {
min-width: 1200px;
&__header {
box-shadow: 0 0.46875rem 2.1875rem rgba(4,9,20,0.03),
0 0.9375rem 1.40625rem rgba(4,9,20,0.03),
0 0.25rem 0.53125rem rgba(4,9,20,0.05),
0 0.125rem 0.1875rem rgba(4,9,20,0.03);
z-index: 31;
}
&__container {
margin-top: $header-height;
margin-left: $navbar-width;
position: relative;
z-index: 20;
}
&__sidebar{
width: $navbar-width;
display: flex;
flex-direction: column;
box-shadow: 7px 0 60px rgba(0,0,0,0.05);
position: fixed;
top: $header-height;
left: 0;
bottom: 0;
padding: 20px 20px;
overflow-y: scroll;
transition: width .25s,opacity .25s,transform .25s,-webkit-transform .25s;
&Overlay {
position: fixed;
top: 0;
height: 120vh;
z-index: 99;
display: block;
visibility: hidden;
opacity: 0;
-webkit-transition: all .5s ease;
transition: all .5s ease;
}
}
&__alerts {
max-width: 40%;
width: 500px;
position: fixed;
bottom: 15px;
right: 15px;
z-index: 1000;
}
}
.react-datepicker-wrapper {
display: flex !important;
}
.react-datepicker-popper {
z-index: 30 !important;
}
@media screen and (max-width: 1023px) {
.Layout {
min-width: initial;
&__container {
margin-left: initial;
margin-top: 1.5rem;
}
&__sidebar {
left: -$navbar-width;
z-index: 100;
}
&__alerts {
max-width: initial;
}
&--sidebarVisible {
.Layout__sidebar {
transform: translate3d($navbar-width,0,0);
&Overlay {
background-color: rgba(34,41,47,.5);
left: 0;
right: 0;
opacity: 1;
visibility: visible;
}
}
}
}
}

View file

@ -0,0 +1,177 @@
import styled, { css } from 'styled-components';
export const Layout = styled.div`
min-width: 1200px;
@media screen and (max-width: 1023px) {
min-width: initial;
}
`;
export const Container = styled.main(
({ theme }) => css`
margin-top: ${theme.layout.navBarHeight};
margin-left: ${theme.layout.navBarWidth};
position: relative;
z-index: 20;
@media screen and (max-width: 1023px) {
margin-left: initial;
}
`
);
export const Sidebar = styled.div<{ $visible: boolean }>(
({ theme, $visible }) => css`
width: ${theme.layout.navBarWidth};
display: flex;
flex-direction: column;
border-right: 1px solid #e7e7e7;
position: fixed;
top: ${theme.layout.navBarHeight};
left: 0;
bottom: 0;
padding: 8px 16px;
overflow-y: scroll;
transition: width 0.25s, opacity 0.25s, transform 0.25s,
-webkit-transform 0.25s;
background: ${theme.menuStyles.backgroundColor.normal};
@media screen and (max-width: 1023px) {
${$visible &&
`transform: translate3d(${theme.layout.navBarWidth}, 0, 0)`};
left: -${theme.layout.navBarWidth};
z-index: 100;
}
`
);
export const Overlay = styled.div<{ $visible: boolean }>(
({ theme, $visible }) => css`
height: calc(100vh - ${theme.layout.navBarHeight});
z-index: 99;
visibility: 'hidden';
opacity: 0;
-webkit-transition: all 0.5s ease;
transition: all 0.5s ease;
left: 0;
position: absolute;
top: 0;
${$visible &&
css`
@media screen and (max-width: 1023px) {
bottom: 0;
right: 0;
visibility: 'visible';
opacity: 1;
background-color: rgba(34, 41, 47, 0.5);
}
`}
`
);
export const Navbar = styled.nav(
({ theme }) => css`
border-bottom: 1px solid #e7e7e7;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 30;
background-color: ${theme.menuStyles.backgroundColor.normal};
min-height: 3.25rem;
`
);
export const NavbarBrand = styled.div`
display: flex;
flex-shrink: 0;
align-items: stretch;
min-height: 3.25rem;
`;
export const NavbarItem = styled.div`
display: flex;
position: relative;
flex-grow: 0;
flex-shrink: 0;
align-items: center;
line-height: 1.5;
padding: 0.5rem 0.75rem;
`;
export const NavbarBurger = styled.div(
({ theme }) => css`
display: block;
position: relative;
cursor: pointer;
height: 3.25rem;
width: 3.25rem;
margin: 0;
padding: 0;
&:hover {
background-color: ${theme.menuStyles.backgroundColor.hover};
}
@media screen and (min-width: 1024px) {
display: none;
}
`
);
export const Span = styled.span(
({ theme }) => css`
display: block;
position: absolute;
background: ${theme.menuStyles.color.active};
height: 1px;
left: calc(50% - 8px);
transform-origin: center;
transition-duration: 86ms;
transition-property: background-color, opacity, transform, -webkit-transform;
transition-timing-function: ease-out;
width: 16px;
&:first-child {
top: calc(50% - 6px);
}
&:nth-child(2) {
top: calc(50% - 1px);
}
&:nth-child(3) {
top: calc(50% + 4px);
}
`
);
export const Hyperlink = styled.a(
({ theme }) => css`
display: flex;
position: relative;
flex-grow: 0;
flex-shrink: 0;
align-items: center;
margin: 0;
color: ${theme.menuStyles.color.active};
font-size: 1.25rem;
font-weight: 600;
cursor: pointer;
line-height: 1.5;
padding: 0.5rem 0.75rem;
text-decoration: none;
word-break: break-word;
`
);
export const AlertsContainer = styled.div`
max-width: 40%;
width: 500px;
position: fixed;
bottom: 15px;
left: 15px;
z-index: 1000;
@media screen and (max-width: 1023px) {
max-width: initial;
}
`;

View file

@ -1,30 +1,28 @@
import React from 'react';
import cx from 'classnames';
import { Cluster } from 'generated-sources';
import { Switch, Route, useLocation } from 'react-router-dom';
import { GIT_TAG, GIT_COMMIT } from 'lib/constants';
import { Alerts } from 'redux/interfaces';
import Nav from 'components/Nav/Nav';
import PageLoader from 'components/common/PageLoader/PageLoader';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import Dashboard from 'components/Dashboard/Dashboard';
import ClusterPage from 'components/Cluster/Cluster';
import Version from 'components/Version/Version';
import Alert from 'components/Alert/Alert';
import 'components/App.scss';
import Alerts from 'components/Alerts/Alerts';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import {
fetchClusters,
getClusterList,
getAreClustersFulfilled,
} from 'redux/reducers/clusters/clustersSlice';
export interface AppProps {
isClusterListFetched?: boolean;
alerts: Alerts;
clusters: Cluster[];
fetchClustersList: () => void;
}
import * as S from './App.styled';
const App: React.FC<AppProps> = ({
isClusterListFetched,
alerts,
clusters,
fetchClustersList,
}) => {
const App: React.FC = () => {
const dispatch = useAppDispatch();
const areClustersFulfilled = useAppSelector(getAreClustersFulfilled);
const clusters = useAppSelector(getClusterList);
const [isSidebarVisible, setIsSidebarVisible] = React.useState(false);
const onBurgerClick = React.useCallback(
@ -41,85 +39,72 @@ const App: React.FC<AppProps> = ({
}, [location]);
React.useEffect(() => {
fetchClustersList();
}, [fetchClustersList]);
dispatch(fetchClusters());
}, [fetchClusters]);
return (
<div
className={cx('Layout', { 'Layout--sidebarVisible': isSidebarVisible })}
>
<nav
className="navbar is-fixed-top is-white Layout__header"
role="navigation"
aria-label="main navigation"
>
<div className="navbar-brand">
<div
className={cx('navbar-burger', 'ml-0', {
'is-active': isSidebarVisible,
})}
onClick={onBurgerClick}
onKeyDown={onBurgerClick}
role="button"
tabIndex={0}
>
<span />
<span />
<span />
</div>
<ThemeProvider theme={theme}>
<S.Layout>
<S.Navbar role="navigation" aria-label="Page Header">
<S.NavbarBrand>
<S.NavbarBurger
onClick={onBurgerClick}
onKeyDown={onBurgerClick}
role="button"
tabIndex={0}
>
<S.Span role="separator" />
<S.Span role="separator" />
<S.Span role="separator" />
</S.NavbarBurger>
<a className="navbar-item title is-5 is-marginless" href="/ui">
UI for Apache Kafka
</a>
<S.Hyperlink href="/ui">UI for Apache Kafka</S.Hyperlink>
<div className="navbar-item">
<Version tag={GIT_TAG} commit={GIT_COMMIT} />
</div>
</div>
</nav>
<S.NavbarItem>
<Version tag={GIT_TAG} commit={GIT_COMMIT} />
</S.NavbarItem>
</S.NavbarBrand>
</S.Navbar>
<main className="Layout__container">
<div className="Layout__sidebar has-shadow has-background-white">
<Nav
clusters={clusters}
isClusterListFetched={isClusterListFetched}
/>
</div>
<div
className="Layout__sidebarOverlay is-overlay"
onClick={closeSidebar}
onKeyDown={closeSidebar}
tabIndex={-1}
aria-hidden="true"
/>
{isClusterListFetched ? (
<Switch>
<Route
exact
path={['/', '/ui', '/ui/clusters']}
component={Dashboard}
<S.Container>
<S.Sidebar aria-label="Sidebar" $visible={isSidebarVisible}>
<Nav
clusters={clusters}
areClustersFulfilled={areClustersFulfilled}
/>
<Route path="/ui/clusters/:clusterName" component={ClusterPage} />
</Switch>
) : (
<PageLoader fullHeight />
)}
</main>
<div className="Layout__alerts">
{alerts.map(({ id, type, title, message, response, createdAt }) => (
<Alert
key={id}
id={id}
type={type}
title={title}
message={message}
response={response}
createdAt={createdAt}
</S.Sidebar>
<S.Overlay
$visible={isSidebarVisible}
onClick={closeSidebar}
onKeyDown={closeSidebar}
tabIndex={-1}
aria-hidden="true"
aria-label="Overlay"
/>
))}
</div>
</div>
{areClustersFulfilled ? (
<>
<Breadcrumb />
<Switch>
<Route
exact
path={['/', '/ui', '/ui/clusters']}
component={Dashboard}
/>
<Route
path="/ui/clusters/:clusterName"
component={ClusterPage}
/>
</Switch>
</>
) : (
<PageLoader />
)}
</S.Container>
<S.AlertsContainer role="toolbar">
<Alerts />
</S.AlertsContainer>
</S.Layout>
</ThemeProvider>
);
};

View file

@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import { fetchClustersList } from 'redux/actions';
import {
getClusterList,
getIsClusterListFetched,
} from 'redux/reducers/clusters/selectors';
import { getAlerts } from 'redux/reducers/alerts/selectors';
import { RootState } from 'redux/interfaces';
import App from 'components/App';
const mapStateToProps = (state: RootState) => ({
isClusterListFetched: getIsClusterListFetched(state),
alerts: getAlerts(state),
clusters: getClusterList(state),
});
const mapDispatchToProps = {
fetchClustersList,
};
export default connect(mapStateToProps, mapDispatchToProps)(App);

View file

@ -1,40 +1,38 @@
import React from 'react';
import { ClusterName, ZooKeeperStatus } from 'redux/interfaces';
import { ClusterStats } from 'generated-sources';
import useInterval from 'lib/hooks/useInterval';
import cx from 'classnames';
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
import Indicator from 'components/common/Dashboard/Indicator';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
import { useParams } from 'react-router';
interface Props extends ClusterStats {
isFetched: boolean;
fetchClusterStats: (clusterName: ClusterName) => void;
fetchBrokers: (clusterName: ClusterName) => void;
}
const Brokers: React.FC<Props> = ({
brokerCount,
activeControllers,
zooKeeperStatus,
onlinePartitionCount,
offlinePartitionCount,
inSyncReplicasCount,
outOfSyncReplicasCount,
underReplicatedPartitionCount,
diskUsage,
import TagStyled from 'components/common/Tag/Tag.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { Table } from 'components/common/table/Table/Table.styled';
import PageHeading from 'components/common/PageHeading/PageHeading';
import * as Metrics from 'components/common/Metrics';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import {
fetchClusterStats,
fetchBrokers,
version,
}) => {
selectStats,
} from 'redux/reducers/brokers/brokersSlice';
const Brokers: React.FC = () => {
const dispatch = useAppDispatch();
const { clusterName } = useParams<{ clusterName: ClusterName }>();
const {
brokerCount,
activeControllers,
zooKeeperStatus,
onlinePartitionCount,
offlinePartitionCount,
inSyncReplicasCount,
outOfSyncReplicasCount,
underReplicatedPartitionCount,
diskUsage,
version,
} = useAppSelector(selectStats);
React.useEffect(() => {
fetchClusterStats(clusterName);
fetchBrokers(clusterName);
}, [fetchClusterStats, fetchBrokers, clusterName]);
dispatch(fetchClusterStats(clusterName));
}, [fetchClusterStats, clusterName]);
useInterval(() => {
fetchClusterStats(clusterName);
@ -43,61 +41,67 @@ const Brokers: React.FC<Props> = ({
const zkOnline = zooKeeperStatus === ZooKeeperStatus.online;
return (
<div className="section">
<Breadcrumb>Brokers overview</Breadcrumb>
<MetricsWrapper title="Uptime">
<Indicator className="is-one-third" label="Total Brokers">
{brokerCount}
</Indicator>
<Indicator className="is-one-third" label="Active Controllers">
{activeControllers}
</Indicator>
<Indicator className="is-one-third" label="Zookeeper Status">
<span className={cx('tag', zkOnline ? 'is-success' : 'is-danger')}>
{zkOnline ? 'Online' : 'Offline'}
</span>
</Indicator>
<Indicator className="is-one-third" label="Version">
{version}
</Indicator>
</MetricsWrapper>
<MetricsWrapper title="Partitions">
<Indicator label="Online">
<span
className={cx({ 'has-text-danger': offlinePartitionCount !== 0 })}
>
{onlinePartitionCount}
</span>
<span className="subtitle has-text-weight-light">
{' '}
of
{(onlinePartitionCount || 0) + (offlinePartitionCount || 0)}
</span>
</Indicator>
<Indicator label="URP" title="Under replicated partitions">
{underReplicatedPartitionCount}
</Indicator>
<Indicator label="In Sync Replicas">{inSyncReplicasCount}</Indicator>
<Indicator label="Out of Sync Replicas">
{outOfSyncReplicasCount}
</Indicator>
</MetricsWrapper>
<MetricsWrapper multiline title="Disk Usage">
{diskUsage?.map((brokerDiskUsage) => (
<React.Fragment key={brokerDiskUsage.brokerId}>
<Indicator className="is-one-third" label="Broker">
{brokerDiskUsage.brokerId}
</Indicator>
<Indicator className="is-one-third" label="Segment Size" title="">
<BytesFormatted value={brokerDiskUsage.segmentSize} />
</Indicator>
<Indicator className="is-one-third" label="Segment count">
{brokerDiskUsage.segmentCount}
</Indicator>
</React.Fragment>
))}
</MetricsWrapper>
</div>
<>
<PageHeading text="Brokers" />
<Metrics.Wrapper>
<Metrics.Section title="Uptime">
<Metrics.Indicator label="Total Brokers">
{brokerCount}
</Metrics.Indicator>
<Metrics.Indicator label="Active Controllers">
{activeControllers}
</Metrics.Indicator>
<Metrics.Indicator label="Zookeeper Status">
<TagStyled color={zkOnline ? 'green' : 'gray'}>
{zkOnline ? 'online' : 'offline'}
</TagStyled>
</Metrics.Indicator>
<Metrics.Indicator label="Version">{version}</Metrics.Indicator>
</Metrics.Section>
<Metrics.Section title="Partitions">
<Metrics.Indicator label="Online" isAlert>
{offlinePartitionCount && offlinePartitionCount > 0 ? (
<Metrics.RedText>{onlinePartitionCount}</Metrics.RedText>
) : (
onlinePartitionCount
)}
<Metrics.LightText>
{' '}
of {(onlinePartitionCount || 0) + (offlinePartitionCount || 0)}
</Metrics.LightText>
</Metrics.Indicator>
<Metrics.Indicator label="URP" title="Under replicated partitions">
{underReplicatedPartitionCount}
</Metrics.Indicator>
<Metrics.Indicator label="In Sync Replicas">
{inSyncReplicasCount}
</Metrics.Indicator>
<Metrics.Indicator label="Out of Sync Replicas">
{outOfSyncReplicasCount}
</Metrics.Indicator>
</Metrics.Section>
</Metrics.Wrapper>
<Table isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Broker" />
<TableHeaderCell title="Segment size (Mb)" />
<TableHeaderCell title="Segment Count" />
</tr>
</thead>
<tbody>
{diskUsage?.map(({ brokerId, segmentSize, segmentCount }) => (
<tr key={brokerId}>
<td>{brokerId}</td>
<td>
<BytesFormatted value={segmentSize} />
</td>
<td>{segmentCount}</td>
</tr>
))}
</tbody>
</Table>
</>
);
};

View file

@ -1,38 +0,0 @@
import { connect } from 'react-redux';
import { fetchClusterStats, fetchBrokers } from 'redux/actions';
import { RootState } from 'redux/interfaces';
import {
getIsBrokerListFetched,
getBrokerCount,
getZooKeeperStatus,
getActiveControllers,
getOnlinePartitionCount,
getOfflinePartitionCount,
getInSyncReplicasCount,
getOutOfSyncReplicasCount,
getUnderReplicatedPartitionCount,
getDiskUsage,
getVersion,
} from 'redux/reducers/brokers/selectors';
import Brokers from 'components/Brokers/Brokers';
const mapStateToProps = (state: RootState) => ({
isFetched: getIsBrokerListFetched(state),
brokerCount: getBrokerCount(state),
zooKeeperStatus: getZooKeeperStatus(state),
activeControllers: getActiveControllers(state),
onlinePartitionCount: getOnlinePartitionCount(state),
offlinePartitionCount: getOfflinePartitionCount(state),
inSyncReplicasCount: getInSyncReplicasCount(state),
outOfSyncReplicasCount: getOutOfSyncReplicasCount(state),
underReplicatedPartitionCount: getUnderReplicatedPartitionCount(state),
diskUsage: getDiskUsage(state),
version: getVersion(state),
});
const mapDispatchToProps = {
fetchClusterStats,
fetchBrokers,
};
export default connect(mapStateToProps, mapDispatchToProps)(Brokers);

View file

@ -1,90 +1,54 @@
import React from 'react';
import { mount } from 'enzyme';
import Brokers from 'components/Brokers/Brokers';
import { ClusterName } from 'redux/interfaces';
import { StaticRouter } from 'react-router';
import { ClusterStats } from 'generated-sources';
interface Props extends ClusterStats {
isFetched: boolean;
fetchClusterStats: (clusterName: ClusterName) => void;
fetchBrokers: (clusterName: ClusterName) => void;
}
import { render } from 'lib/testHelpers';
import { screen, waitFor } from '@testing-library/dom';
import { Route, StaticRouter } from 'react-router';
import { clusterBrokersPath } from 'lib/paths';
import fetchMock from 'fetch-mock';
import { clusterStatsPayload } from 'redux/reducers/brokers/__test__/fixtures';
describe('Brokers Component', () => {
const pathname = `ui/clusters/local/brokers`;
afterEach(() => fetchMock.reset());
describe('Brokers Empty', () => {
const setupEmptyComponent = (props: Partial<Props> = {}) => (
<StaticRouter location={{ pathname }} context={{}}>
<Brokers
brokerCount={0}
activeControllers={0}
zooKeeperStatus={0}
onlinePartitionCount={0}
offlinePartitionCount={0}
inSyncReplicasCount={0}
outOfSyncReplicasCount={0}
underReplicatedPartitionCount={0}
version="1"
fetchClusterStats={jest.fn()}
fetchBrokers={jest.fn()}
diskUsage={undefined}
isFetched={false}
{...props}
/>
const clusterName = 'local';
const renderComponent = () =>
render(
<StaticRouter
location={{
pathname: clusterBrokersPath(clusterName),
}}
>
<Route path={clusterBrokersPath(':clusterName')}>
<Brokers />
</Route>
</StaticRouter>
);
it('renders section', () => {
const component = mount(setupEmptyComponent());
expect(component.exists('.section')).toBeTruthy();
});
it('renders section with is-danger selector', () => {
const component = mount(setupEmptyComponent());
expect(component.exists('.is-danger')).toBeTruthy();
});
it('matches Brokers Empty snapshot', () => {
expect(mount(setupEmptyComponent())).toMatchSnapshot();
});
});
describe('Brokers', () => {
const setupComponent = (props: Partial<Props> = {}) => (
<StaticRouter location={{ pathname }} context={{}}>
<Brokers
brokerCount={1}
activeControllers={1}
zooKeeperStatus={1}
onlinePartitionCount={64}
offlinePartitionCount={0}
inSyncReplicasCount={64}
outOfSyncReplicasCount={0}
underReplicatedPartitionCount={0}
version="1"
fetchClusterStats={jest.fn()}
fetchBrokers={jest.fn()}
diskUsage={[
{
brokerId: 1,
segmentCount: 64,
segmentSize: 60718,
},
]}
isFetched
{...props}
/>
</StaticRouter>
);
it('renders section with is-success selector', () => {
const component = mount(setupComponent());
expect(component.exists('.is-success')).toBeTruthy();
it('renders', async () => {
const mock = fetchMock.getOnce(
`/api/clusters/${clusterName}/stats`,
clusterStatsPayload
);
renderComponent();
await waitFor(() => expect(mock.called()).toBeTruthy());
expect(screen.getByRole('table')).toBeInTheDocument();
const rows = screen.getAllByRole('row');
expect(rows.length).toEqual(3);
});
it('matches snapshot', () => {
expect(mount(setupComponent())).toMatchSnapshot();
it('shows warning when offlinePartitionCount > 0', async () => {
const mock = fetchMock.getOnce(`/api/clusters/${clusterName}/stats`, {
...clusterStatsPayload,
offlinePartitionCount: 1345,
});
renderComponent();
await waitFor(() => expect(mock.called()).toBeTruthy());
const onlineWidget = screen.getByText(
clusterStatsPayload.onlinePartitionCount
);
expect(onlineWidget).toBeInTheDocument();
expect(onlineWidget).toHaveStyle({ color: '#E51A1A' });
});
});
});

View file

@ -1,8 +0,0 @@
import React from 'react';
import { containerRendersView } from 'lib/testHelpers';
import Brokers from 'components/Brokers/Brokers';
import BrokersContainer from 'components/Brokers/BrokersContainer';
describe('BrokersContainer', () => {
containerRendersView(<BrokersContainer />, Brokers);
});

View file

@ -1,779 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Brokers Component Brokers Empty matches Brokers Empty snapshot 1`] = `
<StaticRouter
context={Object {}}
location={
Object {
"pathname": "ui/clusters/local/brokers",
}
}
>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "ui/clusters/local/brokers",
"search": "",
},
"push": [Function],
"replace": [Function],
}
}
staticContext={Object {}}
>
<Brokers
activeControllers={0}
brokerCount={0}
fetchBrokers={
[MockFunction] {
"calls": Array [
Array [
undefined,
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
}
fetchClusterStats={
[MockFunction] {
"calls": Array [
Array [
undefined,
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
}
inSyncReplicasCount={0}
isFetched={false}
offlinePartitionCount={0}
onlinePartitionCount={0}
outOfSyncReplicasCount={0}
underReplicatedPartitionCount={0}
version="1"
zooKeeperStatus={0}
>
<div
className="section"
>
<Breadcrumb>
<nav
aria-label="breadcrumbs"
className="breadcrumb"
>
<ul>
<li
className="is-active"
>
<span
className=""
>
Brokers overview
</span>
</li>
</ul>
</nav>
</Breadcrumb>
<MetricsWrapper
title="Uptime"
>
<div
className="box"
>
<h5
className="subtitle is-6"
>
Uptime
</h5>
<div
className="level"
>
<Indicator
className="is-one-third"
label="Total Brokers"
>
<div
className="level-item is-one-third"
>
<div
title="Total Brokers"
>
<p
className="heading"
>
Total Brokers
</p>
<p
className="title has-text-centered"
>
0
</p>
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Active Controllers"
>
<div
className="level-item is-one-third"
>
<div
title="Active Controllers"
>
<p
className="heading"
>
Active Controllers
</p>
<p
className="title has-text-centered"
>
0
</p>
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Zookeeper Status"
>
<div
className="level-item is-one-third"
>
<div
title="Zookeeper Status"
>
<p
className="heading"
>
Zookeeper Status
</p>
<p
className="title has-text-centered"
>
<span
className="tag is-danger"
>
Offline
</span>
</p>
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Version"
>
<div
className="level-item is-one-third"
>
<div
title="Version"
>
<p
className="heading"
>
Version
</p>
<p
className="title has-text-centered"
>
1
</p>
</div>
</div>
</Indicator>
</div>
</div>
</MetricsWrapper>
<MetricsWrapper
title="Partitions"
>
<div
className="box"
>
<h5
className="subtitle is-6"
>
Partitions
</h5>
<div
className="level"
>
<Indicator
label="Online"
>
<div
className="level-item"
>
<div
title="Online"
>
<p
className="heading"
>
Online
</p>
<p
className="title has-text-centered"
>
<span
className=""
>
0
</span>
<span
className="subtitle has-text-weight-light"
>
of
0
</span>
</p>
</div>
</div>
</Indicator>
<Indicator
label="URP"
title="Under replicated partitions"
>
<div
className="level-item"
>
<div
title="Under replicated partitions"
>
<p
className="heading"
>
URP
</p>
<p
className="title has-text-centered"
>
0
</p>
</div>
</div>
</Indicator>
<Indicator
label="In Sync Replicas"
>
<div
className="level-item"
>
<div
title="In Sync Replicas"
>
<p
className="heading"
>
In Sync Replicas
</p>
<p
className="title has-text-centered"
>
0
</p>
</div>
</div>
</Indicator>
<Indicator
label="Out of Sync Replicas"
>
<div
className="level-item"
>
<div
title="Out of Sync Replicas"
>
<p
className="heading"
>
Out of Sync Replicas
</p>
<p
className="title has-text-centered"
>
0
</p>
</div>
</div>
</Indicator>
</div>
</div>
</MetricsWrapper>
<MetricsWrapper
multiline={true}
title="Disk Usage"
>
<div
className="box"
>
<h5
className="subtitle is-6"
>
Disk Usage
</h5>
<div
className="level level-multiline"
/>
</div>
</MetricsWrapper>
</div>
</Brokers>
</Router>
</StaticRouter>
`;
exports[`Brokers Component Brokers matches snapshot 1`] = `
<StaticRouter
context={Object {}}
location={
Object {
"pathname": "ui/clusters/local/brokers",
}
}
>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "ui/clusters/local/brokers",
"search": "",
},
"push": [Function],
"replace": [Function],
}
}
staticContext={Object {}}
>
<Brokers
activeControllers={1}
brokerCount={1}
diskUsage={
Array [
Object {
"brokerId": 1,
"segmentCount": 64,
"segmentSize": 60718,
},
]
}
fetchBrokers={
[MockFunction] {
"calls": Array [
Array [
undefined,
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
}
fetchClusterStats={
[MockFunction] {
"calls": Array [
Array [
undefined,
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
}
inSyncReplicasCount={64}
isFetched={true}
offlinePartitionCount={0}
onlinePartitionCount={64}
outOfSyncReplicasCount={0}
underReplicatedPartitionCount={0}
version="1"
zooKeeperStatus={1}
>
<div
className="section"
>
<Breadcrumb>
<nav
aria-label="breadcrumbs"
className="breadcrumb"
>
<ul>
<li
className="is-active"
>
<span
className=""
>
Brokers overview
</span>
</li>
</ul>
</nav>
</Breadcrumb>
<MetricsWrapper
title="Uptime"
>
<div
className="box"
>
<h5
className="subtitle is-6"
>
Uptime
</h5>
<div
className="level"
>
<Indicator
className="is-one-third"
label="Total Brokers"
>
<div
className="level-item is-one-third"
>
<div
title="Total Brokers"
>
<p
className="heading"
>
Total Brokers
</p>
<p
className="title has-text-centered"
>
1
</p>
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Active Controllers"
>
<div
className="level-item is-one-third"
>
<div
title="Active Controllers"
>
<p
className="heading"
>
Active Controllers
</p>
<p
className="title has-text-centered"
>
1
</p>
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Zookeeper Status"
>
<div
className="level-item is-one-third"
>
<div
title="Zookeeper Status"
>
<p
className="heading"
>
Zookeeper Status
</p>
<p
className="title has-text-centered"
>
<span
className="tag is-success"
>
Online
</span>
</p>
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Version"
>
<div
className="level-item is-one-third"
>
<div
title="Version"
>
<p
className="heading"
>
Version
</p>
<p
className="title has-text-centered"
>
1
</p>
</div>
</div>
</Indicator>
</div>
</div>
</MetricsWrapper>
<MetricsWrapper
title="Partitions"
>
<div
className="box"
>
<h5
className="subtitle is-6"
>
Partitions
</h5>
<div
className="level"
>
<Indicator
label="Online"
>
<div
className="level-item"
>
<div
title="Online"
>
<p
className="heading"
>
Online
</p>
<p
className="title has-text-centered"
>
<span
className=""
>
64
</span>
<span
className="subtitle has-text-weight-light"
>
of
64
</span>
</p>
</div>
</div>
</Indicator>
<Indicator
label="URP"
title="Under replicated partitions"
>
<div
className="level-item"
>
<div
title="Under replicated partitions"
>
<p
className="heading"
>
URP
</p>
<p
className="title has-text-centered"
>
0
</p>
</div>
</div>
</Indicator>
<Indicator
label="In Sync Replicas"
>
<div
className="level-item"
>
<div
title="In Sync Replicas"
>
<p
className="heading"
>
In Sync Replicas
</p>
<p
className="title has-text-centered"
>
64
</p>
</div>
</div>
</Indicator>
<Indicator
label="Out of Sync Replicas"
>
<div
className="level-item"
>
<div
title="Out of Sync Replicas"
>
<p
className="heading"
>
Out of Sync Replicas
</p>
<p
className="title has-text-centered"
>
0
</p>
</div>
</div>
</Indicator>
</div>
</div>
</MetricsWrapper>
<MetricsWrapper
multiline={true}
title="Disk Usage"
>
<div
className="box"
>
<h5
className="subtitle is-6"
>
Disk Usage
</h5>
<div
className="level level-multiline"
>
<Indicator
className="is-one-third"
label="Broker"
>
<div
className="level-item is-one-third"
>
<div
title="Broker"
>
<p
className="heading"
>
Broker
</p>
<p
className="title has-text-centered"
>
1
</p>
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Segment Size"
title=""
>
<div
className="level-item is-one-third"
>
<div
title="Segment Size"
>
<p
className="heading"
>
Segment Size
</p>
<p
className="title has-text-centered"
>
<BytesFormatted
value={60718}
>
<span>
59KB
</span>
</BytesFormatted>
</p>
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Segment count"
>
<div
className="level-item is-one-third"
>
<div
title="Segment count"
>
<p
className="heading"
>
Segment count
</p>
<p
className="title has-text-centered"
>
64
</p>
</div>
</div>
</Indicator>
</div>
</div>
</MetricsWrapper>
</div>
</Brokers>
</Router>
</StaticRouter>
`;

View file

@ -5,7 +5,7 @@ import { ClusterFeaturesEnum } from 'generated-sources';
import {
getClustersFeatures,
getClustersReadonlyStatus,
} from 'redux/reducers/clusters/selectors';
} from 'redux/reducers/clusters/clustersSlice';
import {
clusterBrokersPath,
clusterConnectorsPath,
@ -19,8 +19,8 @@ import Topics from 'components/Topics/Topics';
import Schemas from 'components/Schemas/Schemas';
import Connect from 'components/Connect/Connect';
import ClusterContext from 'components/contexts/ClusterContext';
import BrokersContainer from 'components/Brokers/BrokersContainer';
import ConsumersGroupsContainer from 'components/ConsumerGroups/ConsumersGroupsContainer';
import Brokers from 'components/Brokers/Brokers';
import ConsumersGroups from 'components/ConsumerGroups/ConsumerGroups';
import KsqlDb from 'components/KsqlDb/KsqlDb';
const Cluster: React.FC = () => {
@ -52,14 +52,11 @@ const Cluster: React.FC = () => {
return (
<ClusterContext.Provider value={contextValue}>
<Switch>
<Route
path={clusterBrokersPath(':clusterName')}
component={BrokersContainer}
/>
<Route path={clusterBrokersPath(':clusterName')} component={Brokers} />
<Route path={clusterTopicsPath(':clusterName')} component={Topics} />
<Route
path={clusterConsumerGroupsPath(':clusterName')}
component={ConsumersGroupsContainer}
component={ConsumersGroups}
/>
{hasSchemaRegistryConfigured && (
<Route

View file

@ -1,85 +1,114 @@
import React from 'react';
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import { Route, StaticRouter } from 'react-router-dom';
import { ClusterFeaturesEnum } from 'generated-sources';
import { fetchClusterListAction } from 'redux/actions';
import configureStore from 'redux/store/configureStore';
import { store } from 'redux/store';
import { onlineClusterPayload } from 'redux/reducers/clusters/__test__/fixtures';
import Cluster from 'components/Cluster/Cluster';
import { fetchClusters } from 'redux/reducers/clusters/clustersSlice';
import { screen } from '@testing-library/react';
import { render } from 'lib/testHelpers';
import {
clusterBrokersPath,
clusterConnectsPath,
clusterConsumerGroupsPath,
clusterKsqlDbPath,
clusterSchemasPath,
clusterTopicsPath,
} from 'lib/paths';
const store = configureStore();
jest.mock('components/Topics/Topics', () => 'mock-Topics');
jest.mock('components/Schemas/Schemas', () => 'mock-Schemas');
jest.mock('components/Connect/Connect', () => 'mock-Connect');
jest.mock('components/Brokers/BrokersContainer', () => 'mock-Brokers');
jest.mock(
'components/ConsumerGroups/ConsumersGroupsContainer',
() => 'mock-ConsumerGroups'
);
jest.mock('components/Topics/Topics', () => () => <div>Topics</div>);
jest.mock('components/Schemas/Schemas', () => () => <div>Schemas</div>);
jest.mock('components/Connect/Connect', () => () => <div>Connect</div>);
jest.mock('components/Connect/Connect', () => () => <div>Connect</div>);
jest.mock('components/Brokers/Brokers', () => () => <div>Brokers</div>);
jest.mock('components/ConsumerGroups/ConsumerGroups', () => () => (
<div>ConsumerGroups</div>
));
jest.mock('components/KsqlDb/KsqlDb', () => () => <div>KsqlDb</div>);
describe('Cluster', () => {
const setupComponent = (pathname: string) => (
<Provider store={store}>
const renderComponent = (pathname: string) =>
render(
<StaticRouter location={{ pathname }}>
<Route path="/ui/clusters/:clusterName">
<Cluster />
</Route>
</StaticRouter>
</Provider>
);
);
it('renders Brokers', () => {
const wrapper = mount(setupComponent('/ui/clusters/secondLocal/brokers'));
expect(wrapper.exists('mock-Brokers')).toBeTruthy();
renderComponent(clusterBrokersPath('second'));
expect(screen.getByText('Brokers')).toBeInTheDocument();
});
it('renders Topics', () => {
const wrapper = mount(setupComponent('/ui/clusters/secondLocal/topics'));
expect(wrapper.exists('mock-Topics')).toBeTruthy();
renderComponent(clusterTopicsPath('second'));
expect(screen.getByText('Topics')).toBeInTheDocument();
});
it('renders ConsumerGroups', () => {
const wrapper = mount(
setupComponent('/ui/clusters/secondLocal/consumer-groups')
);
expect(wrapper.exists('mock-ConsumerGroups')).toBeTruthy();
renderComponent(clusterConsumerGroupsPath('second'));
expect(screen.getByText('ConsumerGroups')).toBeInTheDocument();
});
describe('configured features', () => {
it('does not render Schemas if SCHEMA_REGISTRY is not configured', () => {
const wrapper = mount(setupComponent('/ui/clusters/secondLocal/schemas'));
expect(wrapper.exists('mock-Schemas')).toBeFalsy();
});
it('renders Schemas if SCHEMA_REGISTRY is configured', () => {
store.dispatch(
fetchClusterListAction.success([
{
...onlineClusterPayload,
features: [ClusterFeaturesEnum.SCHEMA_REGISTRY],
},
])
fetchClusters.fulfilled(
[
{
...onlineClusterPayload,
features: [],
},
],
'123'
)
);
const wrapper = mount(setupComponent('/ui/clusters/secondLocal/schemas'));
expect(wrapper.exists('mock-Schemas')).toBeTruthy();
renderComponent(clusterSchemasPath('second'));
expect(screen.queryByText('Schemas')).not.toBeInTheDocument();
});
it('does not render Connect if KAFKA_CONNECT is not configured', () => {
const wrapper = mount(
setupComponent('/ui/clusters/secondLocal/connectors')
);
expect(wrapper.exists('mock-Connect')).toBeFalsy();
});
it('renders Schemas if KAFKA_CONNECT is configured', async () => {
it('renders Schemas if SCHEMA_REGISTRY is configured', async () => {
store.dispatch(
fetchClusterListAction.success([
{
...onlineClusterPayload,
features: [ClusterFeaturesEnum.KAFKA_CONNECT],
},
])
fetchClusters.fulfilled(
[
{
...onlineClusterPayload,
features: [ClusterFeaturesEnum.SCHEMA_REGISTRY],
},
],
'123'
)
);
const wrapper = mount(
setupComponent('/ui/clusters/secondLocal/connectors')
renderComponent(clusterSchemasPath(onlineClusterPayload.name));
expect(screen.getByText('Schemas')).toBeInTheDocument();
});
it('renders Connect if KAFKA_CONNECT is configured', async () => {
store.dispatch(
fetchClusters.fulfilled(
[
{
...onlineClusterPayload,
features: [ClusterFeaturesEnum.KAFKA_CONNECT],
},
],
'requestId'
)
);
expect(wrapper.exists('mock-Connect')).toBeTruthy();
renderComponent(clusterConnectsPath(onlineClusterPayload.name));
expect(screen.getByText('Connect')).toBeInTheDocument();
});
it('renders KSQL if KSQL_DB is configured', async () => {
store.dispatch(
fetchClusters.fulfilled(
[
{
...onlineClusterPayload,
features: [ClusterFeaturesEnum.KSQL_DB],
},
],
'requestId'
)
);
renderComponent(clusterKsqlDbPath(onlineClusterPayload.name));
expect(screen.getByText('KsqlDb')).toBeInTheDocument();
});
});
});

View file

@ -1,71 +0,0 @@
import React from 'react';
import { Route, Switch, useParams } from 'react-router-dom';
import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
import {
clusterConnectorsPath,
clusterConnectorNewPath,
clusterConnectConnectorPath,
clusterConnectConnectorEditPath,
} from 'lib/paths';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
interface RouterParams {
clusterName: ClusterName;
connectName: ConnectName;
connectorName: ConnectorName;
}
const Breadcrumbs: React.FC = () => {
const { clusterName, connectName, connectorName } = useParams<RouterParams>();
const rootLinks = [
{
href: clusterConnectorsPath(clusterName),
label: 'All Connectors',
},
];
const connectorLinks = [
...rootLinks,
{
href: clusterConnectConnectorPath(
clusterName,
connectName,
connectorName
),
label: connectorName,
},
];
return (
<Switch>
<Route exact path={clusterConnectorsPath(':clusterName')}>
<Breadcrumb>All Connectors</Breadcrumb>
</Route>
<Route exact path={clusterConnectorNewPath(':clusterName')}>
<Breadcrumb links={rootLinks}>New Connector</Breadcrumb>
</Route>
<Route
exact
path={clusterConnectConnectorEditPath(
':clusterName',
':connectName',
':connectorName'
)}
>
<Breadcrumb links={connectorLinks}>Edit</Breadcrumb>
</Route>
<Route
path={clusterConnectConnectorPath(
':clusterName',
':connectName',
':connectorName'
)}
>
<Breadcrumb links={rootLinks}>{connectorName}</Breadcrumb>
</Route>
</Switch>
);
};
export default Breadcrumbs;

View file

@ -1,65 +0,0 @@
import React from 'react';
import { create } from 'react-test-renderer';
import Breadcrumbs from 'components/Connect/Breadcrumbs/Breadcrumbs';
import {
clusterConnectConnectorEditPath,
clusterConnectConnectorPath,
clusterConnectorNewPath,
clusterConnectorsPath,
} from 'lib/paths';
import { TestRouterWrapper } from 'lib/testHelpers';
describe('Breadcrumbs', () => {
const setupWrapper = (pathname: string) => (
<TestRouterWrapper
pathname={pathname}
urlParams={{
clusterName: 'my-cluster',
connectName: 'my-connect',
connectorName: 'my-connector',
}}
>
<Breadcrumbs />
</TestRouterWrapper>
);
it('matches snapshot for root path', () => {
expect(
create(setupWrapper(clusterConnectorsPath(':clusterName'))).toJSON()
).toMatchSnapshot();
});
it('matches snapshot for new connector path', () => {
expect(
create(setupWrapper(clusterConnectorNewPath(':clusterName'))).toJSON()
).toMatchSnapshot();
});
it('matches snapshot for connector edit path', () => {
expect(
create(
setupWrapper(
clusterConnectConnectorEditPath(
':clusterName',
':connectName',
':connectorName'
)
)
).toJSON()
).toMatchSnapshot();
});
it('matches snapshot for connector path', () => {
expect(
create(
setupWrapper(
clusterConnectConnectorPath(
':clusterName',
':connectName',
':connectorName'
)
)
).toJSON()
).toMatchSnapshot();
});
});

View file

@ -1,109 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Breadcrumbs matches snapshot for connector edit path 1`] = `
<nav
aria-label="breadcrumbs"
className="breadcrumb"
>
<ul>
<li>
<a
href="/ui/clusters/my-cluster/connectors"
onClick={[Function]}
>
All Connectors
</a>
</li>
<li>
<a
href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector"
onClick={[Function]}
>
my-connector
</a>
</li>
<li
className="is-active"
>
<span
className=""
>
Edit
</span>
</li>
</ul>
</nav>
`;
exports[`Breadcrumbs matches snapshot for connector path 1`] = `
<nav
aria-label="breadcrumbs"
className="breadcrumb"
>
<ul>
<li>
<a
href="/ui/clusters/my-cluster/connectors"
onClick={[Function]}
>
All Connectors
</a>
</li>
<li
className="is-active"
>
<span
className=""
>
my-connector
</span>
</li>
</ul>
</nav>
`;
exports[`Breadcrumbs matches snapshot for new connector path 1`] = `
<nav
aria-label="breadcrumbs"
className="breadcrumb"
>
<ul>
<li>
<a
href="/ui/clusters/my-cluster/connectors"
onClick={[Function]}
>
All Connectors
</a>
</li>
<li
className="is-active"
>
<span
className=""
>
New Connector
</span>
</li>
</ul>
</nav>
`;
exports[`Breadcrumbs matches snapshot for root path 1`] = `
<nav
aria-label="breadcrumbs"
className="breadcrumb"
>
<ul>
<li
className="is-active"
>
<span
className=""
>
All Connectors
</span>
</li>
</ul>
</nav>
`;

View file

@ -7,28 +7,13 @@ import {
clusterConnectConnectorEditPath,
} from 'lib/paths';
import Breadcrumbs from './Breadcrumbs/Breadcrumbs';
import ListContainer from './List/ListContainer';
import NewContainer from './New/NewContainer';
import DetailsContainer from './Details/DetailsContainer';
import EditContainer from './Edit/EditContainer';
const Connect: React.FC = () => (
<div className="section">
<Switch>
<Route
path={clusterConnectConnectorPath(
':clusterName',
':connectName',
':connectorName'
)}
component={Breadcrumbs}
/>
<Route
path={clusterConnectorsPath(':clusterName')}
component={Breadcrumbs}
/>
</Switch>
<div>
<Switch>
<Route
exact

View file

@ -1,21 +0,0 @@
import cx from 'classnames';
import { ConnectorState } from 'generated-sources';
import React from 'react';
export interface StatusTagProps {
status: ConnectorState;
}
const ConnectorStatusTag: React.FC<StatusTagProps> = ({ status }) => {
const classNames = cx('tag', {
'is-success': status === ConnectorState.RUNNING,
'is-light': status === ConnectorState.PAUSED,
'is-warning': status === ConnectorState.UNASSIGNED,
'is-danger':
status === ConnectorState.FAILED || status === ConnectorState.TASK_FAILED,
});
return <span className={classNames}>{status}</span>;
};
export default ConnectorStatusTag;

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Link, useHistory, useParams } from 'react-router-dom';
import { useHistory, useParams } from 'react-router-dom';
import { ConnectorState } from 'generated-sources';
import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
import {
@ -7,6 +7,8 @@ import {
clusterConnectorsPath,
} from 'lib/paths';
import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
import styled from 'styled-components';
import { Button } from 'components/common/Button/Button';
interface RouterParams {
clusterName: ClusterName;
@ -14,6 +16,11 @@ interface RouterParams {
connectorName: ConnectorName;
}
const ConnectorActionsWrapperStyled = styled.div`
display: flex;
gap: 8px;
`;
export interface ActionsProps {
deleteConnector(
clusterName: ClusterName,
@ -78,81 +85,79 @@ const Actions: React.FC<ActionsProps> = ({
}, [resumeConnector, clusterName, connectName, connectorName]);
return (
<div className="buttons">
<ConnectorActionsWrapperStyled>
{connectorStatus === ConnectorState.RUNNING && (
<button
<Button
buttonSize="M"
buttonType="primary"
type="button"
className="button"
onClick={pauseConnectorHandler}
disabled={isConnectorActionRunning}
>
<span className="icon">
<span>
<i className="fas fa-pause" />
</span>
<span>Pause</span>
</button>
</Button>
)}
{connectorStatus === ConnectorState.PAUSED && (
<button
<Button
buttonSize="M"
buttonType="primary"
type="button"
className="button"
onClick={resumeConnectorHandler}
disabled={isConnectorActionRunning}
>
<span className="icon">
<span>
<i className="fas fa-play" />
</span>
<span>Resume</span>
</button>
</Button>
)}
<button
<Button
buttonSize="M"
buttonType="primary"
type="button"
className="button"
onClick={restartConnectorHandler}
disabled={isConnectorActionRunning}
>
<span className="icon">
<span>
<i className="fas fa-sync-alt" />
</span>
<span>Restart all tasks</span>
</button>
</Button>
<Button
buttonSize="M"
buttonType="primary"
type="button"
isLink
disabled={isConnectorActionRunning}
to={clusterConnectConnectorEditPath(
clusterName,
connectName,
connectorName
)}
>
<span>
<i className="fas fa-pencil-alt" />
</span>
<span>Edit config</span>
</Button>
{isConnectorActionRunning ? (
<button type="button" className="button" disabled>
<span className="icon">
<i className="fas fa-edit" />
</span>
<span>Edit config</span>
</button>
) : (
<Link
to={clusterConnectConnectorEditPath(
clusterName,
connectName,
connectorName
)}
className="button"
>
<span className="icon">
<i className="fas fa-pencil-alt" />
</span>
<span>Edit config</span>
</Link>
)}
<button
className="button is-danger"
<Button
buttonSize="M"
buttonType="secondary"
type="button"
onClick={() => setIsDeleteConnectorConfirmationVisible(true)}
disabled={isConnectorActionRunning}
>
<span className="icon">
<span>
<i className="far fa-trash-alt" />
</span>
<span>Delete</span>
</button>
</Button>
<ConfirmationModal
isOpen={isDeleteConnectorConfirmationVisible}
onCancel={() => setIsDeleteConnectorConfirmationVisible(false)}
@ -161,7 +166,7 @@ const Actions: React.FC<ActionsProps> = ({
>
Are you sure you want to remove <b>{connectorName}</b> connector?
</ConfirmationModal>
</div>
</ConnectorActionsWrapperStyled>
);
};

View file

@ -10,6 +10,8 @@ import Actions, {
} from 'components/Connect/Details/Actions/Actions';
import { ConnectorState } from 'generated-sources';
import { ConfirmationModalProps } from 'components/common/ConfirmationModal/ConfirmationModal';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
const mockHistoryPush = jest.fn();
jest.mock('react-router-dom', () => ({
@ -38,21 +40,23 @@ describe('Actions', () => {
const connectorName = 'my-connector';
const setupWrapper = (props: Partial<ActionsProps> = {}) => (
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<Actions
deleteConnector={jest.fn()}
isConnectorDeleting={false}
connectorStatus={ConnectorState.RUNNING}
restartConnector={jest.fn()}
pauseConnector={jest.fn()}
resumeConnector={jest.fn()}
isConnectorActionRunning={false}
{...props}
/>
</TestRouterWrapper>
<ThemeProvider theme={theme}>
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<Actions
deleteConnector={jest.fn()}
isConnectorDeleting={false}
connectorStatus={ConnectorState.RUNNING}
restartConnector={jest.fn()}
pauseConnector={jest.fn()}
resumeConnector={jest.fn()}
isConnectorActionRunning={false}
{...props}
/>
</TestRouterWrapper>
</ThemeProvider>
);
it('matches snapshot', () => {

View file

@ -8,6 +8,8 @@ import {
} from 'redux/interfaces';
import PageLoader from 'components/common/PageLoader/PageLoader';
import JSONEditor from 'components/common/JSONEditor/JSONEditor';
import styled from 'styled-components';
import { Colors } from 'theme/theme';
interface RouterParams {
clusterName: ClusterName;
@ -26,6 +28,13 @@ export interface ConfigProps {
config: ConnectorConfig | null;
}
const ConnectConfigWrapper = styled.div`
padding: 16px;
margin: 16px;
border: 1px solid ${Colors.neutral[10]};
border-radius: 8px;
`;
const Config: React.FC<ConfigProps> = ({
fetchConfig,
isConfigFetching,
@ -44,13 +53,14 @@ const Config: React.FC<ConfigProps> = ({
if (!config) return null;
return (
<JSONEditor
readOnly
value={JSON.stringify(config, null, '\t')}
showGutter={false}
highlightActiveLine={false}
isFixedHeight
/>
<ConnectConfigWrapper>
<JSONEditor
readOnly
value={JSON.stringify(config, null, '\t')}
highlightActiveLine={false}
isFixedHeight
/>
</ConnectConfigWrapper>
);
};

View file

@ -1,18 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Config view matches snapshot 1`] = `
<mock-JSONEditor
highlightActiveLine={false}
isFixedHeight={true}
readOnly={true}
showGutter={false}
value="{
.c0 {
padding: 16px;
margin: 16px;
border: 1px solid #E3E6E8;
border-radius: 8px;
}
<div
className="c0"
>
<mock-JSONEditor
highlightActiveLine={false}
isFixedHeight={true}
readOnly={true}
value="{
\\"connector.class\\": \\"FileStreamSource\\",
\\"tasks.max\\": \\"10\\",
\\"topic\\": \\"test-topic\\",
\\"file\\": \\"/some/file\\"
}"
/>
/>
</div>
`;
exports[`Config view matches snapshot when fetching config 1`] = `<mock-PageLoader />`;

View file

@ -8,6 +8,8 @@ import {
clusterConnectConnectorTasksPath,
} from 'lib/paths';
import PageLoader from 'components/common/PageLoader/PageLoader';
import Navbar from 'components/common/Navigation/Navbar.styled';
import PageHeading from 'components/common/PageHeading/PageHeading';
import OverviewContainer from './Overview/OverviewContainer';
import TasksContainer from './Tasks/TasksContainer';
@ -61,50 +63,45 @@ const Details: React.FC<DetailsProps> = ({
if (!connector) return null;
return (
<div className="box">
<nav className="navbar mb-4" role="navigation">
<div className="navbar-start tabs mb-0">
<NavLink
exact
to={clusterConnectConnectorPath(
clusterName,
connectName,
connectorName
)}
className="navbar-item is-tab"
activeClassName="is-active"
>
Overview
</NavLink>
<NavLink
exact
to={clusterConnectConnectorTasksPath(
clusterName,
connectName,
connectorName
)}
className="navbar-item is-tab"
activeClassName="is-active"
>
Tasks
</NavLink>
<NavLink
exact
to={clusterConnectConnectorConfigPath(
clusterName,
connectName,
connectorName
)}
className="navbar-item is-tab"
activeClassName="is-active"
>
Config
</NavLink>
</div>
<div className="navbar-end">
<ActionsContainer />
</div>
</nav>
<div>
<PageHeading text={connectorName}>
<ActionsContainer />
</PageHeading>
<Navbar role="navigation">
<NavLink
exact
to={clusterConnectConnectorPath(
clusterName,
connectName,
connectorName
)}
activeClassName="is-active"
>
Overview
</NavLink>
<NavLink
exact
to={clusterConnectConnectorTasksPath(
clusterName,
connectName,
connectorName
)}
activeClassName="is-active"
>
Tasks
</NavLink>
<NavLink
exact
to={clusterConnectConnectorConfigPath(
clusterName,
connectName,
connectorName
)}
activeClassName="is-active"
>
Config
</NavLink>
</Navbar>
<Switch>
<Route
exact

View file

@ -1,6 +1,7 @@
import React from 'react';
import { Connector } from 'generated-sources';
import ConnectorStatusTag from 'components/Connect/ConnectorStatusTag';
import TagStyled from 'components/common/Tag/Tag.styled';
import * as Metrics from 'components/common/Metrics';
export interface OverviewProps {
connector: Connector | null;
@ -16,42 +17,30 @@ const Overview: React.FC<OverviewProps> = ({
if (!connector) return null;
return (
<div className="tile is-6">
<table className="table is-fullwidth">
<tbody>
{connector.status?.workerId && (
<tr>
<th>Worker</th>
<td>{connector.status.workerId}</td>
</tr>
)}
<tr>
<th>Type</th>
<td>{connector.type}</td>
</tr>
{connector.config['connector.class'] && (
<tr>
<th>Class</th>
<td>{connector.config['connector.class']}</td>
</tr>
)}
<tr>
<th>State</th>
<td>
<ConnectorStatusTag status={connector.status.state} />
</td>
</tr>
<tr>
<th>Tasks Running</th>
<td>{runningTasksCount}</td>
</tr>
<tr>
<th>Tasks Failed</th>
<td>{failedTasksCount}</td>
</tr>
</tbody>
</table>
</div>
<Metrics.Wrapper>
<Metrics.Section>
{connector.status?.workerId && (
<Metrics.Indicator label="Worker">
{connector.status.workerId}
</Metrics.Indicator>
)}
<Metrics.Indicator label="Type">{connector.type}</Metrics.Indicator>
{connector.config['connector.class'] && (
<Metrics.Indicator label="Class">
{connector.config['connector.class']}
</Metrics.Indicator>
)}
<Metrics.Indicator label="State">
<TagStyled color="yellow">{connector.status.state}</TagStyled>
</Metrics.Indicator>
<Metrics.Indicator label="Tasks running">
{runningTasksCount}
</Metrics.Indicator>
<Metrics.Indicator label="Tasks failed" isAlert>
{failedTasksCount}
</Metrics.Indicator>
</Metrics.Section>
</Metrics.Wrapper>
);
};

View file

@ -7,20 +7,22 @@ import Overview, {
OverviewProps,
} from 'components/Connect/Details/Overview/Overview';
import { connector } from 'redux/reducers/connect/__test__/fixtures';
jest.mock('components/Connect/StatusTag', () => 'mock-StatusTag');
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
describe('Overview', () => {
containerRendersView(<OverviewContainer />, Overview);
describe('view', () => {
const setupWrapper = (props: Partial<OverviewProps> = {}) => (
<Overview
connector={connector}
runningTasksCount={10}
failedTasksCount={2}
{...props}
/>
<ThemeProvider theme={theme}>
<Overview
connector={connector}
runningTasksCount={10}
failedTasksCount={2}
{...props}
/>
</ThemeProvider>
);
it('matches snapshot', () => {
@ -30,7 +32,7 @@ describe('Overview', () => {
it('is empty when no connector', () => {
const wrapper = mount(setupWrapper({ connector: null }));
expect(wrapper.html()).toBeNull();
expect(wrapper.html()).toEqual('');
});
});
});

View file

@ -1,66 +1,224 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Overview view matches snapshot 1`] = `
.c5 {
border: none;
border-radius: 16px;
height: 20px;
line-height: 20px;
background-color: #FFEECC;
color: #171A1C;
font-size: 12px;
display: inline-block;
padding-left: 0.75em;
padding-right: 0.75em;
text-align: center;
}
.c0 {
padding: 1.5rem 1rem;
background: #F1F2F3;
margin-bottom: 0.5rem !important;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: 16px;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.c3 {
background-color: #FFFFFF;
height: 68px;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
min-width: 150px;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: flex-start;
-webkit-box-align: flex-start;
-ms-flex-align: flex-start;
align-items: flex-start;
padding: 12px 16px;
box-shadow: 3px 3px 3px rgba(0,0,0,0.08);
margin: 0 0 3px 0;
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
-ms-flex-positive: 1;
flex-grow: 1;
}
.c4 {
font-weight: 500;
font-size: 12px;
color: #73848C;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: 10px;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: 2px;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.c1 > .c2:first-child {
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.c1 > .c2:last-child {
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
@media screen and (max-width:1023px) {
.c1 > .c2:first-child,
.c1 > .c2:last-child {
border-radius: 0;
}
}
<div
className="tile is-6"
className="c0"
>
<table
className="table is-fullwidth"
>
<tbody>
<tr>
<th>
Worker
</th>
<td>
kafka-connect0:8083
</td>
</tr>
<tr>
<th>
Type
</th>
<td>
SOURCE
</td>
</tr>
<tr>
<th>
Class
</th>
<td>
FileStreamSource
</td>
</tr>
<tr>
<th>
State
</th>
<td>
<span
className="tag is-success"
<div>
<div
className="c1"
>
<div
className="c2 c3"
>
<div>
<div
className="c4"
>
RUNNING
Worker
</div>
<span>
kafka-connect0:8083
</span>
</td>
</tr>
<tr>
<th>
Tasks Running
</th>
<td>
10
</td>
</tr>
<tr>
<th>
Tasks Failed
</th>
<td>
2
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
className="c2 c3"
>
<div>
<div
className="c4"
>
Type
</div>
<span>
SOURCE
</span>
</div>
</div>
<div
className="c2 c3"
>
<div>
<div
className="c4"
>
Class
</div>
<span>
FileStreamSource
</span>
</div>
</div>
<div
className="c2 c3"
>
<div>
<div
className="c4"
>
State
</div>
<span>
<p
className="c5"
>
RUNNING
</p>
</span>
</div>
</div>
<div
className="c2 c3"
>
<div>
<div
className="c4"
>
Tasks running
</div>
<span>
10
</span>
</div>
</div>
<div
className="c2 c3"
>
<div>
<div
className="c4"
>
Tasks failed
<svg
fill="none"
height="4"
viewBox="0 0 4 4"
width="4"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="2"
cy="2"
fill="#E61A1A"
r="2"
/>
</svg>
</div>
<span>
2
</span>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -2,7 +2,10 @@ import React from 'react';
import { useParams } from 'react-router-dom';
import { Task, TaskId } from 'generated-sources';
import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
import StatusTag from 'components/Connect/StatusTag';
import Dropdown from 'components/common/Dropdown/Dropdown';
import DropdownItem from 'components/common/Dropdown/DropdownItem';
import VerticalElipsisIcon from 'components/common/Icons/VerticalElipsisIcon';
import TagStyled from 'components/common/Tag/Tag.styled';
interface RouterParams {
clusterName: ClusterName;
@ -22,33 +25,27 @@ export interface ListItemProps {
const ListItem: React.FC<ListItemProps> = ({ task, restartTask }) => {
const { clusterName, connectName, connectorName } = useParams<RouterParams>();
const [restarting, setRestarting] = React.useState(false);
const restartTaskHandler = React.useCallback(async () => {
setRestarting(true);
await restartTask(clusterName, connectName, connectorName, task.id?.task);
setRestarting(false);
}, [restartTask, clusterName, connectName, connectorName, task.id?.task]);
return (
<tr>
<td className="has-text-overflow-ellipsis">{task.status?.id}</td>
<td>{task.status?.id}</td>
<td>{task.status?.workerId}</td>
<td>
<StatusTag status={task.status.state} />
<TagStyled color="yellow">{task.status.state}</TagStyled>
</td>
<td>{task.status.trace}</td>
<td>
<button
type="button"
className="button is-small is-pulled-right"
onClick={restartTaskHandler}
disabled={restarting}
>
<span className="icon">
<i className="fas fa-sync-alt" />
</span>
</button>
<td>{task.status.trace || 'null'}</td>
<td style={{ width: '5%' }}>
<div>
<Dropdown label={<VerticalElipsisIcon />} right>
<DropdownItem onClick={restartTaskHandler}>
<span>Clear Messages</span>
</DropdownItem>
</Dropdown>
</div>
</td>
</tr>
);

View file

@ -9,8 +9,8 @@ import ListItem, {
ListItemProps,
} from 'components/Connect/Details/Tasks/ListItem/ListItem';
import { tasks } from 'redux/reducers/connect/__test__/fixtures';
jest.mock('components/Connect/StatusTag', () => 'mock-StatusTag');
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
describe('ListItem', () => {
containerRendersView(
@ -33,16 +33,18 @@ describe('ListItem', () => {
const connectorName = 'my-connector';
const setupWrapper = (props: Partial<ListItemProps> = {}) => (
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<table>
<tbody>
<ListItem task={tasks[0]} restartTask={jest.fn()} {...props} />
</tbody>
</table>
</TestRouterWrapper>
<ThemeProvider theme={theme}>
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<table>
<tbody>
<ListItem task={tasks[0]} restartTask={jest.fn()} {...props} />
</tbody>
</table>
</TestRouterWrapper>
</ThemeProvider>
);
it('matches snapshot', () => {
@ -54,7 +56,10 @@ describe('ListItem', () => {
const restartTask = jest.fn();
const wrapper = mount(setupWrapper({ restartTask }));
await act(async () => {
wrapper.find('button').simulate('click');
wrapper.find('svg').simulate('click');
});
await act(async () => {
wrapper.find('span').simulate('click');
});
expect(restartTask).toHaveBeenCalledTimes(1);
expect(restartTask).toHaveBeenCalledWith(

View file

@ -1,38 +1,128 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ListItem view matches snapshot 1`] = `
.c2 {
background: transparent;
border: none;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: 'center';
-webkit-box-align: 'center';
-ms-flex-align: 'center';
align-items: 'center';
-webkit-box-pack: 'center';
-webkit-justify-content: 'center';
-ms-flex-pack: 'center';
justify-content: 'center';
}
.c2:hover {
cursor: pointer;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-self: center;
-ms-flex-item-align: center;
align-self: center;
}
.c0 {
border: none;
border-radius: 16px;
height: 20px;
line-height: 20px;
background-color: #FFEECC;
color: #171A1C;
font-size: 12px;
display: inline-block;
padding-left: 0.75em;
padding-right: 0.75em;
text-align: center;
}
<table>
<tbody>
<tr>
<td
className="has-text-overflow-ellipsis"
>
<td>
1
</td>
<td>
kafka-connect0:8083
</td>
<td>
<mock-StatusTag
status="RUNNING"
/>
</td>
<td />
<td>
<button
className="button is-small is-pulled-right"
disabled={false}
onClick={[Function]}
type="button"
<p
className="c0"
>
<span
className="icon"
RUNNING
</p>
</td>
<td>
null
</td>
<td
style={
Object {
"width": "5%",
}
}
>
<div>
<div
className="dropdown is-right"
>
<i
className="fas fa-sync-alt"
/>
</span>
</button>
<div
className="c1"
>
<button
aria-controls="dropdown-menu"
aria-haspopup="true"
className="c2"
onClick={[Function]}
type="button"
>
<svg
fill="none"
height="16"
viewBox="0 0 4 16"
width="4"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 4C3.1 4 4 3.1 4 2C4 0.9 3.1 0 2 0C0.9 0 0 0.9 0 2C0 3.1 0.9 4 2 4ZM2 6C0.9 6 0 6.9 0 8C0 9.1 0.9 10 2 10C3.1 10 4 9.1 4 8C4 6.9 3.1 6 2 6ZM2 12C0.9 12 0 12.9 0 14C0 15.1 0.9 16 2 16C3.1 16 4 15.1 4 14C4 12.9 3.1 12 2 12Z"
fill="#73848C"
/>
</svg>
</button>
</div>
<div
className="dropdown-menu"
id="dropdown-menu"
role="menu"
>
<div
className="dropdown-content has-text-left"
>
<a
className="dropdown-item is-link"
href="#end"
onClick={[Function]}
role="menuitem"
type="button"
>
<span>
Clear Messages
</span>
</a>
</div>
</div>
</div>
</div>
</td>
</tr>
</tbody>

View file

@ -3,6 +3,8 @@ import { useParams } from 'react-router';
import { Task } from 'generated-sources';
import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
import PageLoader from 'components/common/PageLoader/PageLoader';
import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import ListItemContainer from './ListItem/ListItemContainer';
@ -39,16 +41,14 @@ const Tasks: React.FC<TasksProps> = ({
}
return (
<table className="table is-fullwidth">
<Table isFullwidth>
<thead>
<tr>
<th>ID</th>
<th>Worker</th>
<th>State</th>
<th>Trace</th>
<th>
<span className="is-pulled-right">Restart</span>
</th>
<TableHeaderCell title="ID" />
<TableHeaderCell title="Worker" />
<TableHeaderCell title="State" />
<TableHeaderCell title="Trace" />
<TableHeaderCell />
</tr>
</thead>
<tbody>
@ -61,7 +61,7 @@ const Tasks: React.FC<TasksProps> = ({
<ListItemContainer key={task.status?.id} task={task} />
))}
</tbody>
</table>
</Table>
);
};

View file

@ -6,6 +6,8 @@ import { clusterConnectConnectorTasksPath } from 'lib/paths';
import TasksContainer from 'components/Connect/Details/Tasks/TasksContainer';
import Tasks, { TasksProps } from 'components/Connect/Details/Tasks/Tasks';
import { tasks } from 'redux/reducers/connect/__test__/fixtures';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
jest.mock('components/common/PageLoader/PageLoader', () => 'mock-PageLoader');
@ -28,17 +30,19 @@ describe('Tasks', () => {
const connectorName = 'my-connector';
const setupWrapper = (props: Partial<TasksProps> = {}) => (
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<Tasks
fetchTasks={jest.fn()}
areTasksFetching={false}
tasks={tasks}
{...props}
/>
</TestRouterWrapper>
<ThemeProvider theme={theme}>
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<Tasks
fetchTasks={jest.fn()}
areTasksFetching={false}
tasks={tasks}
{...props}
/>
</TestRouterWrapper>
</ThemeProvider>
);
it('matches snapshot', () => {

View file

@ -1,30 +1,117 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Tasks view matches snapshot 1`] = `
.c0 {
width: 100%;
}
.c0 td {
border-top: 1px #f1f2f3 solid;
font-size: 14px;
font-weight: 400;
padding: 8px 8px 8px 24px;
color: #171A1C;
vertical-align: middle;
}
.c0 tbody > tr:hover {
background-color: #F1F2F3;
}
.c1 {
padding: 4px 0 4px 24px !important;
border-bottom-width: 1px !important;
vertical-align: middle !important;
}
.c1.is-clickable {
cursor: pointer !important;
pointer-events: all !important;
}
.c1.has-text-link-dark span {
color: #4F4FFF !important;
}
.c1 span {
font-family: Inter,sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
-webkit-letter-spacing: 0em;
-moz-letter-spacing: 0em;
-ms-letter-spacing: 0em;
letter-spacing: 0em;
text-align: left;
background: #FFFFFF;
color: #73848C;
}
.c1 span.preview {
margin-left: 8px;
font-size: 14px;
color: #4F4FFF;
cursor: pointer;
}
.c1 span.is-clickable {
cursor: pointer !important;
pointer-events: all !important;
}
<table
className="table is-fullwidth"
className="c0"
>
<thead>
<tr>
<th>
ID
</th>
<th>
Worker
</th>
<th>
State
</th>
<th>
Trace
</th>
<th>
<th
className="c1"
title="ID"
>
<span
className="is-pulled-right"
className="title"
>
Restart
ID
</span>
</th>
<th
className="c1"
title="Worker"
>
<span
className="title"
>
Worker
</span>
</th>
<th
className="c1"
title="State"
>
<span
className="title"
>
State
</span>
</th>
<th
className="c1"
title="Trace"
>
<span
className="title"
>
Trace
</span>
</th>
<th
className="c1"
>
<span
className="title"
/>
</th>
</tr>
</thead>
<tbody>
@ -99,30 +186,117 @@ exports[`Tasks view matches snapshot 1`] = `
exports[`Tasks view matches snapshot when fetching tasks 1`] = `<mock-PageLoader />`;
exports[`Tasks view matches snapshot when no tasks 1`] = `
.c0 {
width: 100%;
}
.c0 td {
border-top: 1px #f1f2f3 solid;
font-size: 14px;
font-weight: 400;
padding: 8px 8px 8px 24px;
color: #171A1C;
vertical-align: middle;
}
.c0 tbody > tr:hover {
background-color: #F1F2F3;
}
.c1 {
padding: 4px 0 4px 24px !important;
border-bottom-width: 1px !important;
vertical-align: middle !important;
}
.c1.is-clickable {
cursor: pointer !important;
pointer-events: all !important;
}
.c1.has-text-link-dark span {
color: #4F4FFF !important;
}
.c1 span {
font-family: Inter,sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
-webkit-letter-spacing: 0em;
-moz-letter-spacing: 0em;
-ms-letter-spacing: 0em;
letter-spacing: 0em;
text-align: left;
background: #FFFFFF;
color: #73848C;
}
.c1 span.preview {
margin-left: 8px;
font-size: 14px;
color: #4F4FFF;
cursor: pointer;
}
.c1 span.is-clickable {
cursor: pointer !important;
pointer-events: all !important;
}
<table
className="table is-fullwidth"
className="c0"
>
<thead>
<tr>
<th>
ID
</th>
<th>
Worker
</th>
<th>
State
</th>
<th>
Trace
</th>
<th>
<th
className="c1"
title="ID"
>
<span
className="is-pulled-right"
className="title"
>
Restart
ID
</span>
</th>
<th
className="c1"
title="Worker"
>
<span
className="title"
>
Worker
</span>
</th>
<th
className="c1"
title="State"
>
<span
className="title"
>
State
</span>
</th>
<th
className="c1"
title="Trace"
>
<span
className="title"
>
Trace
</span>
</th>
<th
className="c1"
>
<span
className="title"
/>
</th>
</tr>
</thead>
<tbody>

View file

@ -6,6 +6,8 @@ import { clusterConnectConnectorPath } from 'lib/paths';
import DetailsContainer from 'components/Connect/Details/DetailsContainer';
import Details, { DetailsProps } from 'components/Connect/Details/Details';
import { connector, tasks } from 'redux/reducers/connect/__test__/fixtures';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
jest.mock('components/common/PageLoader/PageLoader', () => 'mock-PageLoader');
@ -43,20 +45,22 @@ describe('Details', () => {
const connectorName = 'my-connector';
const setupWrapper = (props: Partial<DetailsProps> = {}) => (
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<Details
fetchConnector={jest.fn()}
fetchTasks={jest.fn()}
isConnectorFetching={false}
areTasksFetching={false}
connector={connector}
tasks={tasks}
{...props}
/>
</TestRouterWrapper>
<ThemeProvider theme={theme}>
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<Details
fetchConnector={jest.fn()}
fetchTasks={jest.fn()}
isConnectorFetching={false}
areTasksFetching={false}
connector={connector}
tasks={tasks}
{...props}
/>
</TestRouterWrapper>
</ThemeProvider>
);
it('matches snapshot', () => {

View file

@ -1,47 +1,115 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Details view matches snapshot 1`] = `
<div
className="box"
>
<nav
className="navbar mb-4"
role="navigation"
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
border-bottom: 1px #E3E6E8 solid;
}
.c1 a {
height: 40px;
width: 96px;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
font-weight: 500;
font-size: 14px;
color: #73848C;
border-bottom: 1px transparent solid;
}
.c1 a.is-active {
border-bottom: 1px #4F4FFF solid;
color: #171A1C;
}
.c1 a:hover:not(.is-active) {
border-bottom: 1px transparent solid;
color: #171A1C;
}
.c0 {
height: 56px;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 0px 16px;
}
.c0 h1 {
font-size: 24px;
font-weight: 500;
line-height: 32px;
color: #000;
}
.c0 > div {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: 16px;
}
<div>
<div
className="c0"
>
<div
className="navbar-start tabs mb-0"
>
<a
aria-current="page"
className="navbar-item is-tab is-active"
href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector"
onClick={[Function]}
style={Object {}}
>
Overview
</a>
<a
aria-current={null}
className="navbar-item is-tab"
href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/tasks"
onClick={[Function]}
>
Tasks
</a>
<a
aria-current={null}
className="navbar-item is-tab"
href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/config"
onClick={[Function]}
>
Config
</a>
</div>
<div
className="navbar-end"
>
<h1>
my-connector
</h1>
<div>
<mock-ActionsContainer />
</div>
</div>
<nav
className="c1"
role="navigation"
>
<a
aria-current="page"
className="is-active"
href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector"
onClick={[Function]}
style={Object {}}
>
Overview
</a>
<a
aria-current={null}
href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/tasks"
onClick={[Function]}
>
Tasks
</a>
<a
aria-current={null}
href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/config"
onClick={[Function]}
>
Config
</a>
</nav>
<mock-OverviewContainer
history={

View file

@ -0,0 +1,20 @@
import styled from 'styled-components';
import { Colors } from 'theme/theme';
export const ConnectEditWrapperStyled = styled.div`
margin: 16px;
& form > *:last-child {
margin-top: 16px;
}
`;
export const ConnectEditWarningMessageStyled = styled.div`
height: 48px;
display: flex;
align-items: center;
background-color: ${Colors.yellow[10]};
border-radius: 8px;
padding: 8px;
margin-bottom: 16px;
`;

View file

@ -14,6 +14,12 @@ import { clusterConnectConnectorConfigPath } from 'lib/paths';
import yup from 'lib/yupExtended';
import JSONEditor from 'components/common/JSONEditor/JSONEditor';
import PageLoader from 'components/common/PageLoader/PageLoader';
import { Button } from 'components/common/Button/Button';
import {
ConnectEditWarningMessageStyled,
ConnectEditWrapperStyled,
} from './Edit.styled';
const validationSchema = yup.object().shape({
config: yup.string().required().isJsonObject(),
@ -103,41 +109,36 @@ const Edit: React.FC<EditProps> = ({
'"******"'
);
return (
<>
<ConnectEditWrapperStyled>
{hasCredentials && (
<div className="notification is-danger is-light">
<ConnectEditWarningMessageStyled>
Please replace ****** with the real credential values to avoid
accidentally breaking your connector config!
</div>
</ConnectEditWarningMessageStyled>
)}
<div className="box">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="field">
<div className="control">
<Controller
control={control}
name="config"
render={({ field }) => (
<JSONEditor {...field} readOnly={isSubmitting} />
)}
/>
</div>
<p className="help is-danger">
<ErrorMessage errors={errors} name="config" />
</p>
</div>
<div className="field">
<div className="control">
<input
type="submit"
className="button is-primary"
disabled={!isValid || isSubmitting || !isDirty}
/>
</div>
</div>
</form>
</div>
</>
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Controller
control={control}
name="config"
render={({ field }) => (
<JSONEditor {...field} readOnly={isSubmitting} />
)}
/>
</div>
<div>
<ErrorMessage errors={errors} name="config" />
</div>
<Button
buttonSize="M"
buttonType="primary"
type="submit"
disabled={!isValid || isSubmitting || !isDirty}
>
Submit
</Button>
</form>
</ConnectEditWrapperStyled>
);
};

View file

@ -10,6 +10,8 @@ import {
import EditContainer from 'components/Connect/Edit/EditContainer';
import Edit, { EditProps } from 'components/Connect/Edit/Edit';
import { connector } from 'redux/reducers/connect/__test__/fixtures';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
jest.mock('components/common/PageLoader/PageLoader', () => 'mock-PageLoader');
@ -37,18 +39,20 @@ describe('Edit', () => {
const connectorName = 'my-connector';
const setupWrapper = (props: Partial<EditProps> = {}) => (
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<Edit
fetchConfig={jest.fn()}
isConfigFetching={false}
config={connector.config}
updateConfig={jest.fn()}
{...props}
/>
</TestRouterWrapper>
<ThemeProvider theme={theme}>
<TestRouterWrapper
pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }}
>
<Edit
fetchConfig={jest.fn()}
isConfigFetching={false}
config={connector.config}
updateConfig={jest.fn()}
{...props}
/>
</TestRouterWrapper>
</ThemeProvider>
);
it('matches snapshot', () => {

View file

@ -1,105 +1,207 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Edit view matches snapshot 1`] = `
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
padding: 0px 12px;
border: none;
border-radius: 4px;
white-space: nowrap;
background: #4F4FFF;
color: #FFFFFF;
font-size: 14px;
height: 32px;
}
.c1:hover:enabled {
background: #1717CF;
color: #FFFFFF;
cursor: pointer;
}
.c1:active:enabled {
background: #1414B8;
color: #FFFFFF;
}
.c1:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.c1 a {
color: white;
}
.c1 i {
margin-right: 7px;
}
.c0 {
margin: 16px;
}
.c0 form > *:last-child {
margin-top: 16px;
}
<div
className="box"
className="c0"
>
<form
onSubmit={[Function]}
>
<div
className="field"
>
<div
className="control"
>
<mock-JSONEditor
name="config"
onBlur={[Function]}
onChange={[Function]}
readOnly={false}
value="{
<div>
<mock-JSONEditor
name="config"
onBlur={[Function]}
onChange={[Function]}
readOnly={false}
value="{
\\"connector.class\\": \\"FileStreamSource\\",
\\"tasks.max\\": \\"10\\",
\\"topic\\": \\"test-topic\\",
\\"file\\": \\"/some/file\\"
}"
/>
</div>
<p
className="help is-danger"
/>
</div>
<div
className="field"
<div />
<button
className="c1"
disabled={true}
type="submit"
>
<div
className="control"
>
<input
className="button is-primary"
disabled={true}
type="submit"
/>
</div>
</div>
Submit
</button>
</form>
</div>
`;
exports[`Edit view matches snapshot when config has credentials 1`] = `
Array [
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
padding: 0px 12px;
border: none;
border-radius: 4px;
white-space: nowrap;
background: #4F4FFF;
color: #FFFFFF;
font-size: 14px;
height: 32px;
}
.c2:hover:enabled {
background: #1717CF;
color: #FFFFFF;
cursor: pointer;
}
.c2:active:enabled {
background: #1414B8;
color: #FFFFFF;
}
.c2:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.c2 a {
color: white;
}
.c2 i {
margin-right: 7px;
}
.c0 {
margin: 16px;
}
.c0 form > *:last-child {
margin-top: 16px;
}
.c1 {
height: 48px;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
background-color: #FFEECC;
border-radius: 8px;
padding: 8px;
margin-bottom: 16px;
}
<div
className="c0"
>
<div
className="notification is-danger is-light"
className="c1"
>
Please replace ****** with the real credential values to avoid accidentally breaking your connector config!
</div>,
<div
className="box"
</div>
<form
onSubmit={[Function]}
>
<form
onSubmit={[Function]}
>
<div
className="field"
>
<div
className="control"
>
<mock-JSONEditor
name="config"
onBlur={[Function]}
onChange={[Function]}
readOnly={false}
value="{
<div>
<mock-JSONEditor
name="config"
onBlur={[Function]}
onChange={[Function]}
readOnly={false}
value="{
\\"connector.class\\": \\"FileStreamSource\\",
\\"tasks.max\\": \\"10\\",
\\"topic\\": \\"test-topic\\",
\\"file\\": \\"/some/file\\",
\\"password\\": \\"******\\"
}"
/>
</div>
<p
className="help is-danger"
/>
</div>
<div
className="field"
>
<div
className="control"
>
<input
className="button is-primary"
disabled={true}
type="submit"
/>
</div>
</div>
</form>
</div>,
]
/>
</div>
<div />
<button
className="c2"
disabled={true}
type="submit"
>
Submit
</button>
</form>
</div>
`;
exports[`Edit view matches snapshot when fetching config 1`] = `<mock-PageLoader />`;

View file

@ -1,13 +1,17 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { Connect, FullConnectorInfo } from 'generated-sources';
import { ClusterName, ConnectorSearch } from 'redux/interfaces';
import { clusterConnectorNewPath } from 'lib/paths';
import ClusterContext from 'components/contexts/ClusterContext';
import Indicator from 'components/common/Dashboard/Indicator';
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
import PageLoader from 'components/common/PageLoader/PageLoader';
import Search from 'components/common/Search/Search';
import * as Metrics from 'components/common/Metrics';
import PageHeading from 'components/common/PageHeading/PageHeading';
import { Button } from 'components/common/Button/Button';
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import ListItem from './ListItem';
@ -47,50 +51,51 @@ const List: React.FC<ListProps> = ({
return (
<>
<MetricsWrapper>
<Indicator
className="level-left is-one-third"
label="Connects"
title="Connects"
fetching={areConnectsFetching}
>
{connectors.length}
</Indicator>
<div className="column">
<Search
handleSearch={handleSearch}
placeholder="Search by Connect Name, Status or Type"
value={search}
/>
</div>
<PageHeading text="Connectors">
{!isReadOnly && (
<div className="level-item level-right">
<Link
className="button is-primary"
to={clusterConnectorNewPath(clusterName)}
>
Create Connector
</Link>
</div>
<Button
isLink
buttonType="primary"
buttonSize="M"
to={clusterConnectorNewPath(clusterName)}
>
Create Connector
</Button>
)}
</MetricsWrapper>
</PageHeading>
<Metrics.Wrapper>
<Metrics.Section>
<Metrics.Indicator
label="Connects"
title="Connects"
fetching={areConnectsFetching}
>
{connectors.length}
</Metrics.Indicator>
</Metrics.Section>
</Metrics.Wrapper>
<ControlPanelWrapper hasInput>
<Search
handleSearch={handleSearch}
placeholder="Search by Connect Name, Status or Type"
value={search}
/>
</ControlPanelWrapper>
{areConnectorsFetching ? (
<PageLoader />
) : (
<div className="box">
<table className="table is-fullwidth">
<div>
<Table isFullwidth>
<thead>
<tr>
<th>Name</th>
<th>Connect</th>
<th>Type</th>
<th>Plugin</th>
<th>Topics</th>
<th>Status</th>
<th>Running Tasks</th>
<th> </th>
<TableHeaderCell title="Name" />
<TableHeaderCell title="Connect" />
<TableHeaderCell title="Type" />
<TableHeaderCell title="Plugin" />
<TableHeaderCell title="Topics" />
<TableHeaderCell title="Status" />
<TableHeaderCell title="Running Tasks" />
<TableHeaderCell> </TableHeaderCell>
</tr>
</thead>
<tbody>
@ -109,7 +114,7 @@ const List: React.FC<ListProps> = ({
/>
))}
</tbody>
</table>
</Table>
</div>
)}
</>

View file

@ -1,5 +1,4 @@
import React from 'react';
import cx from 'classnames';
import { FullConnectorInfo } from 'generated-sources';
import { clusterConnectConnectorPath, clusterTopicPath } from 'lib/paths';
import { ClusterName } from 'redux/interfaces';
@ -10,13 +9,22 @@ import Dropdown from 'components/common/Dropdown/Dropdown';
import DropdownDivider from 'components/common/Dropdown/DropdownDivider';
import DropdownItem from 'components/common/Dropdown/DropdownItem';
import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
import ConnectorStatusTag from 'components/Connect/ConnectorStatusTag';
import TagStyled from 'components/common/Tag/Tag.styled';
import { TableKeyLink } from 'components/common/table/Table/TableKeyLink.styled';
import VerticalElipsisIcon from 'components/common/Icons/VerticalElipsisIcon';
import { Colors } from 'theme/theme';
import styled from 'styled-components';
export interface ListItemProps {
clusterName: ClusterName;
connector: FullConnectorInfo;
}
const TopicTagsWrapper = styled.div`
display: flex;
flex-wrap: wrap;
`;
const ListItem: React.FC<ListItemProps> = ({
clusterName,
connector: {
@ -50,55 +58,42 @@ const ListItem: React.FC<ListItemProps> = ({
return (
<tr>
<td className="has-text-overflow-ellipsis">
<TableKeyLink>
<NavLink
exact
to={clusterConnectConnectorPath(clusterName, connect, name)}
activeClassName="is-active"
className="title is-6"
>
{name}
</NavLink>
</td>
</TableKeyLink>
<td>{connect}</td>
<td>{type}</td>
<td>{connectorClass}</td>
<td>
<div className="is-flex is-flex-wrap-wrap">
<TopicTagsWrapper>
{topics?.map((t) => (
<span key={t} className="tag is-info is-light mr-1 mb-1">
<TagStyled key={t} color="gray">
<Link to={clusterTopicPath(clusterName, t)}>{t}</Link>
</span>
</TagStyled>
))}
</div>
</TopicTagsWrapper>
</td>
<td>{status && <ConnectorStatusTag status={status.state} />}</td>
<td>{status && <TagStyled color="yellow">{status.state}</TagStyled>}</td>
<td>
{runningTasks && (
<span
className={cx(
failedTasksCount ? 'has-text-danger' : 'has-text-success'
)}
>
<span>
{runningTasks} of {tasksCount}
</span>
)}
</td>
<td>
<div className="has-text-right">
<Dropdown
label={
<span className="icon">
<i className="fas fa-cog" />
</span>
}
right
>
<div>
<Dropdown label={<VerticalElipsisIcon />} right>
<DropdownDivider />
<DropdownItem
onClick={() => setDeleteConnectorConfirmationVisible(true)}
>
<span className="has-text-danger">Remove Connector</span>
<span style={{ color: Colors.red[50] }}>Remove Connector</span>
</DropdownItem>
</Dropdown>
</div>

View file

@ -2,7 +2,7 @@ import React from 'react';
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import { StaticRouter } from 'react-router-dom';
import configureStore from 'redux/store/configureStore';
import { store } from 'redux/store';
import { connectors } from 'redux/reducers/connect/__test__/fixtures';
import ClusterContext, {
ContextProps,
@ -10,18 +10,20 @@ import ClusterContext, {
} from 'components/contexts/ClusterContext';
import ListContainer from 'components/Connect/List/ListContainer';
import List, { ListProps } from 'components/Connect/List/List';
const store = configureStore();
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
describe('Connectors List', () => {
describe('Container', () => {
it('renders view with initial state of storage', () => {
const wrapper = mount(
<Provider store={store}>
<StaticRouter>
<ListContainer />
</StaticRouter>
</Provider>
<ThemeProvider theme={theme}>
<Provider store={store}>
<StaticRouter>
<ListContainer />
</StaticRouter>
</Provider>
</ThemeProvider>
);
expect(wrapper.exists(List)).toBeTruthy();
@ -36,21 +38,25 @@ describe('Connectors List', () => {
props: Partial<ListProps> = {},
contextValue: ContextProps = initialValue
) => (
<StaticRouter>
<ClusterContext.Provider value={contextValue}>
<List
areConnectorsFetching
areConnectsFetching
connectors={[]}
connects={[]}
fetchConnects={fetchConnects}
fetchConnectors={fetchConnectors}
search=""
setConnectorSearch={setConnectorSearch}
{...props}
/>
</ClusterContext.Provider>
</StaticRouter>
<ThemeProvider theme={theme}>
<Provider store={store}>
<StaticRouter>
<ClusterContext.Provider value={contextValue}>
<List
areConnectorsFetching
areConnectsFetching
connectors={[]}
connects={[]}
fetchConnects={fetchConnects}
fetchConnectors={fetchConnectors}
search=""
setConnectorSearch={setConnectorSearch}
{...props}
/>
</ClusterContext.Provider>
</StaticRouter>
</Provider>
</ThemeProvider>
);
it('renders PageLoader', () => {
@ -87,9 +93,7 @@ describe('Connectors List', () => {
const wrapper = mount(
setupComponent({}, { ...initialValue, isReadOnly: false })
);
expect(
wrapper.exists('.level-item.level-right > .button.is-primary')
).toBeTruthy();
expect(wrapper.exists('button')).toBeTruthy();
});
describe('readonly cluster', () => {

View file

@ -3,16 +3,17 @@ import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { connectors } from 'redux/reducers/connect/__test__/fixtures';
import configureStore from 'redux/store/configureStore';
import { store } from 'redux/store';
import ListItem, { ListItemProps } from 'components/Connect/List/ListItem';
import { ConfirmationModalProps } from 'components/common/ConfirmationModal/ConfirmationModal';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
const store = configureStore();
const mockDeleteConnector = jest.fn(() => ({ type: 'test' }));
const mockDeleteConnector = jest.fn();
jest.mock('redux/actions', () => ({
...jest.requireActual('redux/actions'),
deleteConnector: () => mockDeleteConnector(),
deleteConnector: () => mockDeleteConnector,
}));
jest.mock(
@ -23,22 +24,22 @@ jest.mock(
describe('Connectors ListItem', () => {
const connector = connectors[0];
const setupWrapper = (props: Partial<ListItemProps> = {}) => (
<Provider store={store}>
<BrowserRouter>
<table>
<tbody>
<ListItem clusterName="local" connector={connector} {...props} />
</tbody>
</table>
</BrowserRouter>
</Provider>
<ThemeProvider theme={theme}>
<Provider store={store}>
<BrowserRouter>
<table>
<tbody>
<ListItem clusterName="local" connector={connector} {...props} />
</tbody>
</table>
</BrowserRouter>
</Provider>
</ThemeProvider>
);
it('renders item', () => {
const wrapper = mount(setupWrapper());
expect(wrapper.find('td').at(6).find('.has-text-success').text()).toEqual(
'2 of 2'
);
expect(wrapper.find('td').at(6).text()).toEqual('2 of 2');
});
it('renders item with failed tasks', () => {
@ -50,9 +51,7 @@ describe('Connectors ListItem', () => {
},
})
);
expect(wrapper.find('td').at(6).find('.has-text-danger').text()).toEqual(
'1 of 2'
);
expect(wrapper.find('td').at(6).text()).toEqual('1 of 2');
});
it('does not render info about tasks if taksCount is undefined', () => {

View file

@ -1,246 +1,507 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Connectors ListItem matches snapshot 1`] = `
<Provider
store={
.c5 {
background: transparent;
border: none;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: 'center';
-webkit-box-align: 'center';
-ms-flex-align: 'center';
align-items: 'center';
-webkit-box-pack: 'center';
-webkit-justify-content: 'center';
-ms-flex-pack: 'center';
justify-content: 'center';
}
.c5:hover {
cursor: pointer;
}
.c4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-self: center;
-ms-flex-item-align: center;
align-self: center;
}
.c2 {
border: none;
border-radius: 16px;
height: 20px;
line-height: 20px;
background-color: #E3E6E8;
color: #171A1C;
font-size: 12px;
display: inline-block;
padding-left: 0.75em;
padding-right: 0.75em;
text-align: center;
}
.c3 {
border: none;
border-radius: 16px;
height: 20px;
line-height: 20px;
background-color: #FFEECC;
color: #171A1C;
font-size: 12px;
display: inline-block;
padding-left: 0.75em;
padding-right: 0.75em;
text-align: center;
}
.c0 > a {
color: #171A1C;
font-weight: 500;
text-overflow: ellipsis;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
<Component
theme={
Object {
"@@observable": [Function],
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
"alert": Object {
"color": Object {
"error": "#FAD1D1",
"info": "#E3E6E8",
"success": "#D6F5E0",
"warning": "#FFEECC",
},
},
"buttonStyles": Object {
"fontSize": Object {
"L": "16px",
"M": "14px",
"S": "14px",
},
"height": Object {
"L": "40px",
"M": "32px",
"S": "24px",
},
"primary": Object {
"backgroundColor": Object {
"active": "#1414B8",
"hover": "#1717CF",
"normal": "#4F4FFF",
},
"color": "#FFFFFF",
"invertedColors": Object {
"active": "#1414B8",
"hover": "#1717CF",
"normal": "#4F4FFF",
},
},
"secondary": Object {
"backgroundColor": Object {
"active": "#D5DADD",
"hover": "#E3E6E8",
"normal": "#F1F2F3",
},
"color": "#171A1C",
"invertedColors": Object {
"active": "#171A1C",
"hover": "#454F54",
"normal": "#73848C",
},
},
},
"layout": Object {
"minWidth": "1200px",
"navBarHeight": "3.25rem",
"navBarWidth": "201px",
},
"menuStyles": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#F1F2F3",
"normal": "#FFFFFF",
},
"chevronIconColor": "#73848C",
"color": Object {
"active": "#171A1C",
"hover": "#73848C",
"normal": "#73848C",
},
"statusIconColor": Object {
"offline": "#E51A1A",
"online": "#5CD685",
},
},
"metrics": Object {
"backgroundColor": "#F1F2F3",
"indicator": Object {
"backgroundColor": "#FFFFFF",
"lightTextColor": "#ABB5BA",
"titleColor": "#73848C",
"warningTextColor": "#E51A1A",
},
},
"pageLoader": Object {
"borderBottomColor": "#FFFFFF",
"borderColor": "#4F4FFF",
},
"paginationStyles": Object {
"borderColor": Object {
"active": "#454F54",
"disabled": "#C7CED1",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"active": "#171A1C",
"disabled": "#C7CED1",
"hover": "#171A1C",
"normal": "#171A1C",
},
},
"primaryTabStyles": Object {
"borderColor": Object {
"active": "#4F4FFF",
"hover": "transparent",
"normal": "transparent",
},
"color": Object {
"active": "#171A1C",
"hover": "#171A1C",
"normal": "#73848C",
},
},
"secondaryTabStyles": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#F1F2F3",
"normal": "#FFFFFF",
},
"color": Object {
"active": "#171A1C",
"hover": "#171A1C",
"normal": "#73848C",
},
},
"selectStyles": Object {
"borderColor": Object {
"active": "#454F54",
"disabled": "#E3E6E8",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"active": "#171A1C",
"disabled": "#ABB5BA",
"hover": "#171A1C",
"normal": "#171A1C",
},
},
"switch": Object {
"checked": "#29A352",
"unchecked": "#ABB5BA",
},
"tagStyles": Object {
"backgroundColor": Object {
"gray": "#E3E6E8",
"green": "#D6F5E0",
"yellow": "#FFEECC",
},
"color": "#171A1C",
},
"thStyles": Object {
"backgroundColor": Object {
"normal": "#FFFFFF",
},
"color": Object {
"normal": "#73848C",
},
"previewColor": Object {
"normal": "#4F4FFF",
},
},
}
}
>
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
<Provider
store={
Object {
"@@observable": [Function],
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
>
<table>
<tbody>
<ListItem
clusterName="local"
connector={
Object {
"connect": "first",
"connectorClass": "FileStreamSource",
"failedTasksCount": 0,
"name": "hdfs-source-connector",
"status": Object {
"state": "RUNNING",
},
"tasksCount": 2,
"topics": Array [
"test-topic",
],
"type": "SOURCE",
}
>
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<table>
<tbody>
<ListItem
clusterName="local"
connector={
Object {
"connect": "first",
"connectorClass": "FileStreamSource",
"failedTasksCount": 0,
"name": "hdfs-source-connector",
"status": Object {
"state": "RUNNING",
},
"tasksCount": 2,
"topics": Array [
"test-topic",
],
"type": "SOURCE",
}
}
}
>
<tr>
<td
className="has-text-overflow-ellipsis"
>
<NavLink
activeClassName="is-active"
className="title is-6"
exact={true}
to="/ui/clusters/local/connects/first/connectors/hdfs-source-connector"
>
<Link
aria-current={null}
className="title is-6"
to={
Object {
"hash": "",
"pathname": "/ui/clusters/local/connects/first/connectors/hdfs-source-connector",
"search": "",
"state": null,
}
}
>
<tr>
<styled.td>
<td
className="c0"
>
<LinkAnchor
aria-current={null}
className="title is-6"
href="/ui/clusters/local/connects/first/connectors/hdfs-source-connector"
navigate={[Function]}
<NavLink
exact={true}
to="/ui/clusters/local/connects/first/connectors/hdfs-source-connector"
>
<a
<Link
aria-current={null}
className="title is-6"
href="/ui/clusters/local/connects/first/connectors/hdfs-source-connector"
onClick={[Function]}
to={
Object {
"hash": "",
"pathname": "/ui/clusters/local/connects/first/connectors/hdfs-source-connector",
"search": "",
"state": null,
}
}
>
hdfs-source-connector
</a>
</LinkAnchor>
</Link>
</NavLink>
</td>
<td>
first
</td>
<td>
SOURCE
</td>
<td>
FileStreamSource
</td>
<td>
<div
className="is-flex is-flex-wrap-wrap"
>
<span
className="tag is-info is-light mr-1 mb-1"
key="test-topic"
>
<Link
to="/ui/clusters/local/topics/test-topic"
>
<LinkAnchor
href="/ui/clusters/local/topics/test-topic"
navigate={[Function]}
>
<a
href="/ui/clusters/local/topics/test-topic"
onClick={[Function]}
<LinkAnchor
aria-current={null}
href="/ui/clusters/local/connects/first/connectors/hdfs-source-connector"
navigate={[Function]}
>
test-topic
</a>
</LinkAnchor>
</Link>
</span>
</div>
</td>
<td>
<ConnectorStatusTag
status="RUNNING"
>
<span
className="tag is-success"
>
RUNNING
</span>
</ConnectorStatusTag>
</td>
<td>
<span
className="has-text-success"
>
2
of
2
</span>
</td>
<td>
<div
className="has-text-right"
>
<Dropdown
label={
<span
className="icon"
>
<i
className="fas fa-cog"
/>
</span>
}
right={true}
>
<div
className="dropdown is-right"
>
<div
className="dropdown-trigger"
>
<button
aria-controls="dropdown-menu"
aria-haspopup="true"
className="button is-small is-link"
onClick={[Function]}
type="button"
>
<span
className="icon"
>
<i
className="fas fa-cog"
/>
</span>
</button>
</div>
<div
className="dropdown-menu"
id="dropdown-menu"
role="menu"
>
<div
className="dropdown-content has-text-left"
>
<DropdownDivider>
<hr
className="dropdown-divider"
/>
</DropdownDivider>
<DropdownItem
<a
aria-current={null}
href="/ui/clusters/local/connects/first/connectors/hdfs-source-connector"
onClick={[Function]}
>
<a
className="dropdown-item is-link"
href="#end"
onClick={[Function]}
role="menuitem"
type="button"
hdfs-source-connector
</a>
</LinkAnchor>
</Link>
</NavLink>
</td>
</styled.td>
<td>
first
</td>
<td>
SOURCE
</td>
<td>
FileStreamSource
</td>
<td>
<styled.div>
<div
className="c1"
>
<Styled(Tag)
color="gray"
key="test-topic"
>
<Tag
className="c2"
color="gray"
>
<p
className="c2"
>
<Link
to="/ui/clusters/local/topics/test-topic"
>
<span
className="has-text-danger"
<LinkAnchor
href="/ui/clusters/local/topics/test-topic"
navigate={[Function]}
>
Remove Connector
</span>
</a>
</DropdownItem>
<a
href="/ui/clusters/local/topics/test-topic"
onClick={[Function]}
>
test-topic
</a>
</LinkAnchor>
</Link>
</p>
</Tag>
</Styled(Tag)>
</div>
</styled.div>
</td>
<td>
<Styled(Tag)
color="yellow"
>
<Tag
className="c3"
color="yellow"
>
<p
className="c3"
>
RUNNING
</p>
</Tag>
</Styled(Tag)>
</td>
<td>
<span>
2
of
2
</span>
</td>
<td>
<div>
<Dropdown
label={<VerticalElipsisIcon />}
right={true}
>
<div
className="dropdown is-right"
>
<styled.div>
<div
className="c4"
>
<Styled(DropdownTrigger)
onClick={[Function]}
>
<DropdownTrigger
className="c5"
onClick={[Function]}
>
<button
aria-controls="dropdown-menu"
aria-haspopup="true"
className="c5"
onClick={[Function]}
type="button"
>
<VerticalElipsisIcon>
<svg
fill="none"
height="16"
viewBox="0 0 4 16"
width="4"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 4C3.1 4 4 3.1 4 2C4 0.9 3.1 0 2 0C0.9 0 0 0.9 0 2C0 3.1 0.9 4 2 4ZM2 6C0.9 6 0 6.9 0 8C0 9.1 0.9 10 2 10C3.1 10 4 9.1 4 8C4 6.9 3.1 6 2 6ZM2 12C0.9 12 0 12.9 0 14C0 15.1 0.9 16 2 16C3.1 16 4 15.1 4 14C4 12.9 3.1 12 2 12Z"
fill="#73848C"
/>
</svg>
</VerticalElipsisIcon>
</button>
</DropdownTrigger>
</Styled(DropdownTrigger)>
</div>
</styled.div>
<div
className="dropdown-menu"
id="dropdown-menu"
role="menu"
>
<div
className="dropdown-content has-text-left"
>
<DropdownDivider>
<hr
className="dropdown-divider"
/>
</DropdownDivider>
<DropdownItem
onClick={[Function]}
>
<a
className="dropdown-item is-link"
href="#end"
onClick={[Function]}
role="menuitem"
type="button"
>
<span
style={
Object {
"color": "#E51A1A",
}
}
>
Remove Connector
</span>
</a>
</DropdownItem>
</div>
</div>
</div>
</div>
</Dropdown>
</div>
<mock-ConfirmationModal
isOpen={false}
onCancel={[Function]}
onConfirm={[Function]}
>
Are you sure want to remove
<b>
hdfs-source-connector
</b>
connector?
</mock-ConfirmationModal>
</td>
</tr>
</ListItem>
</tbody>
</table>
</Router>
</BrowserRouter>
</Provider>
</Dropdown>
</div>
<mock-ConfirmationModal
isOpen={false}
onCancel={[Function]}
onConfirm={[Function]}
>
Are you sure want to remove
<b>
hdfs-source-connector
</b>
connector?
</mock-ConfirmationModal>
</td>
</tr>
</ListItem>
</tbody>
</table>
</Router>
</BrowserRouter>
</Provider>
</Component>
`;

View file

@ -1,6 +1,6 @@
import React from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { Controller, useForm } from 'react-hook-form';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { ErrorMessage } from '@hookform/error-message';
import { yupResolver } from '@hookform/resolvers/yup';
import { Connect, Connector, NewConnector } from 'generated-sources';
@ -9,6 +9,13 @@ import { clusterConnectConnectorPath } from 'lib/paths';
import yup from 'lib/yupExtended';
import JSONEditor from 'components/common/JSONEditor/JSONEditor';
import PageLoader from 'components/common/PageLoader/PageLoader';
import { InputLabel } from 'components/common/Input/InputLabel.styled';
import Select from 'components/common/Select/Select';
import { FormError } from 'components/common/Input/Input.styled';
import Input from 'components/common/Input/Input';
import { Button } from 'components/common/Button/Button';
import styled from 'styled-components';
import PageHeading from 'components/common/PageHeading/PageHeading';
const validationSchema = yup.object().shape({
name: yup.string().required(),
@ -19,6 +26,17 @@ interface RouterParams {
clusterName: ClusterName;
}
const NewConnectFormStyled = styled.form`
padding: 16px;
padding-top: 0;
display: flex;
flex-direction: column;
gap: 16px;
& > button:last-child {
align-self: flex-start;
}
`;
export interface NewProps {
fetchConnects(clusterName: ClusterName): void;
areConnectsFetching: boolean;
@ -45,14 +63,7 @@ const New: React.FC<NewProps> = ({
const { clusterName } = useParams<RouterParams>();
const history = useHistory();
const {
register,
handleSubmit,
control,
formState: { isDirty, isSubmitting, isValid, errors },
getValues,
setValue,
} = useForm<FormValues>({
const methods = useForm<FormValues>({
mode: 'onTouched',
resolver: yupResolver(validationSchema),
defaultValues: {
@ -61,6 +72,13 @@ const New: React.FC<NewProps> = ({
config: '',
},
});
const {
handleSubmit,
control,
formState: { isDirty, isSubmitting, isValid, errors },
getValues,
setValue,
} = methods;
React.useEffect(() => {
fetchConnects(clusterName);
@ -105,66 +123,60 @@ const New: React.FC<NewProps> = ({
}
return (
<div className="box">
<form onSubmit={handleSubmit(onSubmit)}>
<FormProvider {...methods}>
<PageHeading text="Create new connector" />
<NewConnectFormStyled onSubmit={handleSubmit(onSubmit)}>
<div className={['field', connectNameFieldClassName].join(' ')}>
<label className="label">Connect *</label>
<div className="control select">
<select {...register('connectName')} disabled={isSubmitting}>
{connects.map(({ name }) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
<p className="help is-danger">
<InputLabel>Connect *</InputLabel>
<Select selectSize="M" name="connectName" disabled={isSubmitting}>
{connects.map(({ name }) => (
<option key={name} value={name}>
{name}
</option>
))}
</Select>
<FormError>
<ErrorMessage errors={errors} name="connectName" />
</p>
</FormError>
</div>
<div className="field">
<label className="label">Name *</label>
<div className="control">
<input
className="input"
placeholder="Connector Name"
{...register('name')}
autoComplete="off"
disabled={isSubmitting}
/>
</div>
<p className="help is-danger">
<div>
<InputLabel>Name *</InputLabel>
<Input
inputSize="M"
placeholder="Connector Name"
name="name"
autoComplete="off"
disabled={isSubmitting}
/>
<FormError>
<ErrorMessage errors={errors} name="name" />
</p>
</FormError>
</div>
<div className="field">
<label className="label">Config *</label>
<div className="control">
<Controller
control={control}
name="config"
render={({ field }) => (
<JSONEditor {...field} readOnly={isSubmitting} />
)}
/>
</div>
<p className="help is-danger">
<div>
<InputLabel>Config *</InputLabel>
<Controller
control={control}
name="config"
render={({ field }) => (
<JSONEditor {...field} readOnly={isSubmitting} />
)}
/>
<FormError>
<ErrorMessage errors={errors} name="config" />
</p>
</FormError>
</div>
<div className="field">
<div className="control">
<input
type="submit"
className="button is-primary"
disabled={!isValid || isSubmitting || !isDirty}
/>
</div>
</div>
</form>
</div>
<Button
buttonSize="M"
buttonType="primary"
type="submit"
disabled={!isValid || isSubmitting || !isDirty}
>
Submit
</Button>
</NewConnectFormStyled>
</FormProvider>
);
};

View file

@ -10,6 +10,8 @@ import {
import NewContainer from 'components/Connect/New/NewContainer';
import New, { NewProps } from 'components/Connect/New/New';
import { connects, connector } from 'redux/reducers/connect/__test__/fixtures';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
jest.mock('components/common/PageLoader/PageLoader', () => 'mock-PageLoader');
@ -37,19 +39,21 @@ describe('New', () => {
wrapper
.find('mock-JSONEditor')
.simulate('change', { target: { value: '{"class":"MyClass"}' } });
wrapper.find('input[type="submit"]').simulate('submit');
wrapper.find('button[type="submit"]').simulate('submit');
});
const setupWrapper = (props: Partial<NewProps> = {}) => (
<TestRouterWrapper pathname={pathname} urlParams={{ clusterName }}>
<New
fetchConnects={jest.fn()}
areConnectsFetching={false}
connects={connects}
createConnector={jest.fn()}
{...props}
/>
</TestRouterWrapper>
<ThemeProvider theme={theme}>
<TestRouterWrapper pathname={pathname} urlParams={{ clusterName }}>
<New
fetchConnects={jest.fn()}
areConnectsFetching={false}
connects={connects}
createConnector={jest.fn()}
{...props}
/>
</TestRouterWrapper>
</ThemeProvider>
);
it('matches snapshot', async () => {

View file

@ -1,24 +1,263 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`New view matches snapshot 1`] = `
Array [
.c0 {
height: 56px;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 0px 16px;
}
.c0 h1 {
font-size: 24px;
font-weight: 500;
line-height: 32px;
color: #000;
}
.c0 > div {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: 16px;
}
<div
className="box"
>
<form
className="c0"
>
<h1>
Create new connector
</h1>
<div />
</div>,
.c1 {
font-weight: 500;
font-size: 12px;
line-height: 20px;
color: #454F54;
}
.c3 {
height: 32px;
border: 1px #ABB5BA solid;
border-radius: 4px;
font-size: 14px;
width: 100%;
padding-left: 12px;
padding-right: 16px;
color: #171A1C;
background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
background-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
appearance: none !important;
}
.c3:hover {
color: #171A1C;
border-color: #73848C;
}
.c3:focus {
outline: none;
color: #171A1C;
border-color: #454F54;
}
.c3:disabled {
color: #ABB5BA;
border-color: #E3E6E8;
cursor: not-allowed;
}
.c2 {
position: relative;
}
.c6 {
border: 1px #ABB5BA solid;
border-radius: 4px;
height: 32px;
width: 100%;
padding-left: 12px;
font-size: 14px;
}
.c6::-webkit-input-placeholder {
color: #ABB5BA;
font-size: 14px;
}
.c6::-moz-placeholder {
color: #ABB5BA;
font-size: 14px;
}
.c6:-ms-input-placeholder {
color: #ABB5BA;
font-size: 14px;
}
.c6::placeholder {
color: #ABB5BA;
font-size: 14px;
}
.c6:hover {
border-color: #73848C;
}
.c6:focus {
outline: none;
border-color: #454F54;
}
.c6:focus::-webkit-input-placeholder {
color: transparent;
}
.c6:focus::-moz-placeholder {
color: transparent;
}
.c6:focus:-ms-input-placeholder {
color: transparent;
}
.c6:focus::placeholder {
color: transparent;
}
.c6:disabled {
color: #ABB5BA;
border-color: #E3E6E8;
cursor: not-allowed;
}
.c6:read-only {
color: #171A1C;
border: none;
background-color: #F1F2F3;
cursor: not-allowed;
}
.c6:-moz-read-only:focus::placeholder {
color: #ABB5BA;
}
.c6:read-only:focus::placeholder {
color: #ABB5BA;
}
.c4 {
color: #E51A1A;
font-size: 12px;
}
.c5 {
position: relative;
}
.c7 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
padding: 0px 12px;
border: none;
border-radius: 4px;
white-space: nowrap;
background: #4F4FFF;
color: #FFFFFF;
font-size: 14px;
height: 32px;
}
.c7:hover:enabled {
background: #1717CF;
color: #FFFFFF;
cursor: pointer;
}
.c7:active:enabled {
background: #1414B8;
color: #FFFFFF;
}
.c7:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.c7 a {
color: white;
}
.c7 i {
margin-right: 7px;
}
.c0 {
padding: 16px;
padding-top: 0;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
gap: 16px;
}
.c0 > button:last-child {
-webkit-align-self: flex-start;
-ms-flex-item-align: start;
align-self: flex-start;
}
<form
className="c0"
onSubmit={[Function]}
>
<div
className="field "
>
<label
className="label"
className="c1"
>
Connect *
</label>
<div
className="control select"
className="select-wrapper c2"
>
<select
className="c3"
disabled={false}
name="connectName"
onBlur={[Function]}
@ -37,23 +276,21 @@ exports[`New view matches snapshot 1`] = `
</select>
</div>
<p
className="help is-danger"
className="c4"
/>
</div>
<div
className="field"
>
<div>
<label
className="label"
className="c1"
>
Name *
</label>
<div
className="control"
className="c5"
>
<input
autoComplete="off"
className="input"
className="c6 c5"
disabled={false}
name="name"
onBlur={[Function]}
@ -62,47 +299,35 @@ exports[`New view matches snapshot 1`] = `
/>
</div>
<p
className="help is-danger"
className="c4"
/>
</div>
<div
className="field"
>
<div>
<label
className="label"
className="c1"
>
Config *
</label>
<div
className="control"
>
<mock-JSONEditor
name="config"
onBlur={[Function]}
onChange={[Function]}
readOnly={false}
value=""
/>
</div>
<mock-JSONEditor
name="config"
onBlur={[Function]}
onChange={[Function]}
readOnly={false}
value=""
/>
<p
className="help is-danger"
className="c4"
/>
</div>
<div
className="field"
<button
className="c7"
disabled={true}
type="submit"
>
<div
className="control"
>
<input
className="button is-primary"
disabled={true}
type="submit"
/>
</div>
</div>
</form>
</div>
Submit
</button>
</form>,
]
`;
exports[`New view matches snapshot when fetching connects 1`] = `<mock-PageLoader />`;

View file

@ -1,20 +0,0 @@
import cx from 'classnames';
import { ConnectorTaskStatus } from 'generated-sources';
import React from 'react';
export interface StatusTagProps {
status: ConnectorTaskStatus;
}
const StatusTag: React.FC<StatusTagProps> = ({ status }) => {
const classNames = cx('tag', {
'is-success': status === ConnectorTaskStatus.RUNNING,
'is-light': status === ConnectorTaskStatus.PAUSED,
'is-warning': status === ConnectorTaskStatus.UNASSIGNED,
'is-danger': status === ConnectorTaskStatus.FAILED,
});
return <span className={classNames}>{status}</span>;
};
export default StatusTag;

View file

@ -1,38 +0,0 @@
import React from 'react';
import { create } from 'react-test-renderer';
import StatusTag, { StatusTagProps } from 'components/Connect/StatusTag';
import { ConnectorTaskStatus } from 'generated-sources';
describe('StatusTag', () => {
const setupWrapper = (props: Partial<StatusTagProps> = {}) => (
<StatusTag status={ConnectorTaskStatus.RUNNING} {...props} />
);
it('matches snapshot for running status', () => {
const wrapper = create(
setupWrapper({ status: ConnectorTaskStatus.RUNNING })
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('matches snapshot for failed status', () => {
const wrapper = create(
setupWrapper({ status: ConnectorTaskStatus.FAILED })
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('matches snapshot for paused status', () => {
const wrapper = create(
setupWrapper({ status: ConnectorTaskStatus.PAUSED })
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('matches snapshot for unassigned status', () => {
const wrapper = create(
setupWrapper({ status: ConnectorTaskStatus.UNASSIGNED })
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View file

@ -1,19 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Connect matches snapshot 1`] = `
<div
className="section"
>
<Switch>
<Route
component={[Function]}
path="/ui/clusters/:clusterName/connects/:connectName/connectors/:connectorName"
/>
<Route
component={[Function]}
path="/ui/clusters/:clusterName/connectors"
/>
</Switch>
<div>
<Switch>
<Route
component={
@ -30,7 +18,7 @@ exports[`Connect matches snapshot 1`] = `
<Route
component={[Function]}
exact={true}
path="/ui/clusters/:clusterName/connectors/create_new"
path="/ui/clusters/:clusterName/connectors/create-new"
/>
<Route
component={[Function]}

View file

@ -1,33 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StatusTag matches snapshot for failed status 1`] = `
<span
className="tag is-danger"
>
FAILED
</span>
`;
exports[`StatusTag matches snapshot for paused status 1`] = `
<span
className="tag is-light"
>
PAUSED
</span>
`;
exports[`StatusTag matches snapshot for running status 1`] = `
<span
className="tag is-success"
>
RUNNING
</span>
`;
exports[`StatusTag matches snapshot for unassigned status 1`] = `
<span
className="tag is-warning"
>
UNASSIGNED
</span>
`;

View file

@ -1,26 +1,23 @@
import React from 'react';
import { ClusterName } from 'redux/interfaces';
import { Switch, Route } from 'react-router-dom';
import { Switch, Route, useParams } from 'react-router-dom';
import PageLoader from 'components/common/PageLoader/PageLoader';
import DetailsContainer from 'components/ConsumerGroups/Details/DetailsContainer';
import ListContainer from 'components/ConsumerGroups/List/ListContainer';
import Details from 'components/ConsumerGroups/Details/Details';
import List from 'components/ConsumerGroups/List/List';
import ResetOffsets from 'components/ConsumerGroups/Details/ResetOffsets/ResetOffsets';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import {
fetchConsumerGroups,
getAreConsumerGroupsFulfilled,
} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
import ResetOffsetsContainer from './Details/ResetOffsets/ResetOffsetsContainer';
interface Props {
clusterName: ClusterName;
isFetched: boolean;
fetchConsumerGroupsList: (clusterName: ClusterName) => void;
}
const ConsumerGroups: React.FC<Props> = ({
clusterName,
isFetched,
fetchConsumerGroupsList,
}) => {
const ConsumerGroups: React.FC = () => {
const dispatch = useAppDispatch();
const { clusterName } = useParams<{ clusterName: ClusterName }>();
const isFetched = useAppSelector(getAreConsumerGroupsFulfilled);
React.useEffect(() => {
fetchConsumerGroupsList(clusterName);
}, [fetchConsumerGroupsList, clusterName]);
dispatch(fetchConsumerGroups(clusterName));
}, [fetchConsumerGroups, clusterName]);
if (isFetched) {
return (
@ -28,16 +25,16 @@ const ConsumerGroups: React.FC<Props> = ({
<Route
exact
path="/ui/clusters/:clusterName/consumer-groups"
component={ListContainer}
component={List}
/>
<Route
exact
path="/ui/clusters/:clusterName/consumer-groups/:consumerGroupID"
component={DetailsContainer}
component={Details}
/>
<Route
path="/ui/clusters/:clusterName/consumer-groups/:consumerGroupID/reset-offsets"
component={ResetOffsetsContainer}
component={ResetOffsets}
/>
</Switch>
);

View file

@ -1,32 +0,0 @@
import { connect } from 'react-redux';
import { fetchConsumerGroupsList } from 'redux/actions';
import { RootState, ClusterName } from 'redux/interfaces';
import { RouteComponentProps } from 'react-router-dom';
import { getIsConsumerGroupsListFetched } from 'redux/reducers/consumerGroups/selectors';
import ConsumerGroups from './ConsumerGroups';
interface RouteProps {
clusterName: ClusterName;
}
type OwnProps = RouteComponentProps<RouteProps>;
const mapStateToProps = (
state: RootState,
{
match: {
params: { clusterName },
},
}: OwnProps
) => ({
isFetched: getIsConsumerGroupsListFetched(state),
clusterName,
});
const mapDispatchToProps = {
fetchConsumerGroupsList: (clusterName: ClusterName) =>
fetchConsumerGroupsList(clusterName),
};
export default connect(mapStateToProps, mapDispatchToProps)(ConsumerGroups);

View file

@ -1,56 +1,57 @@
import React from 'react';
import { ClusterName } from 'redux/interfaces';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import {
clusterConsumerGroupResetOffsetsPath,
clusterConsumerGroupsPath,
} from 'lib/paths';
import { ConsumerGroupID } from 'redux/interfaces/consumerGroup';
import {
ConsumerGroup,
ConsumerGroupDetails,
ConsumerGroupTopicPartition,
} from 'generated-sources';
import PageLoader from 'components/common/PageLoader/PageLoader';
import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
import { useHistory } from 'react-router';
import { useHistory, useParams } from 'react-router';
import ClusterContext from 'components/contexts/ClusterContext';
import PageHeading from 'components/common/PageHeading/PageHeading';
import VerticalElipsisIcon from 'components/common/Icons/VerticalElipsisIcon';
import * as Metrics from 'components/common/Metrics';
import TagStyled from 'components/common/Tag/Tag.styled';
import Dropdown from 'components/common/Dropdown/Dropdown';
import DropdownItem from 'components/common/Dropdown/DropdownItem';
import { Colors } from 'theme/theme';
import { groupBy } from 'lodash';
import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import {
fetchConsumerGroupDetails,
deleteConsumerGroup,
selectById,
getIsConsumerGroupDeleted,
getAreConsumerGroupDetailsFulfilled,
} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
import ListItem from './ListItem';
export interface Props extends ConsumerGroup, ConsumerGroupDetails {
clusterName: ClusterName;
partitions?: ConsumerGroupTopicPartition[];
isFetched: boolean;
isDeleted: boolean;
fetchConsumerGroupDetails: (
clusterName: ClusterName,
consumerGroupID: ConsumerGroupID
) => void;
deleteConsumerGroup: (clusterName: string, id: ConsumerGroupID) => void;
}
const Details: React.FC<Props> = ({
clusterName,
groupId,
partitions,
isFetched,
isDeleted,
fetchConsumerGroupDetails,
deleteConsumerGroup,
}) => {
React.useEffect(() => {
fetchConsumerGroupDetails(clusterName, groupId);
}, [fetchConsumerGroupDetails, clusterName, groupId]);
const items = partitions || [];
const [isConfirmationModelVisible, setIsConfirmationModelVisible] =
React.useState<boolean>(false);
const Details: React.FC = () => {
const history = useHistory();
const { isReadOnly } = React.useContext(ClusterContext);
const { consumerGroupID, clusterName } =
useParams<{ consumerGroupID: ConsumerGroupID; clusterName: ClusterName }>();
const dispatch = useAppDispatch();
const consumerGroup = useAppSelector((state) =>
selectById(state, consumerGroupID)
);
const isDeleted = useAppSelector(getIsConsumerGroupDeleted);
const isFetched = useAppSelector(getAreConsumerGroupDetailsFulfilled);
const [isConfirmationModalVisible, setIsConfirmationModalVisible] =
React.useState<boolean>(false);
React.useEffect(() => {
dispatch(fetchConsumerGroupDetails({ clusterName, consumerGroupID }));
}, [fetchConsumerGroupDetails, clusterName, consumerGroupID]);
const onDelete = () => {
setIsConfirmationModelVisible(false);
deleteConsumerGroup(clusterName, groupId);
setIsConfirmationModalVisible(false);
dispatch(deleteConsumerGroup({ clusterName, consumerGroupID }));
};
React.useEffect(() => {
if (isDeleted) {
@ -59,83 +60,76 @@ const Details: React.FC<Props> = ({
}, [isDeleted]);
const onResetOffsets = () => {
history.push(clusterConsumerGroupResetOffsetsPath(clusterName, groupId));
history.push(
clusterConsumerGroupResetOffsetsPath(clusterName, consumerGroupID)
);
};
if (!isFetched || !consumerGroup) {
return <PageLoader />;
}
const partitionsByTopic = groupBy(consumerGroup.partitions, 'topic');
return (
<div className="section">
<div className="level">
<div className="level-item level-left">
<Breadcrumb
links={[
{
href: clusterConsumerGroupsPath(clusterName),
label: 'All Consumer Groups',
},
]}
>
{groupId}
</Breadcrumb>
</div>
</div>
{isFetched ? (
<div className="box">
<div>
<div>
<PageHeading text={consumerGroupID}>
{!isReadOnly && (
<div className="level">
<div className="level-item level-right buttons">
<button
type="button"
className="button"
onClick={onResetOffsets}
>
Reset offsets
</button>
<button
type="button"
className="button is-danger"
onClick={() => setIsConfirmationModelVisible(true)}
>
Delete consumer group
</button>
</div>
</div>
<Dropdown label={<VerticalElipsisIcon />} right>
<DropdownItem onClick={onResetOffsets}>
Reset offsest
</DropdownItem>
<DropdownItem
style={{ color: Colors.red[50] }}
onClick={() => setIsConfirmationModalVisible(true)}
>
Delete consumer group
</DropdownItem>
</Dropdown>
)}
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Consumer ID</th>
<th>Host</th>
<th>Topic</th>
<th>Partition</th>
<th>Messages behind</th>
<th>Current offset</th>
<th>End offset</th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr>
<td colSpan={10}>No active consumer groups</td>
</tr>
)}
{items.map((consumer) => (
<ListItem
key={consumer.consumerId}
clusterName={clusterName}
consumer={consumer}
/>
))}
</tbody>
</table>
</div>
) : (
<PageLoader />
)}
</PageHeading>
</div>
<Metrics.Wrapper>
<Metrics.Section>
<Metrics.Indicator label="State">
<TagStyled color="yellow">{consumerGroup.state}</TagStyled>
</Metrics.Indicator>
<Metrics.Indicator label="Members">
{consumerGroup.members}
</Metrics.Indicator>
<Metrics.Indicator label="Assigned topics">
{consumerGroup.topics}
</Metrics.Indicator>
<Metrics.Indicator label="Assigned partitions">
{consumerGroup.partitions?.length}
</Metrics.Indicator>
<Metrics.Indicator label="Coordinator ID">
{consumerGroup.coordinator?.id}
</Metrics.Indicator>
</Metrics.Section>
</Metrics.Wrapper>
<Table isFullwidth>
<thead>
<tr>
<TableHeaderCell> </TableHeaderCell>
<TableHeaderCell title="Topic" />
</tr>
</thead>
<tbody>
{Object.keys(partitionsByTopic).map((key) => (
<ListItem
clusterName={clusterName}
consumers={partitionsByTopic[key]}
name={key}
key={key}
/>
))}
</tbody>
</Table>
<ConfirmationModal
isOpen={isConfirmationModelVisible}
onCancel={() => setIsConfirmationModelVisible(false)}
isOpen={isConfirmationModalVisible}
onCancel={() => setIsConfirmationModalVisible(false)}
onConfirm={onDelete}
>
Are you sure you want to delete this consumer group?

View file

@ -1,49 +0,0 @@
import { connect } from 'react-redux';
import { ClusterName, RootState } from 'redux/interfaces';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import {
getIsConsumerGroupDetailsFetched,
getIsConsumerGroupsDeleted,
getConsumerGroupByID,
} from 'redux/reducers/consumerGroups/selectors';
import { ConsumerGroupID } from 'redux/interfaces/consumerGroup';
import {
deleteConsumerGroup,
fetchConsumerGroupDetails,
} from 'redux/actions/thunks';
import Details from './Details';
interface RouteProps {
clusterName: ClusterName;
consumerGroupID: ConsumerGroupID;
}
type OwnProps = RouteComponentProps<RouteProps>;
const mapStateToProps = (
state: RootState,
{
match: {
params: { consumerGroupID, clusterName },
},
}: OwnProps
) => ({
clusterName,
isFetched: getIsConsumerGroupDetailsFetched(state),
isDeleted: getIsConsumerGroupsDeleted(state),
...getConsumerGroupByID(state, consumerGroupID),
});
const mapDispatchToProps = {
fetchConsumerGroupDetails: (
clusterName: ClusterName,
consumerGroupID: ConsumerGroupID
) => fetchConsumerGroupDetails(clusterName, consumerGroupID),
deleteConsumerGroup: (clusterName: string, id: ConsumerGroupID) =>
deleteConsumerGroup(clusterName, id),
};
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(Details)
);

View file

@ -0,0 +1,6 @@
import styled from 'styled-components';
export const ToggleButton = styled.td`
padding: 8px 8px 8px 16px !important;
width: 30px;
`;

View file

@ -1,34 +1,37 @@
import React from 'react';
import { ConsumerGroupTopicPartition } from 'generated-sources';
import { NavLink } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { ClusterName } from 'redux/interfaces/cluster';
import { clusterTopicPath } from 'lib/paths';
import MessageToggleIcon from 'components/common/Icons/MessageToggleIcon';
import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper';
import { TableKeyLink } from 'components/common/table/Table/TableKeyLink.styled';
import TopicContents from './TopicContents/TopicContents';
import { ToggleButton } from './ListItem.styled';
interface Props {
clusterName: ClusterName;
consumer: ConsumerGroupTopicPartition;
name: string;
consumers: ConsumerGroupTopicPartition[];
}
const ListItem: React.FC<Props> = ({ clusterName, consumer }) => {
const ListItem: React.FC<Props> = ({ clusterName, name, consumers }) => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<tr>
<td>{consumer.consumerId}</td>
<td>{consumer.host}</td>
<td>
<NavLink
exact
to={clusterTopicPath(clusterName, consumer.topic)}
activeClassName="is-active"
className="title is-6"
>
{consumer.topic}
</NavLink>
</td>
<td>{consumer.partition}</td>
<td>{consumer.messagesBehind}</td>
<td>{consumer.currentOffset}</td>
<td>{consumer.endOffset}</td>
</tr>
<>
<tr>
<ToggleButton>
<IconButtonWrapper onClick={() => setIsOpen(!isOpen)} aria-hidden>
<MessageToggleIcon isOpen={isOpen} />
</IconButtonWrapper>
</ToggleButton>
<TableKeyLink>
<Link to={clusterTopicPath(clusterName, name)}>{name}</Link>
</TableKeyLink>
</tr>
{isOpen && <TopicContents consumers={consumers} />}
</>
);
};

View file

@ -0,0 +1,69 @@
import styled from 'styled-components';
export const ResetOffsetsStyledWrapper = styled.div`
padding: 16px;
padding-top: 0;
& > form {
display: flex;
flex-direction: column;
gap: 16px;
& > button:last-child {
align-self: flex-start;
}
}
& .multi-select {
height: 32px;
& > .dropdown-container {
height: 32px;
& > .dropdown-heading {
height: 32px;
}
}
}
& .date-picker {
height: 32px;
border: 1px ${(props) => props.theme.selectStyles.borderColor.normal} solid;
border-radius: 4px;
font-size: 14px;
width: 50%;
padding-left: 12px;
color: ${(props) => props.theme.selectStyles.color.normal};
background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
background-repeat: no-repeat !important;
background-position-x: 96% !important;
background-position-y: 55% !important;
appearance: none !important;
&:hover {
cursor: pointer;
}
&:focus {
outline: none;
}
}
`;
export const MainSelectorsWrapperStyled = styled.div`
display: flex;
gap: 16px;
& > * {
flex-grow: 1;
}
`;
export const OffsetsWrapperStyled = styled.div`
display: flex;
width: 100%;
flex-wrap: wrap;
gap: 16px;
`;
export const OffsetsTitleStyled = styled.h1`
font-size: 18px;
font-weight: 500;
`;

View file

@ -1,14 +1,12 @@
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import {
ConsumerGroupDetails,
ConsumerGroupOffsetsResetType,
} from 'generated-sources';
import {
clusterConsumerGroupsPath,
clusterConsumerGroupDetailsPath,
} from 'lib/paths';
import { ConsumerGroupOffsetsResetType } from 'generated-sources';
import { clusterConsumerGroupDetailsPath } from 'lib/paths';
import React from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import {
Controller,
FormProvider,
useFieldArray,
useForm,
} from 'react-hook-form';
import { ClusterName, ConsumerGroupID } from 'redux/interfaces';
import MultiSelect from 'react-multi-select-component';
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
@ -17,31 +15,29 @@ import 'react-datepicker/dist/react-datepicker.css';
import { groupBy } from 'lodash';
import PageLoader from 'components/common/PageLoader/PageLoader';
import { ErrorMessage } from '@hookform/error-message';
import { useHistory } from 'react-router';
import { useHistory, useParams } from 'react-router';
import Select from 'components/common/Select/Select';
import { InputLabel } from 'components/common/Input/InputLabel.styled';
import { Button } from 'components/common/Button/Button';
import Input from 'components/common/Input/Input';
import { FormError } from 'components/common/Input/Input.styled';
import PageHeading from 'components/common/PageHeading/PageHeading';
import {
fetchConsumerGroupDetails,
selectById,
getAreConsumerGroupDetailsFulfilled,
getIsOffsetReseted,
resetConsumerGroupOffsets,
} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
export interface Props {
clusterName: ClusterName;
consumerGroupID: ConsumerGroupID;
consumerGroup: ConsumerGroupDetails;
detailsAreFetched: boolean;
IsOffsetReset: boolean;
fetchConsumerGroupDetails(
clusterName: ClusterName,
consumerGroupID: ConsumerGroupID
): void;
resetConsumerGroupOffsets(
clusterName: ClusterName,
consumerGroupID: ConsumerGroupID,
requestBody: {
topic: string;
resetType: ConsumerGroupOffsetsResetType;
partitionsOffsets?: { offset: string; partition: number }[];
resetToTimestamp?: Date;
partitions: number[];
}
): void;
resetResettingStatus: () => void;
}
import {
MainSelectorsWrapperStyled,
OffsetsWrapperStyled,
ResetOffsetsStyledWrapper,
OffsetsTitleStyled,
} from './ResetOffsets.styled';
interface FormType {
topic: string;
@ -50,18 +46,19 @@ interface FormType {
resetToTimestamp: Date;
}
const ResetOffsets: React.FC<Props> = ({
clusterName,
consumerGroupID,
consumerGroup,
detailsAreFetched,
IsOffsetReset,
fetchConsumerGroupDetails,
resetConsumerGroupOffsets,
resetResettingStatus,
}) => {
const ResetOffsets: React.FC = () => {
const dispatch = useAppDispatch();
const { consumerGroupID, clusterName } =
useParams<{ consumerGroupID: ConsumerGroupID; clusterName: ClusterName }>();
const consumerGroup = useAppSelector((state) =>
selectById(state, consumerGroupID)
);
const isFetched = useAppSelector(getAreConsumerGroupDetailsFulfilled);
const isOffsetReseted = useAppSelector(getIsOffsetReseted);
React.useEffect(() => {
fetchConsumerGroupDetails(clusterName, consumerGroupID);
dispatch(fetchConsumerGroupDetails({ clusterName, consumerGroupID }));
}, [clusterName, consumerGroupID]);
const [uniqueTopics, setUniqueTopics] = React.useState<string[]>([]);
@ -69,8 +66,14 @@ const ResetOffsets: React.FC<Props> = ({
[]
);
const methods = useForm<FormType>({
defaultValues: {
resetType: ConsumerGroupOffsetsResetType.EARLIEST,
topic: '',
partitionsOffsets: [],
},
});
const {
register,
handleSubmit,
setValue,
watch,
@ -78,13 +81,7 @@ const ResetOffsets: React.FC<Props> = ({
setError,
clearErrors,
formState: { errors },
} = useForm<FormType>({
defaultValues: {
resetType: ConsumerGroupOffsetsResetType.EARLIEST,
topic: '',
partitionsOffsets: [],
},
});
} = methods;
const { fields } = useFieldArray({
control,
name: 'partitionsOffsets',
@ -94,11 +91,11 @@ const ResetOffsets: React.FC<Props> = ({
const offsetsValue = watch('partitionsOffsets');
React.useEffect(() => {
if (detailsAreFetched && consumerGroup.partitions) {
if (isFetched && consumerGroup?.partitions) {
setValue('topic', consumerGroup.partitions[0].topic);
setUniqueTopics(Object.keys(groupBy(consumerGroup.partitions, 'topic')));
}
}, [detailsAreFetched]);
}, [isFetched]);
const onSelectedPartitionsChange = (value: Option[]) => {
clearErrors();
@ -153,174 +150,140 @@ const ResetOffsets: React.FC<Props> = ({
}
}
if (isValid) {
resetConsumerGroupOffsets(clusterName, consumerGroupID, augmentedData);
dispatch(
resetConsumerGroupOffsets({
clusterName,
consumerGroupID,
requestBody: augmentedData,
})
);
}
};
const history = useHistory();
React.useEffect(() => {
if (IsOffsetReset) {
resetResettingStatus();
if (isOffsetReseted) {
dispatch(resetLoaderById('consumerGroups/resetConsumerGroupOffsets'));
history.push(
clusterConsumerGroupDetailsPath(clusterName, consumerGroupID)
);
}
}, [IsOffsetReset]);
}, [isOffsetReseted]);
if (!detailsAreFetched) {
if (!isFetched || !consumerGroup) {
return <PageLoader />;
}
return (
<div className="section">
<div className="level">
<div className="level-item level-left">
<Breadcrumb
links={[
{
href: clusterConsumerGroupsPath(clusterName),
label: 'All Consumer Groups',
},
{
href: clusterConsumerGroupDetailsPath(
clusterName,
consumerGroupID
),
label: consumerGroupID,
},
]}
>
Reset Offsets
</Breadcrumb>
</div>
</div>
<div className="box">
<FormProvider {...methods}>
<PageHeading text="Reset offsets" />
<ResetOffsetsStyledWrapper>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="columns">
<div className="column is-one-third">
<label className="label" htmlFor="topic">
Topic
</label>
<div className="select">
<select {...register('topic')} id="topic">
{uniqueTopics.map((topic) => (
<option key={topic} value={topic}>
{topic}
</option>
))}
</select>
</div>
<MainSelectorsWrapperStyled>
<div>
<InputLabel htmlFor="topic">Topic</InputLabel>
<Select name="topic" id="topic" selectSize="M">
{uniqueTopics.map((topic) => (
<option key={topic} value={topic}>
{topic}
</option>
))}
</Select>
</div>
<div className="column is-one-third">
<label className="label" htmlFor="resetType">
Reset Type
</label>
<div className="select">
<select {...register('resetType')} id="resetType">
{Object.values(ConsumerGroupOffsetsResetType).map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
<div>
<InputLabel htmlFor="resetType">Reset Type</InputLabel>
<Select name="resetType" id="resetType" selectSize="M">
{Object.values(ConsumerGroupOffsetsResetType).map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
</div>
<div className="column is-one-third">
<label className="label">Partitions</label>
<div className="select">
<MultiSelect
options={
consumerGroup.partitions
?.filter((p) => p.topic === topicValue)
.map((p) => ({
label: `Partition #${p.partition.toString()}`,
value: p.partition,
})) || []
}
value={selectedPartitions}
onChange={onSelectedPartitionsChange}
labelledBy="Select partitions"
/>
</div>
<div>
<InputLabel>Partitions</InputLabel>
<MultiSelect
options={
consumerGroup.partitions
?.filter((p) => p.topic === topicValue)
.map((p) => ({
label: `Partition #${p.partition.toString()}`,
value: p.partition,
})) || []
}
value={selectedPartitions}
onChange={onSelectedPartitionsChange}
labelledBy="Select partitions"
/>
</div>
</div>
</MainSelectorsWrapperStyled>
{resetTypeValue === ConsumerGroupOffsetsResetType.TIMESTAMP &&
selectedPartitions.length > 0 && (
<div className="columns">
<div className="column is-half">
<label className="label">Timestamp</label>
<Controller
control={control}
name="resetToTimestamp"
render={({ field: { onChange, onBlur, value, ref } }) => (
<DatePicker
ref={ref}
selected={value}
onChange={onChange}
onBlur={onBlur}
showTimeInput
timeInputLabel="Time:"
dateFormat="MMMM d, yyyy h:mm aa"
className="input"
/>
)}
/>
<ErrorMessage
errors={errors}
name="resetToTimestamp"
render={({ message }) => (
<p className="help is-danger">{message}</p>
)}
/>
</div>
<div>
<InputLabel>Timestamp</InputLabel>
<Controller
control={control}
name="resetToTimestamp"
render={({ field: { onChange, onBlur, value, ref } }) => (
<DatePicker
ref={ref}
selected={value}
onChange={onChange}
onBlur={onBlur}
showTimeInput
timeInputLabel="Time:"
dateFormat="MMMM d, yyyy h:mm aa"
className="date-picker"
/>
)}
/>
<ErrorMessage
errors={errors}
name="resetToTimestamp"
render={({ message }) => <FormError>{message}</FormError>}
/>
</div>
)}
{resetTypeValue === ConsumerGroupOffsetsResetType.OFFSET &&
selectedPartitions.length > 0 && (
<div className="columns">
<div className="column is-one-third">
<label className="label">Offsets</label>
<div>
<OffsetsTitleStyled>Offsets</OffsetsTitleStyled>
<OffsetsWrapperStyled>
{fields.map((field, index) => (
<div key={field.id} className="mb-2">
<label
className="subtitle is-6"
htmlFor={`partitionsOffsets.${index}.offset`}
>
<div key={field.id}>
<InputLabel htmlFor={`partitionsOffsets.${index}.offset`}>
Partition #{field.partition}
</label>
<input
</InputLabel>
<Input
id={`partitionsOffsets.${index}.offset`}
type="number"
className="input"
{...register(
`partitionsOffsets.${index}.offset` as const,
{ shouldUnregister: true }
)}
name={`partitionsOffsets.${index}.offset` as const}
hookFormOptions={{ shouldUnregister: true }}
defaultValue={field.offset}
/>
<ErrorMessage
errors={errors}
name={`partitionsOffsets.${index}.offset`}
render={({ message }) => (
<p className="help is-danger">{message}</p>
<FormError>{message}</FormError>
)}
/>
</div>
))}
</div>
</OffsetsWrapperStyled>
</div>
)}
<button
className="button is-primary"
<Button
buttonSize="M"
buttonType="primary"
type="submit"
disabled={selectedPartitions.length === 0}
>
Submit
</button>
</Button>
</form>
</div>
</div>
</ResetOffsetsStyledWrapper>
</FormProvider>
);
};

View file

@ -1,47 +0,0 @@
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { ClusterName, ConsumerGroupID, RootState } from 'redux/interfaces';
import {
getConsumerGroupByID,
getIsConsumerGroupDetailsFetched,
getOffsetReset,
} from 'redux/reducers/consumerGroups/selectors';
import {
fetchConsumerGroupDetails,
resetConsumerGroupOffsets,
resetConsumerGroupOffsetsAction,
} from 'redux/actions';
import ResetOffsets from './ResetOffsets';
interface RouteProps {
clusterName: ClusterName;
consumerGroupID: ConsumerGroupID;
}
type OwnProps = RouteComponentProps<RouteProps>;
const mapStateToProps = (
state: RootState,
{
match: {
params: { consumerGroupID, clusterName },
},
}: OwnProps
) => ({
clusterName,
consumerGroupID,
consumerGroup: getConsumerGroupByID(state, consumerGroupID),
detailsAreFetched: getIsConsumerGroupDetailsFetched(state),
IsOffsetReset: getOffsetReset(state),
});
const mapDispatchToProps = {
fetchConsumerGroupDetails,
resetConsumerGroupOffsets,
resetResettingStatus: resetConsumerGroupOffsetsAction.cancel,
};
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(ResetOffsets)
);

View file

@ -1,178 +1,162 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import ResetOffsets, {
Props,
} from 'components/ConsumerGroups/Details/ResetOffsets/ResetOffsets';
import { ConsumerGroupState } from 'generated-sources';
import React from 'react';
import { StaticRouter } from 'react-router';
import fetchMock from 'fetch-mock';
import { Route, StaticRouter } from 'react-router';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'lib/testHelpers';
import { clusterConsumerGroupResetOffsetsPath } from 'lib/paths';
import { consumerGroupPayload } from 'redux/reducers/consumerGroups/__test__/fixtures';
import ResetOffsets from 'components/ConsumerGroups/Details/ResetOffsets/ResetOffsets';
import { expectedOutputs } from './fixtures';
const clusterName = 'cluster1';
const { groupId } = consumerGroupPayload;
const setupWrapper = (props?: Partial<Props>) => (
<StaticRouter>
<ResetOffsets
clusterName="testCluster"
consumerGroupID="testGroup"
consumerGroup={{
groupId: 'amazon.msk.canary.group.broker-1',
members: 0,
topics: 2,
simple: false,
partitionAssignor: '',
state: ConsumerGroupState.EMPTY,
coordinator: {
id: 2,
host: 'b-2.kad-msk.st2jzq.c6.kafka.eu-west-1.amazonaws.com',
},
messagesBehind: 0,
partitions: [
{
topic: '__amazon_msk_canary',
partition: 1,
currentOffset: 0,
endOffset: 0,
messagesBehind: 0,
consumerId: undefined,
host: undefined,
},
{
topic: '__amazon_msk_canary',
partition: 0,
currentOffset: 56932,
endOffset: 56932,
messagesBehind: 0,
consumerId: undefined,
host: undefined,
},
{
topic: 'other_topic',
partition: 3,
currentOffset: 56932,
endOffset: 56932,
messagesBehind: 0,
consumerId: undefined,
host: undefined,
},
{
topic: 'other_topic',
partition: 4,
currentOffset: 56932,
endOffset: 56932,
messagesBehind: 0,
consumerId: undefined,
host: undefined,
},
],
const renderComponent = () =>
render(
<StaticRouter
location={{
pathname: clusterConsumerGroupResetOffsetsPath(
clusterName,
consumerGroupPayload.groupId
),
}}
detailsAreFetched
IsOffsetReset={false}
fetchConsumerGroupDetails={jest.fn()}
resetConsumerGroupOffsets={jest.fn()}
resetResettingStatus={jest.fn()}
{...props}
/>
</StaticRouter>
);
>
<Route
path={clusterConsumerGroupResetOffsetsPath(
':clusterName',
':consumerGroupID'
)}
>
<ResetOffsets />
</Route>
</StaticRouter>
);
const resetConsumerGroupOffsetsMockCalled = () =>
expect(
fetchMock.called(
`/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`
)
).toBeTruthy();
const selectresetTypeAndPartitions = async (resetType: string) => {
fireEvent.change(screen.getByLabelText('Reset Type'), {
target: { value: resetType },
});
userEvent.selectOptions(screen.getByLabelText('Reset Type'), resetType);
userEvent.click(screen.getByText('Select...'));
await waitFor(() => {
fireEvent.click(screen.getByText('Select...'));
});
await waitFor(() => {
fireEvent.click(screen.getByText('Partition #0'));
userEvent.click(screen.getByText('Partition #0'));
});
};
const resetConsumerGroupOffsetsWith = async (
resetType: string,
offset: null | number = null
) => {
userEvent.selectOptions(screen.getByLabelText('Reset Type'), resetType);
userEvent.click(screen.getByText('Select...'));
await waitFor(() => {
userEvent.click(screen.getByText('Partition #0'));
});
fetchMock.postOnce(
`/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`,
200,
{
body: {
topic: '__amazon_msk_canary',
resetType,
partitions: [0],
partitionsOffsets: [{ partition: 0, offset }],
},
}
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() => resetConsumerGroupOffsetsMockCalled());
};
describe('ResetOffsets', () => {
describe('on initial render', () => {
const component = render(setupWrapper());
it('matches the snapshot', () => {
expect(component.baseElement).toMatchSnapshot();
});
afterEach(() => {
fetchMock.reset();
});
describe('on submit', () => {
describe('with the default ResetType', () => {
it('calls resetConsumerGroupOffsets', async () => {
const mockResetConsumerGroupOffsets = jest.fn();
render(
setupWrapper({
resetConsumerGroupOffsets: mockResetConsumerGroupOffsets,
})
);
await selectresetTypeAndPartitions('EARLIEST');
await waitFor(() => {
fireEvent.click(screen.getByText('Submit'));
});
expect(mockResetConsumerGroupOffsets).toHaveBeenCalledTimes(1);
expect(mockResetConsumerGroupOffsets).toHaveBeenCalledWith(
'testCluster',
'testGroup',
expectedOutputs.EARLIEST
);
});
});
it('renders progress bar for initial state', () => {
fetchMock.getOnce(
`/api/clusters/${clusterName}/consumer-groups/${groupId}`,
404
);
renderComponent();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
describe('with the ResetType set to LATEST', () => {
it('calls resetConsumerGroupOffsets', async () => {
const mockResetConsumerGroupOffsets = jest.fn();
render(
setupWrapper({
resetConsumerGroupOffsets: mockResetConsumerGroupOffsets,
})
describe('with consumer group', () => {
describe('submit handles resetConsumerGroupOffsets', () => {
beforeEach(async () => {
const fetchConsumerGroupMock = fetchMock.getOnce(
`/api/clusters/${clusterName}/consumer-groups/${groupId}`,
consumerGroupPayload
);
await selectresetTypeAndPartitions('LATEST');
await waitFor(() => {
fireEvent.click(screen.getByText('Submit'));
});
expect(mockResetConsumerGroupOffsets).toHaveBeenCalledTimes(1);
expect(mockResetConsumerGroupOffsets).toHaveBeenCalledWith(
'testCluster',
'testGroup',
expectedOutputs.LATEST
renderComponent();
await waitFor(() =>
expect(fetchConsumerGroupMock.called()).toBeTruthy()
);
await waitFor(() => screen.queryByRole('form'));
});
});
describe('with the ResetType set to OFFSET', () => {
it('calls resetConsumerGroupOffsets', async () => {
const mockResetConsumerGroupOffsets = jest.fn();
render(
setupWrapper({
resetConsumerGroupOffsets: mockResetConsumerGroupOffsets,
})
);
it('calls resetConsumerGroupOffsets with EARLIEST', async () => {
await resetConsumerGroupOffsetsWith('EARLIEST');
});
it('calls resetConsumerGroupOffsets with LATEST', async () => {
await resetConsumerGroupOffsetsWith('LATEST');
});
it('calls resetConsumerGroupOffsets with OFFSET', async () => {
await selectresetTypeAndPartitions('OFFSET');
fetchMock.postOnce(
`/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`,
200,
{
body: {
topic: '__amazon_msk_canary',
resetType: 'OFFSET',
partitions: [0],
partitionsOffsets: [{ partition: 0, offset: 10 }],
},
}
);
await waitFor(() => {
fireEvent.change(screen.getAllByLabelText('Partition #0')[1], {
target: { value: '10' },
});
});
await waitFor(() => {
fireEvent.click(screen.getByText('Submit'));
});
expect(mockResetConsumerGroupOffsets).toHaveBeenCalledTimes(1);
expect(mockResetConsumerGroupOffsets).toHaveBeenCalledWith(
'testCluster',
'testGroup',
expectedOutputs.OFFSET
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() => resetConsumerGroupOffsetsMockCalled());
});
});
describe('with the ResetType set to TIMESTAMP', () => {
it('adds error to the page when the input is left empty', async () => {
const mockResetConsumerGroupOffsets = jest.fn();
render(setupWrapper());
it('calls resetConsumerGroupOffsets with TIMESTAMP', async () => {
await selectresetTypeAndPartitions('TIMESTAMP');
await waitFor(() => {
fireEvent.click(screen.getByText('Submit'));
});
expect(mockResetConsumerGroupOffsets).toHaveBeenCalledTimes(0);
expect(screen.getByText("This field shouldn't be empty!")).toBeTruthy();
const resetConsumerGroupOffsetsMock = fetchMock.postOnce(
`/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`,
200,
{
body: {
topic: '__amazon_msk_canary',
resetType: 'OFFSET',
partitions: [0],
partitionsOffsets: [{ partition: 0, offset: 10 }],
},
}
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() =>
expect(
screen.getByText("This field shouldn't be empty!")
).toBeInTheDocument()
);
await waitFor(() =>
expect(
resetConsumerGroupOffsetsMock.called(
`/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`
)
).toBeFalsy()
);
});
});
});

View file

@ -1,184 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResetOffsets on initial render matches the snapshot 1`] = `
<body>
<div>
<div
class="section"
>
<div
class="level"
>
<div
class="level-item level-left"
>
<nav
aria-label="breadcrumbs"
class="breadcrumb"
>
<ul>
<li>
<a
href="/ui/clusters/testCluster/consumer-groups"
>
All Consumer Groups
</a>
</li>
<li>
<a
href="/ui/clusters/testCluster/consumer-groups/testGroup"
>
testGroup
</a>
</li>
<li
class="is-active"
>
<span
class=""
>
Reset Offsets
</span>
</li>
</ul>
</nav>
</div>
</div>
<div
class="box"
>
<form>
<div
class="columns"
>
<div
class="column is-one-third"
>
<label
class="label"
for="topic"
>
Topic
</label>
<div
class="select"
>
<select
id="topic"
name="topic"
>
<option
value="__amazon_msk_canary"
>
__amazon_msk_canary
</option>
<option
value="other_topic"
>
other_topic
</option>
</select>
</div>
</div>
<div
class="column is-one-third"
>
<label
class="label"
for="resetType"
>
Reset Type
</label>
<div
class="select"
>
<select
id="resetType"
name="resetType"
>
<option
value="EARLIEST"
>
EARLIEST
</option>
<option
value="LATEST"
>
LATEST
</option>
<option
value="TIMESTAMP"
>
TIMESTAMP
</option>
<option
value="OFFSET"
>
OFFSET
</option>
</select>
</div>
</div>
<div
class="column is-one-third"
>
<label
class="label"
>
Partitions
</label>
<div
class="select"
>
<div
class="rmsc multi-select"
>
<div
aria-labelledby="Select partitions"
aria-readonly="true"
class="dropdown-container"
tabindex="0"
>
<div
class="dropdown-heading"
>
<div
class="dropdown-heading-value"
>
<span
class="gray"
>
Select...
</span>
</div>
<svg
class="dropdown-heading-dropdown-arrow gray"
fill="none"
height="24"
stroke="currentColor"
stroke-width="2"
width="24"
>
<path
d="M6 9L12 15 18 9"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
<button
class="button is-primary"
disabled=""
type="submit"
>
Submit
</button>
</form>
</div>
</div>
</div>
</body>
`;

View file

@ -0,0 +1,15 @@
import styled from 'styled-components';
import { Colors } from 'theme/theme';
export const TopicContentWrapper = styled.tr`
background-color: ${Colors.neutral[5]};
& > td {
padding: 16px !important;
}
`;
export const ContentBox = styled.div`
background-color: white;
padding: 20px;
border-radius: 8px;
`;

View file

@ -0,0 +1,47 @@
import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { ConsumerGroupTopicPartition } from 'generated-sources';
import React from 'react';
import { ContentBox, TopicContentWrapper } from './TopicContent.styled';
interface Props {
consumers: ConsumerGroupTopicPartition[];
}
const TopicContents: React.FC<Props> = ({ consumers }) => {
return (
<TopicContentWrapper>
<td colSpan={3}>
<ContentBox>
<Table isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Partition" />
<TableHeaderCell title="Consumer ID" />
<TableHeaderCell title="Host" />
<TableHeaderCell title="Messages behind" />
<TableHeaderCell title="Current offset" />
<TableHeaderCell title="End offset" />
</tr>
</thead>
<tbody>
{consumers.map((consumer) => (
<tr key={consumer.partition}>
<td>{consumer.partition}</td>
<td>{consumer.consumerId}</td>
<td>{consumer.host}</td>
<td>{consumer.messagesBehind}</td>
<td>{consumer.currentOffset}</td>
<td>{consumer.endOffset}</td>
</tr>
))}
</tbody>
</Table>
</ContentBox>
</td>
</TopicContentWrapper>
);
};
export default TopicContents;

View file

@ -1,102 +1,115 @@
import Details, { Props } from 'components/ConsumerGroups/Details/Details';
import { mount, shallow } from 'enzyme';
import Details from 'components/ConsumerGroups/Details/Details';
import React from 'react';
import { StaticRouter } from 'react-router';
import fetchMock from 'fetch-mock';
import { createMemoryHistory } from 'history';
import { render } from 'lib/testHelpers';
import { Route, Router } from 'react-router';
import {
clusterConsumerGroupDetailsPath,
clusterConsumerGroupResetOffsetsPath,
clusterConsumerGroupsPath,
} from 'lib/paths';
import { consumerGroupPayload } from 'redux/reducers/consumerGroups/__test__/fixtures';
import {
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
const mockHistory = {
push: jest.fn(),
};
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useHistory: () => mockHistory,
}));
const clusterName = 'cluster1';
const { groupId } = consumerGroupPayload;
const history = createMemoryHistory();
describe('Details component', () => {
const setupWrapper = (props?: Partial<Props>) => (
<Details
clusterName="local"
groupId="test"
isFetched
isDeleted={false}
fetchConsumerGroupDetails={jest.fn()}
deleteConsumerGroup={jest.fn()}
partitions={[
{
consumerId:
'consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d0',
topic: 'messages',
host: '/172.31.9.153',
partition: 6,
currentOffset: 394,
endOffset: 394,
messagesBehind: 0,
},
{
consumerId:
'consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d1',
topic: 'messages',
host: '/172.31.9.153',
partition: 7,
currentOffset: 384,
endOffset: 384,
messagesBehind: 0,
},
]}
{...props}
/>
const renderComponent = () => {
history.push(clusterConsumerGroupDetailsPath(clusterName, groupId));
render(
<Router history={history}>
<Route
path={clusterConsumerGroupDetailsPath(
':clusterName',
':consumerGroupID'
)}
>
<Details />
</Route>
</Router>
);
};
describe('Details component', () => {
afterEach(() => {
fetchMock.reset();
});
describe('when consumer gruops are NOT fetched', () => {
it('Matches the snapshot', () => {
expect(shallow(setupWrapper({ isFetched: false }))).toMatchSnapshot();
it('renders progress bar for initial state', () => {
fetchMock.getOnce(
`/api/clusters/${clusterName}/consumer-groups/${groupId}`,
404
);
renderComponent();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
});
describe('when consumer gruops are fetched', () => {
it('Matches the snapshot', () => {
expect(shallow(setupWrapper())).toMatchSnapshot();
beforeEach(async () => {
const fetchConsumerGroupMock = fetchMock.getOnce(
`/api/clusters/${clusterName}/consumer-groups/${groupId}`,
consumerGroupPayload
);
renderComponent();
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
await waitFor(() => expect(fetchConsumerGroupMock.called()).toBeTruthy());
});
describe('onDelete', () => {
it('calls deleteConsumerGroup', () => {
const deleteConsumerGroup = jest.fn();
const component = mount(
<StaticRouter>{setupWrapper({ deleteConsumerGroup })}</StaticRouter>
);
component.find('button').at(1).simulate('click');
component.update();
component
.find('ConfirmationModal')
.find('button')
.at(1)
.simulate('click');
expect(deleteConsumerGroup).toHaveBeenCalledTimes(1);
});
it('renders component', () => {
expect(screen.getByRole('heading')).toBeInTheDocument();
expect(screen.getByText(groupId)).toBeInTheDocument();
describe('on ConfirmationModal cancel', () => {
it('does not call deleteConsumerGroup', () => {
const deleteConsumerGroup = jest.fn();
const component = mount(
<StaticRouter>{setupWrapper({ deleteConsumerGroup })}</StaticRouter>
);
component.find('button').at(1).simulate('click');
component.update();
component
.find('ConfirmationModal')
.find('button')
.at(0)
.simulate('click');
expect(deleteConsumerGroup).toHaveBeenCalledTimes(0);
});
});
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getAllByRole('columnheader').length).toEqual(2);
describe('after deletion', () => {
it('calls history.push', () => {
mount(
<StaticRouter>{setupWrapper({ isDeleted: true })}</StaticRouter>
);
expect(mockHistory.push).toHaveBeenCalledTimes(1);
});
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('hanles [Reset offsest] click', async () => {
userEvent.click(screen.getByText('Reset offsest'));
expect(history.location.pathname).toEqual(
clusterConsumerGroupResetOffsetsPath(clusterName, groupId)
);
});
it('shows confirmation modal on consumer group delete', async () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
userEvent.click(screen.getByText('Delete consumer group'));
await waitFor(() =>
expect(screen.queryByRole('dialog')).toBeInTheDocument()
);
userEvent.click(screen.getByText('Cancel'));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('hanles [Delete consumer group] click', async () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
userEvent.click(screen.getByText('Delete consumer group'));
await waitFor(() =>
expect(screen.queryByRole('dialog')).toBeInTheDocument()
);
const deleteConsumerGroupMock = fetchMock.deleteOnce(
`/api/clusters/${clusterName}/consumer-groups/${groupId}`,
200
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() =>
expect(deleteConsumerGroupMock.called()).toBeTruthy()
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(history.location.pathname).toEqual(
clusterConsumerGroupsPath(clusterName)
);
});
});
});

View file

@ -1,8 +0,0 @@
import React from 'react';
import { containerRendersView } from 'lib/testHelpers';
import Details from 'components/ConsumerGroups/Details/Details';
import DetailsContainer from 'components/ConsumerGroups/Details/DetailsContainer';
describe('DetailsContainer', () => {
containerRendersView(<DetailsContainer />, Details);
});

View file

@ -1,157 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Details component when consumer gruops are NOT fetched Matches the snapshot 1`] = `
<div
className="section"
>
<div
className="level"
>
<div
className="level-item level-left"
>
<Breadcrumb
links={
Array [
Object {
"href": "/ui/clusters/local/consumer-groups",
"label": "All Consumer Groups",
},
]
}
>
test
</Breadcrumb>
</div>
</div>
<PageLoader />
<ConfirmationModal
isOpen={false}
onCancel={[Function]}
onConfirm={[Function]}
>
Are you sure you want to delete this consumer group?
</ConfirmationModal>
</div>
`;
exports[`Details component when consumer gruops are fetched Matches the snapshot 1`] = `
<div
className="section"
>
<div
className="level"
>
<div
className="level-item level-left"
>
<Breadcrumb
links={
Array [
Object {
"href": "/ui/clusters/local/consumer-groups",
"label": "All Consumer Groups",
},
]
}
>
test
</Breadcrumb>
</div>
</div>
<div
className="box"
>
<div
className="level"
>
<div
className="level-item level-right buttons"
>
<button
className="button"
onClick={[Function]}
type="button"
>
Reset offsets
</button>
<button
className="button is-danger"
onClick={[Function]}
type="button"
>
Delete consumer group
</button>
</div>
</div>
<table
className="table is-striped is-fullwidth"
>
<thead>
<tr>
<th>
Consumer ID
</th>
<th>
Host
</th>
<th>
Topic
</th>
<th>
Partition
</th>
<th>
Messages behind
</th>
<th>
Current offset
</th>
<th>
End offset
</th>
</tr>
</thead>
<tbody>
<ListItem
clusterName="local"
consumer={
Object {
"consumerId": "consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d0",
"currentOffset": 394,
"endOffset": 394,
"host": "/172.31.9.153",
"messagesBehind": 0,
"partition": 6,
"topic": "messages",
}
}
key="consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d0"
/>
<ListItem
clusterName="local"
consumer={
Object {
"consumerId": "consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d1",
"currentOffset": 384,
"endOffset": 384,
"host": "/172.31.9.153",
"messagesBehind": 0,
"partition": 7,
"topic": "messages",
}
}
key="consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d1"
/>
</tbody>
</table>
</div>
<ConfirmationModal
isOpen={false}
onCancel={[Function]}
onConfirm={[Function]}
>
Are you sure you want to delete this consumer group?
</ConfirmationModal>
</div>
`;

View file

@ -1,74 +1,62 @@
import React from 'react';
import { ClusterName } from 'redux/interfaces';
import { ConsumerGroup } from 'generated-sources';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import PageHeading from 'components/common/PageHeading/PageHeading';
import Search from 'components/common/Search/Search';
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
import { useAppSelector } from 'lib/hooks/redux';
import { selectAll } from 'redux/reducers/consumerGroups/consumerGroupsSlice';
import ListItem from './ListItem';
interface Props {
clusterName: ClusterName;
consumerGroups: ConsumerGroup[];
}
const List: React.FC<Props> = ({ consumerGroups }) => {
const List: React.FC = () => {
const consumerGroups = useAppSelector(selectAll);
const [searchText, setSearchText] = React.useState<string>('');
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(event.target.value);
const handleInputChange = (search: string) => {
setSearchText(search);
};
return (
<div className="section">
<Breadcrumb>All Consumer Groups</Breadcrumb>
<div className="box">
<div>
<div className="columns">
<div className="column is-half is-offset-half">
<input
id="searchText"
type="text"
name="searchText"
className="input"
placeholder="Search"
value={searchText}
onChange={handleInputChange}
<div>
<PageHeading text="Consumers" />
<ControlPanelWrapper hasInput>
<Search
placeholder="Search"
value={searchText}
handleSearch={handleInputChange}
/>
</ControlPanelWrapper>
<Table isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Consumer group ID" />
<TableHeaderCell title="Num of members" />
<TableHeaderCell title="Num of topics" />
<TableHeaderCell title="Messages behind" />
<TableHeaderCell title="Coordinator" />
<TableHeaderCell title="State" />
</tr>
</thead>
<tbody>
{consumerGroups
.filter(
(consumerGroup) =>
!searchText || consumerGroup?.groupId?.indexOf(searchText) >= 0
)
.map((consumerGroup) => (
<ListItem
key={consumerGroup.groupId}
consumerGroup={consumerGroup}
/>
</div>
</div>
<table className="table is-striped is-fullwidth is-hoverable">
<thead>
<tr>
<th>Consumer group ID</th>
<th>Num of members</th>
<th>Num of topics</th>
<th>Messages behind</th>
<th>Coordinator</th>
<th>State</th>
</tr>
</thead>
<tbody>
{consumerGroups
.filter(
(consumerGroup) =>
!searchText ||
consumerGroup?.groupId?.indexOf(searchText) >= 0
)
.map((consumerGroup) => (
<ListItem
key={consumerGroup.groupId}
consumerGroup={consumerGroup}
/>
))}
{consumerGroups.length === 0 && (
<tr>
<td colSpan={10}>No active consumer groups</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
))}
{consumerGroups.length === 0 && (
<tr>
<td colSpan={10}>No active consumer groups</td>
</tr>
)}
</tbody>
</Table>
</div>
);
};

View file

@ -1,26 +0,0 @@
import { connect } from 'react-redux';
import { ClusterName, RootState } from 'redux/interfaces';
import { getConsumerGroupsList } from 'redux/reducers/consumerGroups/selectors';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import List from './List';
interface RouteProps {
clusterName: ClusterName;
}
type OwnProps = RouteComponentProps<RouteProps>;
const mapStateToProps = (
state: RootState,
{
match: {
params: { clusterName },
},
}: OwnProps
) => ({
clusterName,
consumerGroups: getConsumerGroupsList(state),
});
export default withRouter(connect(mapStateToProps)(List));

View file

@ -1,26 +1,25 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { ConsumerGroup } from 'generated-sources';
import ConsumerGroupStateTag from 'components/common/ConsumerGroupState/ConsumerGroupStateTag';
import TagStyled from 'components/common/Tag/Tag.styled';
import { TableKeyLink } from 'components/common/table/Table/TableKeyLink.styled';
const ListItem: React.FC<{ consumerGroup: ConsumerGroup }> = ({
consumerGroup,
}) => {
const history = useHistory();
function goToConsumerGroupDetails() {
history.push(`consumer-groups/${consumerGroup.groupId}`);
}
return (
<tr className="is-clickable" onClick={goToConsumerGroupDetails}>
<td>{consumerGroup.groupId}</td>
<tr>
<TableKeyLink>
<Link to={`consumer-groups/${consumerGroup.groupId}`}>
{consumerGroup.groupId}
</Link>
</TableKeyLink>
<td>{consumerGroup.members}</td>
<td>{consumerGroup.topics}</td>
<td>{consumerGroup.messagesBehind}</td>
<td>{consumerGroup.coordinator?.id}</td>
<td>
<ConsumerGroupStateTag state={consumerGroup.state} />
<TagStyled color="yellow">{consumerGroup.state}</TagStyled>
</td>
</tr>
);

View file

@ -1,46 +1,46 @@
import React from 'react';
import { shallow, mount } from 'enzyme';
import List from 'components/ConsumerGroups/List/List';
import { screen } from '@testing-library/react';
import { StaticRouter } from 'react-router';
import userEvent from '@testing-library/user-event';
import { render } from 'lib/testHelpers';
import { store } from 'redux/store';
import { fetchConsumerGroups } from 'redux/reducers/consumerGroups/consumerGroupsSlice';
import { consumerGroups } from 'redux/reducers/consumerGroups/__test__/fixtures';
describe('List', () => {
const mockConsumerGroups = [
{
groupId: 'groupId',
members: 0,
topics: 1,
simple: false,
partitionAssignor: '',
coordinator: {
id: 1,
host: 'host',
},
partitions: [
{
consumerId: null,
currentOffset: 0,
endOffset: 0,
host: null,
messagesBehind: 0,
partition: 1,
topic: 'topic',
},
],
},
];
const component = shallow(
<List consumerGroups={mockConsumerGroups} clusterName="cluster" />
);
const componentEmpty = mount(
<List consumerGroups={[]} clusterName="cluster" />
beforeEach(() =>
render(
<StaticRouter>
<List />
</StaticRouter>
)
);
it('render empty List consumer Groups', () => {
expect(componentEmpty.find('td').text()).toEqual(
'No active consumer groups'
);
it('renders empty table', () => {
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('No active consumer groups')).toBeInTheDocument();
});
it('render List consumer Groups', () => {
expect(component.exists('.section')).toBeTruthy();
describe('consumerGroups are fecthed', () => {
beforeEach(() => {
store.dispatch({
type: fetchConsumerGroups.fulfilled.type,
payload: consumerGroups,
});
});
it('renders all rows with consumers', () => {
expect(screen.getByText('groupId1')).toBeInTheDocument();
expect(screen.getByText('groupId2')).toBeInTheDocument();
});
describe('when searched', () => {
it('renders only searched consumers', () => {
userEvent.type(screen.getByPlaceholderText('Search'), 'groupId1');
expect(screen.getByText('groupId1')).toBeInTheDocument();
expect(screen.getByText('groupId2')).toBeInTheDocument();
});
});
});
});

View file

@ -1,8 +0,0 @@
import React from 'react';
import { containerRendersView } from 'lib/testHelpers';
import ListContainer from 'components/ConsumerGroups/List/ListContainer';
import List from 'components/ConsumerGroups/List/List';
describe('ListContainer', () => {
containerRendersView(<ListContainer />, List);
});

View file

@ -1,6 +1,9 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mount } from 'enzyme';
import ListItem from 'components/ConsumerGroups/List/ListItem';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
import { StaticRouter } from 'react-router';
describe('List', () => {
const mockConsumerGroup = {
@ -25,9 +28,19 @@ describe('List', () => {
},
],
};
const component = shallow(<ListItem consumerGroup={mockConsumerGroup} />);
const component = mount(
<StaticRouter>
<ThemeProvider theme={theme}>
<table>
<tbody>
<ListItem consumerGroup={mockConsumerGroup} />
</tbody>
</table>
</ThemeProvider>
</StaticRouter>
);
it('render empty ListItem', () => {
expect(component.exists('.is-clickable')).toBeTruthy();
expect(component.exists('tr')).toBeTruthy();
});
});

View file

@ -1,78 +0,0 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { clusterBrokersPath, clusterTopicsPath } from 'lib/paths';
import { Cluster, ServerStatus } from 'generated-sources';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
interface ClusterWidgetProps {
cluster: Cluster;
}
const ClusterWidget: React.FC<ClusterWidgetProps> = ({
cluster: {
name,
status,
topicCount,
brokerCount,
bytesInPerSec,
bytesOutPerSec,
onlinePartitionCount,
readOnly,
version,
},
}) => (
<div className="column is-full-modile is-6">
<div className="box">
<div className="title is-6 has-text-overflow-ellipsis">
<div
className={`tag mr-2 ${
status === ServerStatus.ONLINE ? 'is-success' : 'is-danger'
}`}
>
{status}
</div>
{readOnly && <div className="tag mr-2 is-info is-light">readonly</div>}
{name}
</div>
<table className="table is-fullwidth">
<tbody>
<tr>
<th>Version</th>
<td>{version}</td>
</tr>
<tr>
<th>Brokers</th>
<td>
<NavLink to={clusterBrokersPath(name)}>{brokerCount}</NavLink>
</td>
</tr>
<tr>
<th>Partitions</th>
<td>{onlinePartitionCount}</td>
</tr>
<tr>
<th>Topics</th>
<td>
<NavLink to={clusterTopicsPath(name)}>{topicCount}</NavLink>
</td>
</tr>
<tr>
<th>Production</th>
<td>
<BytesFormatted value={bytesInPerSec} />
</td>
</tr>
<tr>
<th>Consumption</th>
<td>
<BytesFormatted value={bytesOutPerSec} />
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
export default ClusterWidget;

View file

@ -1,11 +1,15 @@
import React from 'react';
import { chunk } from 'lodash';
import { v4 } from 'uuid';
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
import Indicator from 'components/common/Dashboard/Indicator';
import * as Metrics from 'components/common/Metrics';
import { Cluster } from 'generated-sources';
import ClusterWidget from './ClusterWidget';
import TagStyled from 'components/common/Tag/Tag.styled';
import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
import { NavLink } from 'react-router-dom';
import { clusterTopicsPath } from 'lib/paths';
import Switch from 'components/common/Switch/Switch';
interface Props {
clusters: Cluster[];
@ -41,37 +45,68 @@ const ClustersWidget: React.FC<Props> = ({
const handleSwitch = () => setShowOfflineOnly(!showOfflineOnly);
return (
<div>
<h5 className="title is-5">Clusters</h5>
<MetricsWrapper>
<Indicator label="Online Clusters">
<span className="tag is-success">{onlineClusters.length}</span>
</Indicator>
<Indicator label="Offline Clusters">
<span className="tag is-danger">{offlineClusters.length}</span>
</Indicator>
<Indicator label="Hide online clusters">
<input
type="checkbox"
className="switch is-rounded"
name="switchRoundedDefault"
id="switchRoundedDefault"
checked={showOfflineOnly}
onChange={handleSwitch}
/>
<label htmlFor="switchRoundedDefault" />
</Indicator>
</MetricsWrapper>
<>
<Metrics.Wrapper>
<Metrics.Section>
<Metrics.Indicator
label={<TagStyled color="green">Online</TagStyled>}
>
<span data-testid="onlineCount">{onlineClusters.length}</span>{' '}
<Metrics.LightText>clusters</Metrics.LightText>
</Metrics.Indicator>
<Metrics.Indicator
label={<TagStyled color="gray">Offline</TagStyled>}
>
<span data-testid="offlineCount">{offlineClusters.length}</span>{' '}
<Metrics.LightText>clusters</Metrics.LightText>
</Metrics.Indicator>
</Metrics.Section>
</Metrics.Wrapper>
<div className="p-4">
<Switch
name="switchRoundedDefault"
checked={showOfflineOnly}
onChange={handleSwitch}
/>
<span>Only offline clusters</span>
</div>
{clusterList.map((chunkItem) => (
<div className="columns" key={chunkItem.id}>
{chunkItem.data.map((cluster) => (
<ClusterWidget cluster={cluster} key={cluster.name} />
))}
</div>
<Table key={chunkItem.id} isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Cluster name" />
<TableHeaderCell title="Version" />
<TableHeaderCell title="Brokers count" />
<TableHeaderCell title="Partitions" />
<TableHeaderCell title="Topics" />
<TableHeaderCell title="Production" />
<TableHeaderCell title="Consumption" />
</tr>
</thead>
<tbody>
{chunkItem.data.map((cluster) => (
<tr key={cluster.name}>
<td>{cluster.name}</td>
<td>{cluster.version}</td>
<td>{cluster.brokerCount}</td>
<td>{cluster.onlinePartitionCount}</td>
<td>
<NavLink to={clusterTopicsPath(cluster.name)}>
{cluster.topicCount}
</NavLink>
</td>
<td>
<BytesFormatted value={cluster.bytesInPerSec} />
</td>
<td>
<BytesFormatted value={cluster.bytesOutPerSec} />
</td>
</tr>
))}
</tbody>
</Table>
))}
</div>
</>
);
};

View file

@ -3,7 +3,7 @@ import {
getClusterList,
getOnlineClusters,
getOfflineClusters,
} from 'redux/reducers/clusters/selectors';
} from 'redux/reducers/clusters/clustersSlice';
import { RootState } from 'redux/interfaces';
import ClustersWidget from './ClustersWidget';

View file

@ -1,87 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { ServerStatus } from 'generated-sources';
import { clusterBrokersPath, clusterTopicsPath } from 'lib/paths';
import ClusterWidget from 'components/Dashboard/ClustersWidget/ClusterWidget';
import { offlineCluster, onlineCluster } from './fixtures';
describe('ClusterWidget', () => {
describe('when cluster is online', () => {
it('renders with correct tag', () => {
const tag = shallow(<ClusterWidget cluster={onlineCluster} />).find(
'.tag'
);
expect(tag.hasClass('is-success')).toBeTruthy();
expect(tag.text()).toEqual(ServerStatus.ONLINE);
});
it('renders table', () => {
const table = shallow(<ClusterWidget cluster={onlineCluster} />).find(
'table'
);
expect(table.hasClass('is-fullwidth')).toBeTruthy();
expect(
table.find(`NavLink[to="${clusterBrokersPath(onlineCluster.name)}"]`)
.exists
).toBeTruthy();
expect(
table.find(`NavLink[to="${clusterTopicsPath(onlineCluster.name)}"]`)
.exists
).toBeTruthy();
});
it('matches snapshot', () => {
expect(
shallow(<ClusterWidget cluster={onlineCluster} />)
).toMatchSnapshot();
});
});
describe('when cluster is offline', () => {
it('renders with correct tag', () => {
const tag = shallow(<ClusterWidget cluster={offlineCluster} />).find(
'.tag'
);
expect(tag.hasClass('is-danger')).toBeTruthy();
expect(tag.text()).toEqual(ServerStatus.OFFLINE);
});
it('renders table', () => {
const table = shallow(<ClusterWidget cluster={offlineCluster} />).find(
'table'
);
expect(table.hasClass('is-fullwidth')).toBeTruthy();
expect(
table.find(`NavLink[to="${clusterBrokersPath(onlineCluster.name)}"]`)
.exists
).toBeTruthy();
expect(
table.find(`NavLink[to="${clusterTopicsPath(onlineCluster.name)}"]`)
.exists
).toBeTruthy();
});
it('matches snapshot', () => {
expect(
shallow(<ClusterWidget cluster={offlineCluster} />)
).toMatchSnapshot();
});
});
describe('when cluster is read-only', () => {
it('renders the tag', () => {
expect(
shallow(
<ClusterWidget cluster={{ ...onlineCluster, readOnly: true }} />
)
.find('.title')
.childAt(1)
.text()
).toEqual('readonly');
});
});
});

View file

@ -1,37 +1,33 @@
import React from 'react';
import { shallow } from 'enzyme';
import { StaticRouter } from 'react-router';
import { screen } from '@testing-library/react';
import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget';
import userEvent from '@testing-library/user-event';
import { render } from 'lib/testHelpers';
import { offlineCluster, onlineCluster, clusters } from './fixtures';
const component = () =>
shallow(
<ClustersWidget
clusters={clusters}
onlineClusters={[onlineCluster]}
offlineClusters={[offlineCluster]}
/>
const setupComponent = () =>
render(
<StaticRouter>
<ClustersWidget
clusters={clusters}
onlineClusters={[onlineCluster]}
offlineClusters={[offlineCluster]}
/>
</StaticRouter>
);
describe('ClustersWidget', () => {
beforeEach(() => setupComponent());
it('renders clusterWidget list', () => {
const clusterWidget = component().find('ClusterWidget');
expect(clusterWidget.length).toBe(2);
});
it('renders ClusterWidget', () => {
expect(component().exists('ClusterWidget')).toBeTruthy();
});
it('renders columns', () => {
expect(component().exists('.columns')).toBeTruthy();
expect(screen.getAllByRole('row').length).toBe(3);
});
it('hides online cluster widgets', () => {
const value = component();
const input = value.find('input');
expect(value.find('ClusterWidget').length).toBe(2);
input.simulate('change', { target: { checked: true } });
expect(value.find('ClusterWidget').length).toBe(1);
expect(screen.getAllByRole('row').length).toBe(3);
userEvent.click(screen.getByRole('checkbox'));
expect(screen.getAllByRole('row').length).toBe(2);
});
});

View file

@ -3,16 +3,24 @@ import { mount } from 'enzyme';
import { containerRendersView } from 'lib/testHelpers';
import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget';
import ClustersWidgetContainer from 'components/Dashboard/ClustersWidget/ClustersWidgetContainer';
import theme from 'theme/theme';
import { ThemeProvider } from 'styled-components';
describe('ClustersWidgetContainer', () => {
containerRendersView(<ClustersWidgetContainer />, ClustersWidget);
describe('view empty ClusterWidget', () => {
const setupEmptyWrapper = () => (
<ClustersWidget clusters={[]} onlineClusters={[]} offlineClusters={[]} />
<ThemeProvider theme={theme}>
<ClustersWidget
clusters={[]}
onlineClusters={[]}
offlineClusters={[]}
/>
</ThemeProvider>
);
it(' is empty when no online clusters', () => {
const wrapper = mount(setupEmptyWrapper());
expect(wrapper.find('.is-success').text()).toBe('0');
expect(wrapper.find('[data-testid="onlineCount"]').text()).toBe('0');
});
});
});

View file

@ -1,171 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ClusterWidget when cluster is offline matches snapshot 1`] = `
<div
className="column is-full-modile is-6"
>
<div
className="box"
>
<div
className="title is-6 has-text-overflow-ellipsis"
>
<div
className="tag mr-2 is-danger"
>
offline
</div>
local
</div>
<table
className="table is-fullwidth"
>
<tbody>
<tr>
<th>
Version
</th>
<td />
</tr>
<tr>
<th>
Brokers
</th>
<td>
<NavLink
to="/ui/clusters/local/brokers"
>
1
</NavLink>
</td>
</tr>
<tr>
<th>
Partitions
</th>
<td>
2
</td>
</tr>
<tr>
<th>
Topics
</th>
<td>
<NavLink
to="/ui/clusters/local/topics"
>
2
</NavLink>
</td>
</tr>
<tr>
<th>
Production
</th>
<td>
<BytesFormatted
value={8000.00000673768}
/>
</td>
</tr>
<tr>
<th>
Consumption
</th>
<td>
<BytesFormatted
value={0.815306356729712}
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`;
exports[`ClusterWidget when cluster is online matches snapshot 1`] = `
<div
className="column is-full-modile is-6"
>
<div
className="box"
>
<div
className="title is-6 has-text-overflow-ellipsis"
>
<div
className="tag mr-2 is-success"
>
online
</div>
secondLocal
</div>
<table
className="table is-fullwidth"
>
<tbody>
<tr>
<th>
Version
</th>
<td />
</tr>
<tr>
<th>
Brokers
</th>
<td>
<NavLink
to="/ui/clusters/secondLocal/brokers"
>
1
</NavLink>
</td>
</tr>
<tr>
<th>
Partitions
</th>
<td>
6
</td>
</tr>
<tr>
<th>
Topics
</th>
<td>
<NavLink
to="/ui/clusters/secondLocal/topics"
>
3
</NavLink>
</td>
</tr>
<tr>
<th>
Production
</th>
<td>
<BytesFormatted
value={0.00003061819685376472}
/>
</td>
</tr>
<tr>
<th>
Consumption
</th>
<td>
<BytesFormatted
value={5.737800890036267}
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`;

View file

@ -1,16 +1,9 @@
import React from 'react';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import ClustersWidgetContainer from './ClustersWidget/ClustersWidgetContainer';
const Dashboard: React.FC = () => (
<div className="section">
<div className="level">
<div className="level-item level-left">
<Breadcrumb>Dashboard</Breadcrumb>
</div>
</div>
<div>
<ClustersWidgetContainer />
</div>
);

View file

@ -5,10 +5,6 @@ import Dashboard from 'components/Dashboard/Dashboard';
const component = shallow(<Dashboard />);
describe('Dashboard', () => {
it('renders section', () => {
expect(component.exists('.section')).toBe(true);
});
it('renders ClustersWidget', () => {
expect(component.exists('Connect(ClustersWidget)')).toBe(true);
});

View file

@ -1,30 +0,0 @@
import React from 'react';
import Breadcrumb, {
BreadcrumbItem,
} from 'components/common/Breadcrumb/Breadcrumb';
import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
import { useParams, useRouteMatch } from 'react-router';
interface RouteParams {
clusterName: string;
}
const Breadcrumbs: React.FC = () => {
const { clusterName } = useParams<RouteParams>();
const isQuery = useRouteMatch(clusterKsqlDbQueryPath(clusterName));
if (!isQuery) {
return <Breadcrumb>KSQLDB</Breadcrumb>;
}
const links: BreadcrumbItem[] = [
{
label: 'KSQLDB',
href: clusterKsqlDbPath(clusterName),
},
];
return <Breadcrumb links={links}>Query</Breadcrumb>;
};
export default Breadcrumbs;

View file

@ -1,33 +0,0 @@
import React from 'react';
import { StaticRouter } from 'react-router';
import Breadcrumbs from 'components/KsqlDb/BreadCrumbs/BreadCrumbs';
import { mount } from 'enzyme';
import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
describe('BreadCrumbs', () => {
const clusterName = 'local';
const rootPathname = clusterKsqlDbPath(clusterName);
const queryPathname = clusterKsqlDbQueryPath(clusterName);
const setupComponent = (pathname: string) => (
<StaticRouter location={{ pathname }} context={{}}>
<Breadcrumbs />
</StaticRouter>
);
it('Renders root path', () => {
const component = mount(setupComponent(rootPathname));
expect(component.find({ children: 'KSQLDB' }).exists()).toBeTruthy();
expect(component.find({ children: 'Query' }).exists()).toBeFalsy();
});
it('Renders query path', () => {
const component = mount(setupComponent(queryPathname));
expect(
component.find('a').find({ children: 'KSQLDB' }).exists()
).toBeTruthy();
expect(component.find({ children: 'Query' }).exists()).toBeTruthy();
});
});

View file

@ -3,19 +3,13 @@ import { Switch, Route } from 'react-router-dom';
import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
import List from 'components/KsqlDb/List/List';
import Query from 'components/KsqlDb/Query/Query';
import Breadcrumbs from 'components/KsqlDb/BreadCrumbs/BreadCrumbs';
const KsqlDb: React.FC = () => {
return (
<div className="section">
<Switch>
<Route path={clusterKsqlDbPath()} component={Breadcrumbs} />
</Switch>
<Switch>
<Route exact path={clusterKsqlDbPath()} component={List} />
<Route exact path={clusterKsqlDbQueryPath()} component={Query} />
</Switch>
</div>
<Switch>
<Route exact path={clusterKsqlDbPath()} component={List} />
<Route exact path={clusterKsqlDbQueryPath()} component={Query} />
</Switch>
);
};

View file

@ -1,5 +1,4 @@
import Indicator from 'components/common/Dashboard/Indicator';
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
import * as Metrics from 'components/common/Metrics';
import PageLoader from 'components/common/PageLoader/PageLoader';
import ListItem from 'components/KsqlDb/List/ListItem';
import React, { FC, useEffect } from 'react';
@ -7,8 +6,11 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { fetchKsqlDbTables } from 'redux/actions/thunks/ksqlDb';
import { getKsqlDbTables } from 'redux/reducers/ksqlDb/selectors';
import { Link } from 'react-router-dom';
import { clusterKsqlDbQueryPath } from 'lib/paths';
import PageHeading from 'components/common/PageHeading/PageHeading';
import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { Button } from 'components/common/Button/Button';
const headers = [
{ Header: 'Type', accessor: 'type' },
@ -34,42 +36,40 @@ const List: FC = () => {
return (
<>
<MetricsWrapper wrapperClassName="is-justify-content-space-between">
<div className="column is-flex m-0 p-0">
<Indicator
className="level-left is-one-third mr-3"
label="Tables"
title="Tables"
fetching={fetching}
>
<PageHeading text="KSQL DB">
<Button
isLink
to={clusterKsqlDbQueryPath(clusterName)}
buttonType="primary"
buttonSize="M"
>
Execute KSQL request
</Button>
</PageHeading>
<Metrics.Wrapper>
<Metrics.Section>
<Metrics.Indicator label="Tables" title="Tables" fetching={fetching}>
{tablesCount}
</Indicator>
<Indicator
className="level-left is-one-third ml-3"
</Metrics.Indicator>
<Metrics.Indicator
label="Streams"
title="Streams"
fetching={fetching}
>
{streamsCount}
</Indicator>
</div>
<Link
to={clusterKsqlDbQueryPath(clusterName)}
className="button is-primary"
>
Execute ksql
</Link>
</MetricsWrapper>
<div className="box">
</Metrics.Indicator>
</Metrics.Section>
</Metrics.Wrapper>
<div>
{fetching ? (
<PageLoader />
) : (
<table className="table is-fullwidth">
<Table isFullwidth>
<thead>
<tr>
<th> </th>
{headers.map(({ Header, accessor }) => (
<th key={accessor}>{Header}</th>
<TableHeaderCell title={Header} key={accessor} />
))}
</tr>
</thead>
@ -79,11 +79,13 @@ const List: FC = () => {
))}
{rows.length === 0 && (
<tr>
<td colSpan={headers.length}>No tables or streams found</td>
<td colSpan={headers.length + 1}>
No tables or streams found
</td>
</tr>
)}
</tbody>
</table>
</Table>
)}
</div>
</>

View file

@ -1,3 +1,5 @@
import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper';
import MessageToggleIcon from 'components/common/Icons/MessageToggleIcon';
import React from 'react';
interface Props {
@ -16,13 +18,9 @@ const ListItem: React.FC<Props> = ({ accessors, data }) => {
<>
<tr>
<td>
<span
className="icon has-text-link is-size-7 is-small is-clickable"
onClick={toggleIsOpen}
aria-hidden
>
<i className={`fas fa-${isOpen ? 'minus' : 'plus'}`} />
</span>
<IconButtonWrapper onClick={toggleIsOpen}>
<MessageToggleIcon isOpen={isOpen} />
</IconButtonWrapper>
</td>
{accessors.map((accessor) => (
<td key={accessor}>{data[accessor]}</td>

View file

@ -1,63 +1,37 @@
import React from 'react';
import List from 'components/KsqlDb/List/List';
import { mount } from 'enzyme';
import { StaticRouter } from 'react-router';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import { RootState } from 'redux/interfaces';
import { fetchKsqlDbTablesPayload } from 'redux/reducers/ksqlDb/__test__/fixtures';
import { Route, Router } from 'react-router';
import { createMemoryHistory } from 'history';
import { clusterKsqlDbPath } from 'lib/paths';
import { render } from 'lib/testHelpers';
import fetchMock from 'fetch-mock';
import { screen, waitForElementToBeRemoved } from '@testing-library/dom';
const emptyPlaceholder = 'No tables or streams found';
const history = createMemoryHistory();
const clusterName = 'local';
const mockStore = configureStore();
const renderComponent = () => {
history.push(clusterKsqlDbPath(clusterName));
render(
<Router history={history}>
<Route path={clusterKsqlDbPath(':clusterName')}>
<List />
</Route>
</Router>
);
};
describe('KsqlDb List', () => {
const pathname = `ui/clusters/local/ksql-db`;
it('Renders placeholder on empty data', () => {
const initialState: Partial<RootState> = {
ksqlDb: {
tables: [],
streams: [],
executionResult: null,
afterEach(() => fetchMock.reset());
it('renders placeholder on empty data', async () => {
fetchMock.post(
{
url: `/api/clusters/${clusterName}/ksql`,
},
loader: {
GET_KSQL_DB_TABLES_AND_STREAMS: 'fetched',
},
};
const store = mockStore(initialState);
const component = mount(
<StaticRouter location={{ pathname }} context={{}}>
<Provider store={store}>
<List />
</Provider>
</StaticRouter>
{ data: [] }
);
expect(
component.find({ children: emptyPlaceholder }).exists()
).toBeTruthy();
});
it('Renders rows', () => {
const initialState: Partial<RootState> = {
ksqlDb: { ...fetchKsqlDbTablesPayload, executionResult: null },
loader: {
GET_KSQL_DB_TABLES_AND_STREAMS: 'fetched',
},
};
const store = mockStore(initialState);
const component = mount(
<StaticRouter location={{ pathname }} context={{}}>
<Provider store={store}>
<List />
</Provider>
</StaticRouter>
);
// 2 streams, 2 tables and 1 head tr
expect(component.find('tr').length).toEqual(5);
renderComponent();
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
expect(screen.getByText('No tables or streams found')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,26 @@
import styled from 'styled-components';
export const QueryWrapper = styled.div`
padding: 16px;
`;
export const KSQLInputsWrapper = styled.div`
width: 100%;
display: flex;
gap: 24px;
padding-bottom: 16px;
& > div {
flex-grow: 1;
}
`;
export const KSQLInputHeader = styled.div`
display: flex;
justify-content: space-between;
`;
export const KSQLButtons = styled.div`
display: flex;
gap: 16px;
`;

Some files were not shown because too many files have changed in this diff Show more