From d09b70b9dcc4ab3be4c15818f9739bb435980d17 Mon Sep 17 00:00:00 2001
From: Michael Edgar
Date: Wed, 29 Apr 2026 20:09:42 -0400
Subject: [PATCH] Experiment with React data-view for Kafka clusters table
Signed-off-by: Michael Edgar
---
api/src/main/webui/package-lock.json | 328 +++++++++++-
api/src/main/webui/package.json | 1 +
api/src/main/webui/src/api/client.ts | 12 +-
.../webui/src/api/hooks/useKafkaClusters.ts | 54 +-
.../webui/src/api/hooks/useResourceList.ts | 106 ++++
api/src/main/webui/src/api/types.ts | 26 +-
.../common/ResourceListDataView.tsx | 494 ++++++++++++++++++
.../src/components/home/ClustersDataView.tsx | 151 ++++++
.../src/components/home/ClustersTable.tsx | 204 --------
api/src/main/webui/src/pages/HomePage.tsx | 98 +---
.../webui/src/pages/kafka/KafkaLayout.tsx | 9 +-
api/src/main/webui/vite.config.ts | 3 +-
ui/tests/playwright/auth.setup.ts | 6 +-
13 files changed, 1133 insertions(+), 359 deletions(-)
create mode 100644 api/src/main/webui/src/api/hooks/useResourceList.ts
create mode 100644 api/src/main/webui/src/components/common/ResourceListDataView.tsx
create mode 100644 api/src/main/webui/src/components/home/ClustersDataView.tsx
delete mode 100644 api/src/main/webui/src/components/home/ClustersTable.tsx
diff --git a/api/src/main/webui/package-lock.json b/api/src/main/webui/package-lock.json
index 06bd75acc..507d675fb 100644
--- a/api/src/main/webui/package-lock.json
+++ b/api/src/main/webui/package-lock.json
@@ -11,6 +11,7 @@
"@patternfly/patternfly": "^6.4.0",
"@patternfly/react-charts": "^8.4.1",
"@patternfly/react-core": "^6.4.3",
+ "@patternfly/react-data-view": "^6.4.0",
"@patternfly/react-drag-drop": "^6.4.3",
"@patternfly/react-icons": "^6.4.0",
"@patternfly/react-styles": "^6.4.0",
@@ -414,6 +415,21 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@emotion/is-prop-valid": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.7.3.tgz",
+ "integrity": "sha512-uxJqm/sqwXw3YPA5GXX365OBcJGFtxUVkB6WyezqFHlNe9jqUWH5ur2O2M8dGBz61kn1g3ZBlzUunFQXQIClhA==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/memoize": "0.7.1"
+ }
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.1.tgz",
+ "integrity": "sha512-Qv4LTqO11jepd5Qmlp3M1YEjBumoTHcHFdgPTQ+sFlIL5myi/7xu/POwP7IRu6odBdmLXdtIs1D6TuW6kbwbbg==",
+ "license": "MIT"
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -757,6 +773,24 @@
}
}
},
+ "node_modules/@patternfly/react-component-groups": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/@patternfly/react-component-groups/-/react-component-groups-6.4.0.tgz",
+ "integrity": "sha512-vg0761nQ/7hfggbp6+XowRcQQSd9oIToh77+4lmsyrs41MkA5ppQIPBCZ4lUZW87kmEPhkHqglpJcVfsrrIM/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@patternfly/react-core": "^6.0.0",
+ "@patternfly/react-icons": "^6.0.0",
+ "@patternfly/react-styles": "^6.0.0",
+ "@patternfly/react-table": "^6.0.0",
+ "react-jss": "^10.10.0"
+ },
+ "peerDependencies": {
+ "@patternfly/react-drag-drop": "^6.0.0",
+ "react": "^17 || ^18 || ^19",
+ "react-dom": "^17 || ^18 || ^19"
+ }
+ },
"node_modules/@patternfly/react-core": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.4.3.tgz",
@@ -775,6 +809,24 @@
"react-dom": "^17 || ^18 || ^19"
}
},
+ "node_modules/@patternfly/react-data-view": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/@patternfly/react-data-view/-/react-data-view-6.4.0.tgz",
+ "integrity": "sha512-AYIJvWLSoZaf3askvBjyyFQEvSCiquw5PFzEOiTsNoM2pDYkRagzppjclpI+MRJr44ZrfpljC6ZKE4f5Ni2p+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@patternfly/react-component-groups": "^6.1.0",
+ "@patternfly/react-core": "^6.4.0",
+ "@patternfly/react-icons": "^6.4.0",
+ "@patternfly/react-table": "^6.4.0",
+ "clsx": "^2.1.1",
+ "react-jss": "^10.10.0"
+ },
+ "peerDependencies": {
+ "react": "^17 || ^18 || ^19",
+ "react-dom": "^17 || ^18 || ^19"
+ }
+ },
"node_modules/@patternfly/react-drag-drop": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/@patternfly/react-drag-drop/-/react-drag-drop-6.4.3.tgz",
@@ -1658,6 +1710,15 @@
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1693,11 +1754,31 @@
"node": ">= 8"
}
},
+ "node_modules/css-jss": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/css-jss/-/css-jss-10.10.0.tgz",
+ "integrity": "sha512-YyMIS/LsSKEGXEaVJdjonWe18p4vXLo8CMA4FrW/kcaEyqdIGKCFXao31gbJddXEdIxSXFFURWrenBJPlKTgAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "^10.10.0",
+ "jss-preset-default": "^10.10.0"
+ }
+ },
+ "node_modules/css-vendor": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz",
+ "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.3",
+ "is-in-browser": "^1.0.2"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -2328,6 +2409,12 @@
"void-elements": "3.1.0"
}
},
+ "node_modules/hyphenate-style-name": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
+ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/i18next": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
@@ -2408,6 +2495,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-in-browser": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
+ "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==",
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2474,6 +2567,172 @@
"node": ">=6"
}
},
+ "node_modules/jss": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz",
+ "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "csstype": "^3.0.2",
+ "is-in-browser": "^1.1.3",
+ "tiny-warning": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/jss"
+ }
+ },
+ "node_modules/jss-plugin-camel-case": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz",
+ "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "hyphenate-style-name": "^1.0.3",
+ "jss": "10.10.0"
+ }
+ },
+ "node_modules/jss-plugin-compose": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-compose/-/jss-plugin-compose-10.10.0.tgz",
+ "integrity": "sha512-F5kgtWpI2XfZ3Z8eP78tZEYFdgTIbpA/TMuX3a8vwrNolYtN1N4qJR/Ob0LAsqIwCMLojtxN7c7Oo/+Vz6THow==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0",
+ "tiny-warning": "^1.0.2"
+ }
+ },
+ "node_modules/jss-plugin-default-unit": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz",
+ "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0"
+ }
+ },
+ "node_modules/jss-plugin-expand": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-expand/-/jss-plugin-expand-10.10.0.tgz",
+ "integrity": "sha512-ymT62W2OyDxBxr7A6JR87vVX9vTq2ep5jZLIdUSusfBIEENLdkkc0lL/Xaq8W9s3opUq7R0sZQpzRWELrfVYzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0"
+ }
+ },
+ "node_modules/jss-plugin-extend": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-extend/-/jss-plugin-extend-10.10.0.tgz",
+ "integrity": "sha512-sKYrcMfr4xxigmIwqTjxNcHwXJIfvhvjTNxF+Tbc1NmNdyspGW47Ey6sGH8BcQ4FFQhLXctpWCQSpDwdNmXSwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0",
+ "tiny-warning": "^1.0.2"
+ }
+ },
+ "node_modules/jss-plugin-global": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz",
+ "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0"
+ }
+ },
+ "node_modules/jss-plugin-nested": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz",
+ "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0",
+ "tiny-warning": "^1.0.2"
+ }
+ },
+ "node_modules/jss-plugin-props-sort": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz",
+ "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0"
+ }
+ },
+ "node_modules/jss-plugin-rule-value-function": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz",
+ "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0",
+ "tiny-warning": "^1.0.2"
+ }
+ },
+ "node_modules/jss-plugin-rule-value-observable": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-observable/-/jss-plugin-rule-value-observable-10.10.0.tgz",
+ "integrity": "sha512-ZLMaYrR3QE+vD7nl3oNXuj79VZl9Kp8/u6A1IbTPDcuOu8b56cFdWRZNZ0vNr8jHewooEeq2doy8Oxtymr2ZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0",
+ "symbol-observable": "^1.2.0"
+ }
+ },
+ "node_modules/jss-plugin-template": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-template/-/jss-plugin-template-10.10.0.tgz",
+ "integrity": "sha512-ocXZBIOJOA+jISPdsgkTs8wwpK6UbsvtZK5JI7VUggTD6LWKbtoxUzadd2TpfF+lEtlhUmMsCkTRNkITdPKa6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0",
+ "tiny-warning": "^1.0.2"
+ }
+ },
+ "node_modules/jss-plugin-vendor-prefixer": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz",
+ "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "css-vendor": "^2.0.8",
+ "jss": "10.10.0"
+ }
+ },
+ "node_modules/jss-preset-default": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/jss-preset-default/-/jss-preset-default-10.10.0.tgz",
+ "integrity": "sha512-GL175Wt2FGhjE+f+Y3aWh+JioL06/QWFgZp53CbNNq6ZkVU0TDplD8Bxm9KnkotAYn3FlplNqoW5CjyLXcoJ7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "jss": "10.10.0",
+ "jss-plugin-camel-case": "10.10.0",
+ "jss-plugin-compose": "10.10.0",
+ "jss-plugin-default-unit": "10.10.0",
+ "jss-plugin-expand": "10.10.0",
+ "jss-plugin-extend": "10.10.0",
+ "jss-plugin-global": "10.10.0",
+ "jss-plugin-nested": "10.10.0",
+ "jss-plugin-props-sort": "10.10.0",
+ "jss-plugin-rule-value-function": "10.10.0",
+ "jss-plugin-rule-value-observable": "10.10.0",
+ "jss-plugin-template": "10.10.0",
+ "jss-plugin-vendor-prefixer": "10.10.0"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3027,6 +3286,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-display-name": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/react-display-name/-/react-display-name-0.2.5.tgz",
+ "integrity": "sha512-I+vcaK9t4+kypiSgaiVWAipqHRXYmZIuAiS8vzFvXHHXVigg/sMKwlRgLy6LH2i3rmP+0Vzfl5lFsFRwF1r3pg==",
+ "license": "MIT"
+ },
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
@@ -3095,6 +3360,28 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/react-jss": {
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/react-jss/-/react-jss-10.10.0.tgz",
+ "integrity": "sha512-WLiq84UYWqNBF6579/uprcIUnM1TSywYq6AIjKTTTG5ziJl9Uy+pwuvpN3apuyVwflMbD60PraeTKT7uWH9XEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "@emotion/is-prop-valid": "^0.7.3",
+ "css-jss": "10.10.0",
+ "hoist-non-react-statics": "^3.2.0",
+ "is-in-browser": "^1.1.3",
+ "jss": "10.10.0",
+ "jss-preset-default": "10.10.0",
+ "prop-types": "^15.6.0",
+ "shallow-equal": "^1.2.0",
+ "theming": "^3.3.0",
+ "tiny-warning": "^1.0.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.6"
+ }
+ },
"node_modules/react-router": {
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
@@ -3198,6 +3485,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
+ "node_modules/shallow-equal": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
+ "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3231,12 +3524,45 @@
"node": ">=0.10.0"
}
},
+ "node_modules/symbol-observable": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
+ "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
+ "node_modules/theming": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/theming/-/theming-3.3.0.tgz",
+ "integrity": "sha512-u6l4qTJRDaWZsqa8JugaNt7Xd8PPl9+gonZaIe28vAhqgHMIG/DOyFPqiKN/gQLQYj05tHv+YQdNILL4zoiAVA==",
+ "license": "MIT",
+ "dependencies": {
+ "hoist-non-react-statics": "^3.3.0",
+ "prop-types": "^15.5.8",
+ "react-display-name": "^0.2.4",
+ "tiny-warning": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "peerDependencies": {
+ "react": ">=16.3"
+ }
+ },
+ "node_modules/tiny-warning": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
diff --git a/api/src/main/webui/package.json b/api/src/main/webui/package.json
index a267eacf1..76433590c 100644
--- a/api/src/main/webui/package.json
+++ b/api/src/main/webui/package.json
@@ -13,6 +13,7 @@
"@patternfly/patternfly": "^6.4.0",
"@patternfly/react-charts": "^8.4.1",
"@patternfly/react-core": "^6.4.3",
+ "@patternfly/react-data-view": "^6.4.0",
"@patternfly/react-drag-drop": "^6.4.3",
"@patternfly/react-icons": "^6.4.0",
"@patternfly/react-styles": "^6.4.0",
diff --git a/api/src/main/webui/src/api/client.ts b/api/src/main/webui/src/api/client.ts
index c75b3e250..c3fe1ff49 100644
--- a/api/src/main/webui/src/api/client.ts
+++ b/api/src/main/webui/src/api/client.ts
@@ -68,17 +68,7 @@ class ApiClient {
return {} as T;
}
- const data = await response.json();
-
- if (!response.ok) {
- throw new ApiError(
- response.status,
- response.statusText,
- data.errors
- );
- }
-
- return data;
+ return await response.json();
}
/**
diff --git a/api/src/main/webui/src/api/hooks/useKafkaClusters.ts b/api/src/main/webui/src/api/hooks/useKafkaClusters.ts
index b32c18079..6b4a13ca7 100644
--- a/api/src/main/webui/src/api/hooks/useKafkaClusters.ts
+++ b/api/src/main/webui/src/api/hooks/useKafkaClusters.ts
@@ -4,60 +4,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../client';
-import { ApiResponse, KafkaCluster, KafkaClustersResponse } from '../types';
+import { ApiResponse, KafkaCluster } from '../types';
+import { ResourceListParams, useResourceList } from './useResourceList';
/**
* Fetch all Kafka clusters
*/
-export function useKafkaClusters(params?: {
- pageSize?: number;
- pageCursor?: string;
- sort?: string;
- sortDir?: 'asc' | 'desc';
- name?: string;
-}) {
- return useQuery({
- queryKey: [
- 'kafka-clusters',
- params?.pageSize,
- params?.pageCursor,
- params?.sort,
- params?.sortDir,
- params?.name,
- ],
- queryFn: async () => {
- const searchParams = new URLSearchParams();
-
- if (params?.pageSize) {
- searchParams.set('page[size]', String(params.pageSize));
- }
-
- // Handle cursor-based pagination
- if (params?.pageCursor) {
- if (params.pageCursor.startsWith('after:')) {
- searchParams.set('page[after]', params.pageCursor.slice(6));
- } else if (params.pageCursor.startsWith('before:')) {
- searchParams.set('page[before]', params.pageCursor.slice(7));
- }
- }
-
- // Handle sorting
- if (params?.sort) {
- const sortPrefix = params.sortDir === 'desc' ? '-' : '';
- searchParams.set('sort', `${sortPrefix}${params.sort}`);
- }
-
- // Handle name filter
- if (params?.name) {
- searchParams.set('filter[name]', `like,*${params.name}*`);
- }
-
- const queryString = searchParams.toString();
- const path = `/api/kafkas${queryString ? `?${queryString}` : ''}`;
-
- return apiClient.get(path);
- },
- });
+export function useKafkaClusters(params?: ResourceListParams) {
+ return useResourceList('kafkas', '/api/kafkas', params);
}
/**
diff --git a/api/src/main/webui/src/api/hooks/useResourceList.ts b/api/src/main/webui/src/api/hooks/useResourceList.ts
new file mode 100644
index 000000000..1b7f9b2a8
--- /dev/null
+++ b/api/src/main/webui/src/api/hooks/useResourceList.ts
@@ -0,0 +1,106 @@
+/**
+ * TanStack Query hooks for generic resource lists
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { apiClient } from '../client';
+import { ListResponse, Resource } from '../types';
+
+export interface ResourceListPageParams {
+ size?: number | null;
+ beforeCursor?: string | null;
+ afterCursor?: string | null;
+ sort?: {
+ field: string;
+ direction?: 'asc' | 'desc';
+ } | string;
+}
+
+export interface ResourceListParams {
+ /**
+ * Parameters for pagination (page size, sorting, etc.)
+ */
+ page?: ResourceListPageParams;
+ /**
+ * Parameters for filtering by specific fields (search, etc.)
+ */
+ filters?: Record;
+ /**
+ * Comma-separated list of fields to include in the response.
+ * @example
+ * fields=name,status
+ */
+ fields?: string;
+
+ /**
+ * Whether the query should be enabled or not.
+ * @default true
+ */
+ enabled?: boolean;
+
+ /**
+ * If set, the query will continuously refetch at this frequency in milliseconds.
+ */
+ refreshInterval?: number;
+}
+
+function updatePageParams(page: ResourceListPageParams, searchParams: URLSearchParams) {
+ if (page.size) {
+ searchParams.set('page[size]', String(page.size));
+ }
+
+ // Handle cursor-based pagination
+ if (page.afterCursor) {
+ searchParams.set('page[after]', page.afterCursor);
+ } else if (page.beforeCursor) {
+ searchParams.set('page[before]', page.beforeCursor);
+ }
+
+ // Handle sorting
+ if (page.sort) {
+ if (typeof page.sort === 'string') {
+ searchParams.set('sort', page.sort);
+ } else {
+ const sortPrefix = page.sort.direction === 'desc' ? '-' : '';
+ searchParams.set('sort', `${sortPrefix}${page.sort.field}`);
+ }
+ }
+}
+
+export function useResourceList(
+ resourceType: string,
+ path: string,
+ params?: ResourceListParams,
+) {
+ return useQuery({
+ queryKey: [
+ resourceType + '-resource-list-query',
+ JSON.stringify(params),
+ ],
+ queryFn: async () => {
+ const searchParams = new URLSearchParams();
+
+ if (params?.page) {
+ updatePageParams(params.page, searchParams);
+ }
+
+ // Handle name filter
+ if (params?.filters) {
+ Object.entries(params.filters).forEach(([key, value]) => {
+ searchParams.set(`filter[${key}]`, `like,*${value}*`);
+ });
+ }
+
+ if (params?.fields) {
+ searchParams.set(`fields[${resourceType}]`, params.fields);
+ }
+
+ const queryString = searchParams.toString();
+ const url = path + (queryString ? `?${queryString}` : '');
+ return apiClient.get>(url);
+ },
+ enabled: params?.enabled,
+ refetchInterval: params?.refreshInterval,
+ placeholderData: (previousData) => previousData, // Keep previous data while loading new page
+ });
+}
diff --git a/api/src/main/webui/src/api/types.ts b/api/src/main/webui/src/api/types.ts
index ad6dd1b8a..225b4fd84 100644
--- a/api/src/main/webui/src/api/types.ts
+++ b/api/src/main/webui/src/api/types.ts
@@ -6,6 +6,24 @@
*/
// Common types
+export interface ListResponse {
+ meta?: {
+ page: {
+ total: number;
+ pageNumber: number;
+ rangeTruncated: boolean;
+ } & Record;
+ };
+ links?: {
+ first?: string;
+ last?: string;
+ prev?: string;
+ next?: string;
+ };
+ data?: T[];
+ errors?: ApiError[];
+}
+
export interface ApiResponse {
data?: T;
errors?: ApiError[];
@@ -33,12 +51,12 @@ export interface MetaWithPrivileges {
privileges?: string[];
}
-export interface Resource> {
+export interface Resource {
type: string;
id: string;
- attributes?: T;
+ attributes?: Record;
relationships?: Record;
- meta?: Record;
+ meta?: object;
}
// Kafka Cluster types
@@ -57,7 +75,7 @@ export interface KafkaClusterCondition {
lastTransitionTime?: string;
}
-export interface KafkaCluster {
+export interface KafkaCluster extends Resource {
id: string;
type: 'kafkas';
attributes: {
diff --git a/api/src/main/webui/src/components/common/ResourceListDataView.tsx b/api/src/main/webui/src/components/common/ResourceListDataView.tsx
new file mode 100644
index 000000000..d46b9a61f
--- /dev/null
+++ b/api/src/main/webui/src/components/common/ResourceListDataView.tsx
@@ -0,0 +1,494 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import {
+ DataView,
+ DataViewTable,
+ DataViewToolbar,
+ DataViewTextFilter,
+ useDataViewFilters,
+ useDataViewPagination,
+ DataViewEventsProvider,
+ DataViewTh,
+ DataViewTr,
+ DataViewState,
+ useDataViewSort,
+} from '@patternfly/react-data-view';
+import {
+ Pagination,
+ EmptyState,
+ EmptyStateBody,
+} from '@patternfly/react-core';
+import { SkeletonTableBody, SkeletonTableHead } from '@patternfly/react-component-groups';
+import { SearchIcon, CubesIcon, ErrorCircleOIcon } from '@patternfly/react-icons';
+import { ISortBy, Tbody, Td, Tr } from '@patternfly/react-table';
+import { ListResponse, Resource } from '@/api/types';
+import { DataViewFilters } from '@patternfly/react-data-view/dist/dynamic/DataViewFilters';
+import { ResourceListParams } from '@/api/hooks/useResourceList';
+
+const perPageOptions = [
+ { title: '5', value: 5 },
+ { title: '10', value: 10 },
+ { title: '20', value: 20 },
+ { title: '50', value: 50 },
+ { title: '100', value: 100 },
+];
+
+const DEFAULT_PAGE_SIZE = 10;
+
+export interface ResourceListDataViewColumnMapper {
+ (
+ sortBy?: string,
+ direction?: ISortBy['direction'],
+ onSort?: (
+ _event: React.MouseEvent | React.KeyboardEvent | MouseEvent | undefined,
+ newSortBy: string,
+ newSortDirection: ISortBy["direction"]
+ ) => void,
+ ): DataViewTh[];
+}
+
+export interface ResourceListDataViewRowMapper {
+ (entity: T): DataViewTr;
+}
+
+export interface ResourceListDataViewProps {
+ listResponse: ListResponse | undefined;
+ isLoading: boolean;
+ columnProvider: {
+ dependencies: unknown[];
+ callback: ResourceListDataViewColumnMapper;
+ };
+ rowProvider: {
+ dependencies: unknown[];
+ callback: ResourceListDataViewRowMapper;
+ };
+ dataFilters?: Record;
+ ariaLabel?: string;
+ ouiaIdPrefix?: string;
+ onDataViewChange: (params: ResourceListParams) => void;
+}
+
+export function ResourceListDataView({
+ listResponse,
+ isLoading = false,
+ columnProvider,
+ rowProvider,
+ dataFilters,
+ ariaLabel,
+ ouiaIdPrefix = 'noid',
+ onDataViewChange,
+}: ResourceListDataViewProps) {
+
+ const { t } = useTranslation();
+ const [ searchParams, setSearchParams ] = useSearchParams();
+
+ // Map user-provided filter config to empty initial values
+ const initialFilters = useMemo(() => {
+ if (!dataFilters) {
+ return {};
+ }
+
+ return Object.entries(dataFilters).reduce((acc, [filterId, config]) => {
+ // Initialize based on filter type
+ if (config.type === 'checkbox') {
+ // Empty array for checkbox filters
+ acc[filterId] = config.initialValue ?? [];
+ } else {
+ // Empty string for text filters
+ acc[filterId] = config.initialValue ?? '';
+ }
+ return acc;
+ }, {} as Record);
+ }, [ dataFilters ]);
+
+ const {
+ filters,
+ onSetFilters,
+ clearAllFilters
+ } = useDataViewFilters>({
+ initialFilters,
+ searchParams,
+ setSearchParams: () => {
+ /* Do nothing (block the hook's update of the params).
+ *
+ * The `useDataViewFilters` hook requires both search param functions
+ * in order to retrieve current filters from the URL. URL updates are handled
+ * separately in a useEffect below to avoid race conditions between the hooks. */
+ },
+ });
+
+ const [ pendingFilters, setPendingFilters ] = useState>({});
+ const [ beforeCursor, setBeforeCursor ] = useState(
+ searchParams.get("page[before]") ?? undefined
+ );
+ const [ afterCursor, setAfterCursor ] = useState(
+ searchParams.get("page[after]") ?? undefined
+ );
+
+ /*
+ * synthetic URLSearchParams to convert single sort parameter
+ * to two parameters expected by the hook
+ */
+ const sortSearchParams = useMemo(() => {
+ const params = new URLSearchParams();
+ const sort = searchParams.get("sort");
+ if (sort) {
+ let sortBy: string;
+
+ if (sort.startsWith("-")) {
+ params.set("direction", "desc");
+ sortBy = sort.substring(1);
+ } else {
+ params.set("direction", "asc");
+ sortBy = sort;
+ }
+
+ params.set("sortBy", sortBy);
+ }
+ return params;
+ }, [searchParams.get("sort")]);
+
+ const { sortBy, direction, onSort } = useDataViewSort({
+ searchParams: sortSearchParams,
+ });
+
+ const totalCount = listResponse?.meta?.page?.total ?? 0;
+
+ // Track current page number locally to avoid flashing during navigation
+ const [currentPage, setCurrentPage] = useState(listResponse?.meta?.page?.pageNumber ?? 1);
+
+ // Update current page when API response arrives
+ useEffect(() => {
+ if (listResponse?.meta?.page?.pageNumber) {
+ setCurrentPage(listResponse.meta.page.pageNumber);
+ }
+ }, [listResponse?.meta?.page?.pageNumber]);
+
+ // Reset to page 1 when filters or sort change
+ useEffect(() => {
+ const hasFilters = Object.values(filters).some(v =>
+ Array.isArray(v) ? v.length > 0 : v?.trim().length > 0
+ );
+ if (hasFilters || sortBy) {
+ setCurrentPage(listResponse?.meta?.page?.pageNumber ?? 1);
+ }
+ }, [filters, sortBy]);
+
+ /*
+ * synthetic URLSearchParams that includes the current page number.
+ * Uses local state to avoid flashing during navigation.
+ */
+ const paginationSearchParams = useMemo(() => {
+ const params = new URLSearchParams();
+ params.set('page', String(currentPage));
+
+ // Only copy the page size param if it exists
+ const pageSize = searchParams.get('page[size]');
+ if (pageSize) {
+ params.set('page[size]', pageSize);
+ }
+
+ return params;
+ }, [currentPage, searchParams.get('page[size]')]);
+
+ // DataView manages pagination state internally
+ const pagination = useDataViewPagination({
+ perPage: DEFAULT_PAGE_SIZE,
+ perPageParam: 'page[size]',
+ searchParams: paginationSearchParams,
+ });
+
+ const { perPage } = pagination;
+
+ // Memoized pagination handlers
+ const handlePerPageSelect = useCallback((event: React.MouseEvent | React.KeyboardEvent | MouseEvent, newPerPage: number) => {
+ setBeforeCursor(undefined);
+ setAfterCursor(undefined);
+ pagination.onPerPageSelect(event, newPerPage);
+ }, [pagination.onPerPageSelect]);
+
+ const handleNextPage = useCallback(() => {
+ const nextPage = listResponse?.links?.next;
+ const cursor = new URLSearchParams(nextPage).get("page[after]") ?? undefined;
+ setBeforeCursor(undefined);
+ setAfterCursor(cursor);
+ }, [listResponse?.links?.next]);
+
+ const handlePreviousPage = useCallback(() => {
+ const prevPage = listResponse?.links?.prev;
+ const cursor = new URLSearchParams(prevPage).get("page[before]") ?? undefined;
+ setBeforeCursor(cursor);
+ setAfterCursor(undefined);
+ }, [listResponse?.links?.prev]);
+
+ // Memoized filter handlers
+ const handleClearAllFilters = useCallback(() => {
+ clearAllFilters();
+ setPendingFilters({});
+ }, [clearAllFilters]);
+
+ const handleFilterChange = useCallback((_key: string, newValues: Partial>) => {
+ onSetFilters(newValues as Record);
+ }, [onSetFilters]);
+
+ // Custom hook for text filter handlers
+ const useTextFilterHandlers = (filterId: string) => {
+ const handleChange = useCallback((_event: React.FormEvent | undefined, value: string) => {
+ if (value) {
+ setPendingFilters(prev => ({ ...prev, [filterId]: value }));
+ } else {
+ onSetFilters({ [filterId]: '' });
+ setPendingFilters(prev => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [filterId]: _, ...rest } = prev;
+ return rest;
+ });
+ }
+ }, [filterId]);
+
+ const handleClear = useCallback(() => {
+ onSetFilters({ [filterId]: '' });
+ setPendingFilters(prev => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [filterId]: _, ...rest } = prev;
+ return rest;
+ });
+ }, [filterId]);
+
+ const handleSearch = useCallback((_event: React.SyntheticEvent, value: string) => {
+ onSetFilters({ [filterId]: pendingFilters[filterId] ?? value });
+ setPendingFilters(prev => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [filterId]: _, ...rest } = prev;
+ return rest;
+ });
+ }, [filterId, pendingFilters[filterId]]);
+
+ return { handleChange, handleClear, handleSearch };
+ };
+
+ // Manually sync to URL in a single effect
+ useEffect(() => {
+ setSearchParams(params => {
+ const newParams = new URLSearchParams();
+ let filtersChanged = false;
+
+ Object.entries(filters).forEach(([key, value]) => {
+ const oldValue = params.get(key) ?? undefined;
+
+ // Normalize values for comparison
+ let newValue: string | undefined;
+ const oldNormalized: string | undefined = oldValue;
+
+ if (value) {
+ if (Array.isArray(value)) {
+ newValue = value.join(',');
+ } else {
+ newValue = value;
+ }
+ newParams.set(key, newValue);
+ } else {
+ newValue = undefined;
+ }
+
+ // Compare normalized values
+ if (oldNormalized !== newValue) {
+ filtersChanged = true;
+ }
+ });
+
+ let sort: string | undefined;
+
+ if (sortBy && direction) {
+ sort = `${direction === 'desc' ? '-' : ''}${sortBy}`;
+ newParams.set('sort', sort);
+ }
+
+ const oldSort = params.get('sort') ?? undefined;
+
+ if (perPage && perPage !== DEFAULT_PAGE_SIZE) {
+ newParams.set('page[size]', String(perPage));
+ }
+
+ if (!filtersChanged && oldSort === sort) {
+ // Only set page cursors if sorting is unchanged
+ if (beforeCursor) {
+ newParams.set('page[before]', beforeCursor);
+ }
+
+ if (afterCursor) {
+ newParams.set('page[after]', afterCursor);
+ }
+ }
+
+ return newParams;
+ }, { replace: true }); // Replace current history entry instead of pushing new one
+ }, [filters, sortBy, direction, perPage, beforeCursor, afterCursor]);
+
+ // Define columns
+ const columns: DataViewTh[] = useMemo(() => {
+ return columnProvider.callback(sortBy, direction, onSort);
+ }, [sortBy, direction, ...columnProvider.dependencies]);
+
+ // Determine the active state, errors, and table rows for DataView
+ const [ activeState, errors, rows ] = useMemo(() => {
+ if (isLoading) {
+ return [ DataViewState.loading, undefined, [] ];
+ }
+
+ if (listResponse?.errors) {
+ return [ DataViewState.error, listResponse.errors, [] ];
+ }
+
+ if (listResponse?.data && listResponse?.data.length === 0) {
+ return [ DataViewState.empty, [], [] ];
+ }
+
+ return [
+ undefined,
+ [],
+ listResponse?.data?.map(entry => rowProvider.callback(entry)) ?? []
+ ];
+ }, [ isLoading, listResponse, ...rowProvider.dependencies ]);
+
+ useEffect(() => {
+ const pageSize = searchParams.get('page[size]');
+ const searchFilters: Record = {};
+
+ Object.entries(filters).forEach(([ key, value ]) => {
+ if (value) {
+ searchFilters[key] = value;
+ }
+ });
+
+ const modifiedParams: ResourceListParams = {
+ filters: searchFilters,
+ page: {
+ size: pageSize ? Number(pageSize) : DEFAULT_PAGE_SIZE,
+ sort: searchParams.get('sort') ?? undefined,
+ beforeCursor: searchParams.get('page[before]'),
+ afterCursor: searchParams.get('page[after]'),
+ }
+ };
+
+ onDataViewChange(modifiedParams);
+ }, [ searchParams.toString(), filters ]);
+
+ // Use the same pagination component in the header and footer
+ const paginationControl = useMemo(() => {
+ return totalCount > 0 ? (
+
+ ) : undefined
+ }, [totalCount, pagination, handlePerPageSelect, handleNextPage, handlePreviousPage])
+
+ // Define empty state content
+ const emptyBody = useMemo(() => {
+ const isFiltered = Object.values(filters).some(value => {
+ if (Array.isArray(value)) {
+ return value.length > 0;
+ }
+ return value?.trim().length > 0
+ });
+
+ return (
+
+
+ |
+ { errors && errors.length > 0 ? (
+
+ {errors[0].detail}
+
+ ) : isFiltered ? (
+
+ {t('common.noResultsFoundDescription')}
+
+ ) : (
+
+ {t('common.noDataDescription')}
+
+ )}
+ |
+
+
+ );
+ }, [ errors, filters, columns, t ]);
+
+ const headLoading = useMemo(
+ () => ,
+ [columns]
+ );
+
+ const bodyLoading = useMemo(
+ () => ,
+ [columns.length]
+ );
+
+ return (
+
+
+ {/* Toolbar with filters and pagination */}
+
+ { Object.entries(dataFilters).map(([name, filter]) => {
+ if (filter.type === 'checkbox') {
+ return <>>; // TODO: Add checkbox filter component
+ } else {
+ const { handleChange, handleClear, handleSearch } = useTextFilterHandlers(name);
+ return ;
+ }
+ })}
+
+ }
+ pagination={paginationControl}
+ />
+
+ {/* Table view */}
+
+
+ {/* Bottom pagination */}
+
+
+
+ );
+}
diff --git a/api/src/main/webui/src/components/home/ClustersDataView.tsx b/api/src/main/webui/src/components/home/ClustersDataView.tsx
new file mode 100644
index 000000000..39ca1aeb9
--- /dev/null
+++ b/api/src/main/webui/src/components/home/ClustersDataView.tsx
@@ -0,0 +1,151 @@
+import { useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import {
+ Button,
+ Truncate,
+} from '@patternfly/react-core';
+import { ThProps } from '@patternfly/react-table';
+import { KafkaCluster, ListResponse } from '@/api/types';
+import { ResourceListParams } from '@/api/hooks/useResourceList';
+import {
+ ResourceListDataView,
+ ResourceListDataViewColumnMapper,
+ ResourceListDataViewRowMapper
+} from '../common/ResourceListDataView';
+
+const columnNames = ['name', 'namespace', 'version', 'status'];
+
+interface ClustersDataViewProps {
+ clusterResponse?: ListResponse;
+ isLoading?: boolean;
+ onDataViewChange: (params: ResourceListParams) => void;
+}
+
+export function ClustersDataView({
+ clusterResponse,
+ isLoading = false,
+ onDataViewChange,
+}: ClustersDataViewProps) {
+
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ // Memoized sort handler to avoid recreating on every render
+ const handleSort = useCallback((
+ onSort: ((event: React.MouseEvent, sortBy: string, direction: 'asc' | 'desc') => void) | undefined,
+ event: React.MouseEvent,
+ columnIndex: number,
+ direction: 'asc' | 'desc'
+ ) => {
+ onSort?.(event, columnNames[columnIndex], direction);
+ }, []);
+
+ const colMapper: ResourceListDataViewColumnMapper = useCallback(
+ (sortBy, direction, onSort) => [
+ {
+ cell: t('kafka.name'),
+ props: {
+ width: 25,
+ sort: {
+ sortBy: {
+ index: sortBy ? columnNames.indexOf(sortBy) : undefined,
+ direction: direction,
+ },
+ columnIndex: 0,
+ onSort: (event, columnIndex, direction) =>
+ handleSort(onSort, event, columnIndex, direction),
+ } as ThProps['sort'],
+ },
+ },
+ {
+ cell: t('kafka.namespace'),
+ },
+ {
+ cell: t('kafka.version'),
+ },
+ {
+ cell: t('kafka.status'),
+ },
+ {
+ cell: t('common.actions'),
+ props: {
+ modifier: 'fitContent',
+ },
+ },
+ ],
+ [t, handleSort]
+ );
+
+ const rowMapper: ResourceListDataViewRowMapper = useCallback(
+ (cluster) => ({
+ id: cluster.id,
+ row: [
+ {
+ cell: ,
+ props: {
+ dataLabel: t('kafka.name'),
+ },
+ },
+ {
+ cell: cluster.attributes.namespace || t('common.notAvailable', 'N/A'),
+ props: {
+ dataLabel: t('kafka.namespace'),
+ },
+ },
+ {
+ cell: cluster.attributes.kafkaVersion || t('common.notAvailable', 'N/A'),
+ props: {
+ dataLabel: t('kafka.version'),
+ },
+ },
+ {
+ cell: cluster.attributes.status || t('common.notAvailable', 'N/A'),
+ props: {
+ dataLabel: t('kafka.status'),
+ },
+ },
+ {
+ cell: (
+
+ ),
+ props: {
+ dataLabel: t('common.actions'),
+ modifier: 'fitContent',
+ },
+ },
+ ],
+ }),
+ [t, navigate]
+ );
+
+ return (
+
+ );
+}
diff --git a/api/src/main/webui/src/components/home/ClustersTable.tsx b/api/src/main/webui/src/components/home/ClustersTable.tsx
deleted file mode 100644
index a745262e4..000000000
--- a/api/src/main/webui/src/components/home/ClustersTable.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-import { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import {
- Table,
- Thead,
- Tr,
- Th,
- Tbody,
- Td,
- ThProps,
-} from '@patternfly/react-table';
-import {
- Button,
- Truncate,
- Pagination,
- PaginationVariant,
- Toolbar,
- ToolbarContent,
- ToolbarItem,
- SearchInput,
- Tooltip,
-} from '@patternfly/react-core';
-import { HelpIcon } from '@patternfly/react-icons';
-import { KafkaCluster } from '@/api/types';
-import { NoDataEmptyState, NoResultsEmptyState } from '@/components/EmptyStates';
-
-export function ClustersTable({
- clusters,
- totalCount,
- page,
- perPage,
- onPageChange,
- onPerPageChange,
- sortBy,
- sortDirection,
- onSort,
- filterName,
- onFilterNameChange,
-}: {
- clusters: KafkaCluster[] | undefined;
- totalCount?: number;
- page: number;
- perPage: number;
- onPageChange: (page: number) => void;
- onPerPageChange: (perPage: number) => void;
- sortBy?: string;
- sortDirection?: 'asc' | 'desc';
- onSort?: (column: string, direction: 'asc' | 'desc') => void;
- filterName?: string;
- onFilterNameChange?: (name: string) => void;
-}) {
- const { t } = useTranslation();
- const navigate = useNavigate();
- const [searchValue, setSearchValue] = useState(filterName || '');
-
- const getSortParams = (columnName: string): ThProps['sort'] | undefined => {
- if (!onSort) return undefined;
-
- return {
- sortBy: {
- index: sortBy === columnName ? 0 : undefined,
- direction: sortDirection || 'asc',
- },
- onSort: (_event, _index, direction) => {
- onSort(columnName, direction);
- },
- columnIndex: 0,
- };
- };
-
- const handleSearchSubmit = () => {
- if (onFilterNameChange) {
- onFilterNameChange(searchValue);
- }
- };
-
- const handleSearchClear = () => {
- setSearchValue('');
- if (onFilterNameChange) {
- onFilterNameChange('');
- }
- };
-
- if (!clusters || clusters.length === 0) {
- // Show "no results" if a filter is active, otherwise "no data"
- const hasActiveFilter = filterName && filterName.trim().length > 0;
-
- if (hasActiveFilter) {
- return (
-
- );
- }
-
- return (
-
- );
- }
-
- return (
- <>
- {onFilterNameChange && (
-
-
-
- setSearchValue(value)}
- onSearch={handleSearchSubmit}
- onClear={handleSearchClear}
- aria-label={t('kafka.filterByName', 'Filter by name')}
- />
-
- {totalCount !== undefined && totalCount > 0 && (
-
- onPageChange(page)}
- onPerPageSelect={(_event, perPage) => onPerPageChange(perPage)}
- variant={PaginationVariant.top}
- perPageOptions={[
- { title: '10', value: 10 },
- { title: '20', value: 20 },
- { title: '50', value: 50 },
- { title: '100', value: 100 },
- ]}
- isCompact
- />
-
- )}
-
-
- )}
-
-
-
- | {t('kafka.name')} |
- {t('kafka.namespace')} |
- {t('kafka.version')} |
-
- {t('kafka.status')}{' '}
-
-
-
- |
- {t('common.actions')} |
-
-
-
- {clusters.map((cluster) => (
-
- |
-
- |
-
- {cluster.attributes.namespace || t('common.notAvailable', 'N/A')}
- |
-
- {cluster.attributes.kafkaVersion || t('common.notAvailable', 'N/A')}
- |
-
- {cluster.attributes.status || t('common.notAvailable', 'N/A')}
- |
-
-
- |
-
- ))}
-
-
- {totalCount !== undefined && totalCount > 0 && (
- onPageChange(page)}
- onPerPageSelect={(_event, perPage) => onPerPageChange(perPage)}
- variant={PaginationVariant.bottom}
- perPageOptions={[
- { title: '10', value: 10 },
- { title: '20', value: 20 },
- { title: '50', value: 50 },
- { title: '100', value: 100 },
- ]}
- isCompact
- />
- )}
- >
- );
-}
\ No newline at end of file
diff --git a/api/src/main/webui/src/pages/HomePage.tsx b/api/src/main/webui/src/pages/HomePage.tsx
index d9b0fbbbe..3b851c3d7 100644
--- a/api/src/main/webui/src/pages/HomePage.tsx
+++ b/api/src/main/webui/src/pages/HomePage.tsx
@@ -2,15 +2,14 @@
* Home Page - Kafka Cluster Selection
*/
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
PageSection,
Title,
EmptyState,
EmptyStateBody,
- Spinner,
+ Skeleton,
Card,
CardTitle,
CardBody,
@@ -30,75 +29,21 @@ import { useKafkaClusters } from '../api/hooks/useKafkaClusters';
import { useMetadata } from '../api/hooks/useMetadata';
import { useShowLearning } from '../hooks/useShowLearning';
import { AppLayout } from '@/components/app/AppLayout';
-import { ClustersTable } from '@/components/home/ClustersTable';
+import { ClustersDataView } from '@/components/home/ClustersDataView';
+import { ResourceListParams } from '@/api/hooks/useResourceList';
export function HomePage() {
const { t } = useTranslation();
- const navigate = useNavigate();
- const [page, setPage] = useState(1);
- const [perPage, setPerPage] = useState(20);
- const [sortBy, setSortBy] = useState('name');
- const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
- const [filterName, setFilterName] = useState('');
-
- const { data, isLoading, error } = useKafkaClusters({
- pageSize: perPage,
- sort: sortBy,
- sortDir: sortDirection,
- name: filterName || undefined,
- });
-
+ const [dataParams, setDataParams] = useState({});
+ const { data: clusterResponse, isLoading, error } = useKafkaClusters(dataParams);
const { data: metadata } = useMetadata();
- const clusters = data?.data || [];
- const totalCount = data?.meta?.page?.total;
+ const totalCount = clusterResponse?.meta?.page?.total;
const platform = metadata?.data?.attributes?.platform;
- // If only one cluster, redirect to it
- useEffect(() => {
- if (clusters.length === 1 && totalCount === 1 && !filterName) {
- navigate(`/kafka/${clusters[0].id}`);
- }
- }, [clusters, totalCount, filterName, navigate]);
-
- const handleSort = (column: string, direction: 'asc' | 'desc') => {
- setSortBy(column);
- setSortDirection(direction);
- setPage(1); // Reset to first page when sorting changes
- };
-
- const handlePageChange = (newPage: number) => {
- setPage(newPage);
- };
-
- const handlePerPageChange = (newPerPage: number) => {
- setPerPage(newPerPage);
- setPage(1); // Reset to first page when page size changes
- };
-
- const handleFilterNameChange = (name: string) => {
- setFilterName(name);
- setPage(1); // Reset to first page when filter changes
- };
-
const showLearning = useShowLearning();
const [isLearningExpanded, setIsLearningExpanded] = useState(false);
- if (isLoading) {
- return (
-
-
-
-
-
- {t('common.loading')}
-
-
-
-
- );
- }
-
if (error) {
return (
@@ -122,25 +67,18 @@ export function HomePage() {
{t('common.platform', 'Platform')}: {platform}
)}
- {totalCount !== undefined && (
-
- {totalCount} {t('kafka.connectedClusters', 'Connected Kafka clusters')}
-
- )}
+
+ { totalCount
+ ? totalCount + ' ' + t('kafka.connectedClusters')
+ :
+ }
+
-
{showLearning && (
@@ -292,4 +230,4 @@ export function HomePage() {
)}
);
-}
\ No newline at end of file
+}
diff --git a/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx b/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx
index 0611639cb..367541019 100644
--- a/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx
+++ b/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx
@@ -47,10 +47,13 @@ export function KafkaLayout() {
const { data, isLoading, error } = useKafkaCluster(kafkaId, {
fields: 'name,namespace,status,kafkaVersion,creationTimestamp,listeners,conditions',
});
-
+
// Fetch all clusters for the cluster switcher
- const { data: clustersData } = useKafkaClusters({ pageSize: 1000 });
-
+ const { data: clustersData } = useKafkaClusters({
+ fields: 'name,namespace',
+ page: { size: 1000 },
+ });
+
// Fetch topic data if we're on a topic detail page
const { data: topicData } = useTopic(
kafkaId,
diff --git a/api/src/main/webui/vite.config.ts b/api/src/main/webui/vite.config.ts
index 8985e2a18..c83244ef6 100644
--- a/api/src/main/webui/vite.config.ts
+++ b/api/src/main/webui/vite.config.ts
@@ -33,6 +33,7 @@ export default defineConfig({
'@patternfly/react-table',
'@patternfly/react-icons',
'@patternfly/react-user-feedback',
+ '@patternfly/react-data-view',
]
}
-});
\ No newline at end of file
+});
diff --git a/ui/tests/playwright/auth.setup.ts b/ui/tests/playwright/auth.setup.ts
index 89f782b02..bfee1d66c 100644
--- a/ui/tests/playwright/auth.setup.ts
+++ b/ui/tests/playwright/auth.setup.ts
@@ -3,11 +3,7 @@ import { expect, test as setup } from "@playwright/test";
setup("authenticate", async ({ page }) => {
await page.goto("./");
- //await page.waitForURL("**/login", { waitUntil: "commit" });
- //await page.getByRole("button", { name: 'Click to login anonymously' }).click();
- if (process.env.TEST_KAFKA_INDEX) {
- page.getByRole("button", { name: 'View' }).nth(process.env.TEST_KAFKA_INDEX).click();
- }
+ page.getByRole("button", { name: 'View' }).nth(process.env.TEST_KAFKA_INDEX ?? 0).click();
await page.waitForURL("**/overview", { waitUntil: "commit" });
await expect(page.getByRole("heading", { name: "Cluster overview" }),).toBeVisible();
const newPage = page.mainFrame();