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 - /> - - )} - - - )} - - - - - - - - - - - - {clusters.map((cluster) => ( - - - - - - - - ))} - -
{t('kafka.name')}{t('kafka.namespace')}{t('kafka.version')} - {t('kafka.status')}{' '} - - - - {t('common.actions')}
- - - {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();