From 90ae0e28b70c9855c32b1eea7c4dbe437e2b1a4b Mon Sep 17 00:00:00 2001 From: "andrii.dudar" Date: Wed, 23 Oct 2024 12:28:33 +0200 Subject: [PATCH] [OPIK-239] [UX improvements] Add charts of experiment table [OPIK-223] [UX improvements] Add grouping by dataset in experiment table --- apps/opik-frontend/package-lock.json | 292 +++++++++++++- apps/opik-frontend/package.json | 1 + .../src/api/datasets/useDatasetsList.ts | 13 +- .../src/api/datasets/useExperimentsList.ts | 16 +- .../ExperimentRowActionsCell.tsx | 8 +- .../pages/ExperimentsPage/ExperimentsPage.tsx | 215 ++++++++--- .../charts/ExperimentChartContainer.tsx | 157 ++++++++ .../charts/ExperimentChartLegendContent.tsx | 65 ++++ .../charts/ExperimentChartTooltipContent.tsx | 87 +++++ .../charts/ExperimentsChartsWrapper.tsx | 78 ++++ .../pages/ExperimentsPage/table.tsx | 106 +++++ .../pages/TracesPage/TracesSpansTab.tsx | 6 +- .../components/shared/DataTable/DataTable.tsx | 162 ++++++-- .../src/components/shared/DataTable/utils.tsx | 2 +- .../DataTablePagination.tsx | 9 +- .../LoadableSelectBox/LoadableSelectBox.tsx | 23 +- apps/opik-frontend/src/components/ui/card.tsx | 79 ++++ .../opik-frontend/src/components/ui/chart.tsx | 363 ++++++++++++++++++ .../src/components/ui/popover.tsx | 4 +- apps/opik-frontend/src/components/ui/tag.tsx | 18 + .../src/hooks/useGroupedExperimentsList.ts | 251 ++++++++++++ .../src/hooks/useObserveResizeNode.ts | 31 ++ apps/opik-frontend/src/lib/table.ts | 46 ++- apps/opik-frontend/src/main.scss | 12 + 24 files changed, 1891 insertions(+), 153 deletions(-) create mode 100644 apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartContainer.tsx create mode 100644 apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartLegendContent.tsx create mode 100644 apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartTooltipContent.tsx create mode 100644 apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentsChartsWrapper.tsx create mode 100644 apps/opik-frontend/src/components/pages/ExperimentsPage/table.tsx create mode 100644 apps/opik-frontend/src/components/ui/card.tsx create mode 100644 apps/opik-frontend/src/components/ui/chart.tsx create mode 100644 apps/opik-frontend/src/hooks/useGroupedExperimentsList.ts create mode 100644 apps/opik-frontend/src/hooks/useObserveResizeNode.ts diff --git a/apps/opik-frontend/package-lock.json b/apps/opik-frontend/package-lock.json index a66ea4969..a52a7e581 100644 --- a/apps/opik-frontend/package-lock.json +++ b/apps/opik-frontend/package-lock.json @@ -61,6 +61,7 @@ "react-hotkeys-hook": "^4.5.1", "react-resizable-panels": "^2.0.20", "react18-json-view": "^0.2.8", + "recharts": "^2.13.3", "slugify": "^1.6.6", "stylelint-scss": "^6.4.1", "tailwind-merge": "^2.3.0", @@ -4477,6 +4478,60 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "node_modules/@types/diff": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.2.tgz", @@ -5960,8 +6015,117 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } }, "node_modules/data-view-buffer": { "version": "1.0.1", @@ -6044,6 +6208,11 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/deeks": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/deeks/-/deeks-3.1.0.tgz", @@ -6221,6 +6390,15 @@ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -6874,6 +7052,14 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-equals": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", + "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -7614,6 +7800,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -9687,7 +9881,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9697,8 +9890,7 @@ "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-from-env": { "version": "1.1.0", @@ -9857,6 +10049,20 @@ "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-smooth": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", + "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -9879,6 +10085,21 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/react18-json-view": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/react18-json-view/-/react18-json-view-0.2.8.tgz", @@ -9906,6 +10127,46 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.13.3.tgz", + "integrity": "sha512-YDZ9dOfK9t3ycwxgKbrnDlRC4BHdjlY73fet3a0C1+qGMjXVZe6+VXmpOIIhzkje5MMEL8AN4hLIe4AMskBzlA==", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11549,6 +11810,27 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.7", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", diff --git a/apps/opik-frontend/package.json b/apps/opik-frontend/package.json index 41cb65bf8..3594618ce 100644 --- a/apps/opik-frontend/package.json +++ b/apps/opik-frontend/package.json @@ -78,6 +78,7 @@ "react-hotkeys-hook": "^4.5.1", "react-resizable-panels": "^2.0.20", "react18-json-view": "^0.2.8", + "recharts": "^2.13.3", "slugify": "^1.6.6", "stylelint-scss": "^6.4.1", "tailwind-merge": "^2.3.0", diff --git a/apps/opik-frontend/src/api/datasets/useDatasetsList.ts b/apps/opik-frontend/src/api/datasets/useDatasetsList.ts index 7ec3b8bf2..0235c7f11 100644 --- a/apps/opik-frontend/src/api/datasets/useDatasetsList.ts +++ b/apps/opik-frontend/src/api/datasets/useDatasetsList.ts @@ -1,9 +1,11 @@ import { QueryFunctionContext, useQuery } from "@tanstack/react-query"; +import isBoolean from "lodash/isBoolean"; import api, { DATASETS_REST_ENDPOINT, QueryConfig } from "@/api/api"; import { Dataset } from "@/types/datasets"; type UseDatasetsListParams = { workspaceName: string; + withExperimentsOnly?: boolean; search?: string; page: number; size: number; @@ -16,12 +18,21 @@ export type UseDatasetsListResponse = { const getDatasetsList = async ( { signal }: QueryFunctionContext, - { workspaceName, search, size, page }: UseDatasetsListParams, + { + workspaceName, + withExperimentsOnly, + search, + size, + page, + }: UseDatasetsListParams, ) => { const { data } = await api.get(DATASETS_REST_ENDPOINT, { signal, params: { workspace_name: workspaceName, + ...(isBoolean(withExperimentsOnly) && { + with_experiments_only: withExperimentsOnly, + }), ...(search && { name: search }), size, page, diff --git a/apps/opik-frontend/src/api/datasets/useExperimentsList.ts b/apps/opik-frontend/src/api/datasets/useExperimentsList.ts index 808ea0266..1db925044 100644 --- a/apps/opik-frontend/src/api/datasets/useExperimentsList.ts +++ b/apps/opik-frontend/src/api/datasets/useExperimentsList.ts @@ -1,10 +1,12 @@ import { QueryFunctionContext, useQuery } from "@tanstack/react-query"; +import isBoolean from "lodash/isBoolean"; import api, { EXPERIMENTS_REST_ENDPOINT, QueryConfig } from "@/api/api"; import { Experiment } from "@/types/datasets"; -type UseExperimentsListParams = { +export type UseExperimentsListParams = { workspaceName: string; datasetId?: string; + datasetDeleted?: boolean; search?: string; page: number; size: number; @@ -15,14 +17,22 @@ export type UseExperimentsListResponse = { total: number; }; -const getExperimentsList = async ( +export const getExperimentsList = async ( { signal }: QueryFunctionContext, - { workspaceName, datasetId, search, size, page }: UseExperimentsListParams, + { + workspaceName, + datasetId, + datasetDeleted, + search, + size, + page, + }: UseExperimentsListParams, ) => { const { data } = await api.get(EXPERIMENTS_REST_ENDPOINT, { signal, params: { workspace_name: workspaceName, + ...(isBoolean(datasetDeleted) && { dataset_deleted: datasetDeleted }), ...(search && { name: search }), ...(datasetId && { datasetId }), size, diff --git a/apps/opik-frontend/src/components/pages/ExperimentsPage/ExperimentRowActionsCell.tsx b/apps/opik-frontend/src/components/pages/ExperimentsPage/ExperimentRowActionsCell.tsx index f169541af..7cb97d911 100644 --- a/apps/opik-frontend/src/components/pages/ExperimentsPage/ExperimentRowActionsCell.tsx +++ b/apps/opik-frontend/src/components/pages/ExperimentsPage/ExperimentRowActionsCell.tsx @@ -9,11 +9,11 @@ import { MoreHorizontal, Trash } from "lucide-react"; import React, { useCallback, useRef, useState } from "react"; import { CellContext } from "@tanstack/react-table"; import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog"; -import { Experiment } from "@/types/datasets"; import useExperimentBatchDeleteMutation from "@/api/datasets/useExperimentBatchDeleteMutation"; +import { GroupedExperiment } from "@/hooks/useGroupedExperimentsList"; -export const ExperimentRowActionsCell: React.FunctionComponent< - CellContext +const ExperimentRowActionsCell: React.FunctionComponent< + CellContext > = ({ row }) => { const resetKeyRef = useRef(0); const experiment = row.original; @@ -64,3 +64,5 @@ export const ExperimentRowActionsCell: React.FunctionComponent< ); }; + +export default ExperimentRowActionsCell; diff --git a/apps/opik-frontend/src/components/pages/ExperimentsPage/ExperimentsPage.tsx b/apps/opik-frontend/src/components/pages/ExperimentsPage/ExperimentsPage.tsx index c92d12561..d3f271a8c 100644 --- a/apps/opik-frontend/src/components/pages/ExperimentsPage/ExperimentsPage.tsx +++ b/apps/opik-frontend/src/components/pages/ExperimentsPage/ExperimentsPage.tsx @@ -1,9 +1,20 @@ -import React, { useCallback, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Info } from "lucide-react"; +import last from "lodash/last"; import { useNavigate } from "@tanstack/react-router"; -import { keepPreviousData } from "@tanstack/react-query"; import useLocalStorageState from "use-local-storage-state"; -import { RowSelectionState } from "@tanstack/react-table"; +import { + ExpandedState, + GroupingState, + RowSelectionState, + Row, +} from "@tanstack/react-table"; import DataTable from "@/components/shared/DataTable/DataTable"; import DataTablePagination from "@/components/shared/DataTablePagination/DataTablePagination"; @@ -12,52 +23,47 @@ import FeedbackScoresCell from "@/components/shared/DataTableCells/FeedbackScore import IdCell from "@/components/shared/DataTableCells/IdCell"; import ResourceCell from "@/components/shared/DataTableCells/ResourceCell"; import { RESOURCE_TYPE } from "@/components/shared/ResourceLink/ResourceLink"; -import useExperimentsList from "@/api/datasets/useExperimentsList"; -import { Experiment } from "@/types/datasets"; import Loader from "@/components/shared/Loader/Loader"; import useAppStore from "@/store/AppStore"; import { formatDate } from "@/lib/date"; import { COLUMN_TYPE, ColumnData } from "@/types/shared"; -import { generateSelectColumDef } from "@/components/shared/DataTable/utils"; import { convertColumnDataToColumn } from "@/lib/table"; import ColumnsButton from "@/components/shared/ColumnsButton/ColumnsButton"; import AddExperimentDialog from "@/components/pages/ExperimentsPage/AddExperimentDialog"; import ExperimentsActionsPanel from "@/components/pages/ExperimentsPage/ExperimentsActionsPanel"; import ExperimentsFiltersButton from "@/components/pages/ExperimentsPage/ExperimentsFiltersButton"; +import ExperimentRowActionsCell from "@/components/pages/ExperimentsPage/ExperimentRowActionsCell"; +import ExperimentsChartsWrapper from "@/components/pages/ExperimentsPage/charts/ExperimentsChartsWrapper"; import SearchInput from "@/components/shared/SearchInput/SearchInput"; -import { ExperimentRowActionsCell } from "@/components/pages/ExperimentsPage/ExperimentRowActionsCell"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import useGroupedExperimentsList, { + checkIsMoreRowId, + DEFAULT_EXPERIMENTS_PER_GROUP, + DELETED_DATASET_ID, + GroupedExperiment, + GROUPING_COLUMN, +} from "@/hooks/useGroupedExperimentsList"; +import { + generateExperimentNameColumDef, + generateGroupedCellDef, +} from "@/components/pages/ExperimentsPage/table"; const SELECTED_COLUMNS_KEY = "experiments-selected-columns"; const COLUMNS_WIDTH_KEY = "experiments-columns-width"; const COLUMNS_ORDER_KEY = "experiments-columns-order"; -const getRowId = (e: Experiment) => e.id; +const getRowId = (e: GroupedExperiment) => e.id; +const getIsMoreRow = (row: Row) => + checkIsMoreRowId(row?.original?.id || ""); -export const DEFAULT_COLUMNS: ColumnData[] = [ +export const DEFAULT_COLUMNS: ColumnData[] = [ { id: "id", label: "ID", type: COLUMN_TYPE.string, cell: IdCell as never, }, - { - id: "name", - label: "Name", - type: COLUMN_TYPE.string, - }, - { - id: "dataset", - label: "Dataset", - type: COLUMN_TYPE.string, - cell: ResourceCell as never, - customMeta: { - nameKey: "dataset_name", - idKey: "dataset_id", - resource: RESOURCE_TYPE.dataset, - }, - }, { id: "created_at", label: "Created", @@ -78,8 +84,6 @@ export const DEFAULT_COLUMNS: ColumnData[] = [ ]; export const DEFAULT_SELECTED_COLUMNS: string[] = [ - "name", - "dataset", "created_at", "feedback_scores", ]; @@ -88,29 +92,29 @@ const ExperimentsPage: React.FunctionComponent = () => { const navigate = useNavigate(); const workspaceName = useAppStore((state) => state.activeWorkspaceName); + const openGroupsRef = useRef>({}); const resetDialogKeyRef = useRef(0); const [openDialog, setOpenDialog] = useState(false); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); - const [size, setSize] = useState(10); + const [size, setSize] = useState(5); const [datasetId, setDatasetId] = useState(""); const [rowSelection, setRowSelection] = useState({}); - const { data, isPending } = useExperimentsList( - { - workspaceName, - datasetId, - search, - page, - size, - }, - { - placeholderData: keepPreviousData, - refetchInterval: 30000, - }, - ); + const [expanded, setExpanded] = useState({}); + const [groupLimit, setGroupLimit] = useState>({}); + + const { data, isPending } = useGroupedExperimentsList({ + workspaceName, + groupLimit, + datasetId, + search, + page, + size, + }); const experiments = useMemo(() => data?.content ?? [], [data?.content]); + const groupIds = useMemo(() => data?.groupIds ?? [], [data?.groupIds]); const total = data?.total ?? 0; const noData = !search && !datasetId; const noDataText = noData @@ -137,33 +141,65 @@ const ExperimentsPage: React.FunctionComponent = () => { defaultValue: {}, }); - const selectedRows: Array = useMemo(() => { - return experiments.filter((row) => rowSelection[row.id]); + const selectedRows: Array = useMemo(() => { + return experiments.filter( + (row) => rowSelection[row.id] && !checkIsMoreRowId(row.id), + ); }, [rowSelection, experiments]); const columns = useMemo(() => { - const retVal = convertColumnDataToColumn( - DEFAULT_COLUMNS, + return [ + generateExperimentNameColumDef(), + generateGroupedCellDef({ + id: GROUPING_COLUMN, + label: "Dataset", + type: COLUMN_TYPE.string, + cell: ResourceCell as never, + customMeta: { + nameKey: "dataset_name", + idKey: "dataset_id", + resource: RESOURCE_TYPE.dataset, + }, + }), + ...convertColumnDataToColumn( + DEFAULT_COLUMNS, + { + columnsOrder, + columnsWidth, + selectedColumns, + }, + ), { - columnsOrder, - columnsWidth, - selectedColumns, + id: "actions", + enableHiding: false, + cell: ExperimentRowActionsCell, + size: 48, + enableResizing: false, + enableSorting: false, }, - ); - - retVal.unshift(generateSelectColumDef()); + ]; + }, [selectedColumns, columnsWidth, columnsOrder]); - retVal.push({ - id: "actions", - enableHiding: false, - cell: ExperimentRowActionsCell, - size: 48, - enableResizing: false, - enableSorting: false, + useEffect(() => { + const updateForExpandedState: Record = {}; + groupIds.forEach((groupId) => { + const id = `${GROUPING_COLUMN}:${groupId}`; + if (!openGroupsRef.current[id]) { + openGroupsRef.current[id] = true; + updateForExpandedState[id] = true; + } }); - return retVal; - }, [selectedColumns, columnsWidth, columnsOrder]); + if (Object.keys(updateForExpandedState).length) { + setExpanded((state) => { + if (state === true) return state; + return { + ...state, + ...updateForExpandedState, + }; + }); + } + }, [groupIds]); const resizeConfig = useMemo( () => ({ @@ -173,13 +209,30 @@ const ExperimentsPage: React.FunctionComponent = () => { [setColumnsWidth], ); + const groupingConfig = useMemo( + () => ({ + groupedColumnMode: false as const, + grouping: [GROUPING_COLUMN] as GroupingState, + }), + [], + ); + + const expandingConfig = useMemo( + () => ({ + autoResetExpanded: false, + expanded, + setExpanded, + }), + [expanded, setExpanded], + ); + const handleNewExperimentClick = useCallback(() => { setOpenDialog(true); resetDialogKeyRef.current = resetDialogKeyRef.current + 1; }, []); const handleRowClick = useCallback( - (experiment: Experiment) => { + (experiment: GroupedExperiment) => { navigate({ to: "/$workspaceName/experiments/$datasetId/compare", params: { @@ -194,6 +247,32 @@ const ExperimentsPage: React.FunctionComponent = () => { [navigate, workspaceName], ); + const renderMoreRow = useCallback((row: Row) => { + return ( + + + + + + ); + }, []); + if (isPending) { return ; } @@ -203,7 +282,7 @@ const ExperimentsPage: React.FunctionComponent = () => {

Experiments

-
+
{
+ {noData && ( @@ -257,6 +343,7 @@ const ExperimentsPage: React.FunctionComponent = () => { size={size} sizeChange={setSize} total={total} + disabledSizeChange >
; +}; + +export type ChartData = { + dataset: Dataset; + data: DataRecord[]; + lines: string[]; + index: number; +}; + +type ExperimentChartContainerProps = { + className: string; + chartData: ChartData; +}; + +const ExperimentChartContainer: React.FC = ({ + chartData, + className, +}) => { + const [hiddenLines, setHiddenLines] = useState([]); + + const config = useMemo(() => { + return chartData.lines.reduce((acc, line) => { + acc[line] = { + label: line, + color: TAG_VARIANTS_COLOR_MAP[generateTagVariant(line)!], + }; + return acc; + }, {}); + }, [chartData.lines]); + + const tickWidth = useMemo(() => { + const MIN_WIDTH = 26; + const MAX_WIDTH = 80; + const CHARACTER_WIDTH = 7; + const EXTRA_SPACE = 10; + + const values = chartData.data.reduce((acc, data) => { + return [ + ...acc, + ...Object.values(data.scores).map( + (v) => Math.round(v).toString().length, + ), + ]; + }, []); + + return Math.min( + Math.max(MIN_WIDTH, Math.max(...values) * CHARACTER_WIDTH + EXTRA_SPACE), + MAX_WIDTH, + ); + }, [chartData.data]); + + const [width, setWidth] = useState(0); + const { ref } = useObserveResizeNode((node) => + setWidth(node.clientWidth), + ); + + const { + dataset: { id: chartId, name }, + } = chartData; + + const legendWidth = Math.max( + MIN_LEGEND_WIDTH, + Math.min(width * 0.3, MAX_LEGEND_WIDTH), + ); + + return ( + + + {name} + + + + + + + } + /> + + } + width={legendWidth} + height={128} + /> + {chartData.lines.map((line) => { + const hide = hiddenLines.includes(line); + + return ( + x.scores[line] || undefined} + name={config[line].label as string} + stroke={config[line].color as string} + dot={{ strokeWidth: 1, r: 1 }} + activeDot={{ strokeWidth: 1, r: 3 }} + hide={hide} + /> + ); + })} + + + + + ); +}; + +export default ExperimentChartContainer; diff --git a/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartLegendContent.tsx b/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartLegendContent.tsx new file mode 100644 index 000000000..dce117546 --- /dev/null +++ b/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartLegendContent.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import * as RechartsPrimitive from "recharts"; +import { cn } from "@/lib/utils"; +import { OnChangeFn } from "@/types/shared"; +import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper"; + +const ExperimentChartLegendContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps & + React.ComponentProps<"div"> & { + setHideState: OnChangeFn; + chartId: string; + } +>(({ payload, color, setHideState }, ref) => { + if (!payload?.length) { + return null; + } + + return ( +
+ {payload.map((item) => { + const key = `${item.value || "value"}`; + const indicatorColor = color || item.color; + + return ( +
{ + setHideState((state) => { + return item.inactive + ? state.filter((value) => value !== item.value) + : [...state, item.value]; + }); + }} + > + +
+ {item.value} +
+
+
+
+ ); + })} +
+ ); +}); +ExperimentChartLegendContent.displayName = "ExperimentChartLegendContent"; + +export default ExperimentChartLegendContent; diff --git a/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartTooltipContent.tsx b/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartTooltipContent.tsx new file mode 100644 index 000000000..bc4dc7e3f --- /dev/null +++ b/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartTooltipContent.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import * as RechartsPrimitive from "recharts"; +import { getPayloadConfigFromPayload, useChart } from "@/components/ui/chart"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; + +const ExperimentChartTooltipContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps & + React.ComponentProps<"div"> +>(({ active, payload, color }, ref) => { + const { config } = useChart(); + + if (!active || !payload?.length) { + return null; + } + + const { experimentName, createdDate } = payload[0].payload; + + return ( + <> + + +
+
+ +
+
+
+ {experimentName} +
+
+ {createdDate} +
+
+
+ {payload.map((item) => { + const key = `${item.name || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload( + config, + item, + key, + ); + const indicatorColor = color || item.payload.fill || item.color; + + return ( +
+
+
+
+ + {itemConfig?.label || item.name} + +
+ {item.value && ( + + {item.value.toLocaleString()} + + )} +
+
+ ); + })} +
+
+ + + + ); +}); +ExperimentChartTooltipContent.displayName = "ExperimentChartTooltipContent"; + +export default ExperimentChartTooltipContent; diff --git a/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentsChartsWrapper.tsx b/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentsChartsWrapper.tsx new file mode 100644 index 000000000..4bcb0ea79 --- /dev/null +++ b/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentsChartsWrapper.tsx @@ -0,0 +1,78 @@ +import React, { useMemo } from "react"; +import uniq from "lodash/uniq"; + +import { + DELETED_DATASET_ID, + GroupedExperiment, +} from "@/hooks/useGroupedExperimentsList"; +import { formatDate } from "@/lib/date"; +import ExperimentChartContainer, { + ChartData, +} from "@/components/pages/ExperimentsPage/charts/ExperimentChartContainer"; + +export type ExperimentsChartsWrapperProps = { + experiments: GroupedExperiment[]; +}; + +const ExperimentsChartsWrapper: React.FC = ({ + experiments, +}) => { + const chartsData = useMemo(() => { + const groupsMap: Record = {}; + let index = 0; + + experiments.forEach((experiment) => { + if (experiment.virtual_dataset_id !== DELETED_DATASET_ID) { + if (!groupsMap[experiment.virtual_dataset_id]) { + groupsMap[experiment.virtual_dataset_id] = { + dataset: experiment.dataset, + data: [], + lines: [], + index, + }; + index += 1; + } + + groupsMap[experiment.virtual_dataset_id].data.unshift({ + experimentId: experiment.id, + experimentName: experiment.name, + createdDate: formatDate(experiment.created_at), + scores: (experiment.feedback_scores || []).reduce< + Record + >((acc, score) => { + acc[score.name] = score.value; + return acc; + }, {}), + }); + + groupsMap[experiment.virtual_dataset_id].lines = uniq([ + ...groupsMap[experiment.virtual_dataset_id].lines, + ...(experiment.feedback_scores || []).map((s) => s.name), + ]); + } + }); + + return Object.values(groupsMap).sort((g1, g2) => g1.index - g2.index); + }, [experiments]); + + const chartClassName = + chartsData.length === 1 + ? "w-full" + : chartsData.length === 2 + ? "basis-1/2" + : "basis-2/5"; + + return ( +
+ {chartsData.map((data) => ( + + ))} +
+ ); +}; + +export default ExperimentsChartsWrapper; diff --git a/apps/opik-frontend/src/components/pages/ExperimentsPage/table.tsx b/apps/opik-frontend/src/components/pages/ExperimentsPage/table.tsx new file mode 100644 index 000000000..7fb62e110 --- /dev/null +++ b/apps/opik-frontend/src/components/pages/ExperimentsPage/table.tsx @@ -0,0 +1,106 @@ +import { Checkbox } from "@/components/ui/checkbox"; +import React from "react"; +import { CellContext, ColumnDef, flexRender } from "@tanstack/react-table"; +import { ColumnData } from "@/types/shared"; +import { Button } from "@/components/ui/button"; +import { ChevronDown, ChevronUp, Text } from "lucide-react"; +import { mapColumnDataFields } from "@/lib/table"; +import { cn } from "@/lib/utils"; +import CellWrapper from "@/components/shared/DataTableCells/CellWrapper"; + +export const generateExperimentNameColumDef = () => { + return { + accessorKey: "name", + header: ({ table }) => ( +
e.stopPropagation()} + > + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + + Name +
+ ), + cell: (context) => ( + + event.stopPropagation()} + checked={context.row.getIsSelected()} + disabled={!context.row.getCanSelect()} + onCheckedChange={(value) => context.row.toggleSelected(!!value)} + aria-label="Select row" + /> + {context.getValue() as string} + + ), + size: 180, + minSize: 100, + enableSorting: false, + enableHiding: false, + } as ColumnDef; +}; + +export const generateGroupedCellDef = ( + columnData: ColumnData, +) => { + return { + ...mapColumnDataFields(columnData), + header: () => "", + cell: (context: CellContext) => { + const { row, cell } = context; + return ( +
+
+ event.stopPropagation()} + checked={ + row.getIsAllSubRowsSelected() || + (row.getIsSomeSelected() && "indeterminate") + } + disabled={!row.getCanSelect()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + +
+
+ {flexRender(columnData.cell as never, cell.getContext())} +
+
+ ); + }, + size: 0, + minSize: 0, + enableResizing: false, + enableSorting: false, + enableHiding: false, + } as ColumnDef; +}; diff --git a/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab.tsx b/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab.tsx index 3ad82210c..1050b62e7 100644 --- a/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab.tsx +++ b/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab.tsx @@ -256,9 +256,11 @@ export const TracesSpansTab: React.FC = ({ onRowClick={handleRowClick} activeRowId={activeRowId ?? ""} resizeConfig={resizeConfig} + selectionConfig={{ + rowSelection, + setRowSelection, + }} getRowId={getRowId} - rowSelection={rowSelection} - setRowSelection={setRowSelection} rowHeight={height as ROW_HEIGHT} noData={} /> diff --git a/apps/opik-frontend/src/components/shared/DataTable/DataTable.tsx b/apps/opik-frontend/src/components/shared/DataTable/DataTable.tsx index cb34832ce..91b176a36 100644 --- a/apps/opik-frontend/src/components/shared/DataTable/DataTable.tsx +++ b/apps/opik-frontend/src/components/shared/DataTable/DataTable.tsx @@ -1,15 +1,22 @@ -import { ReactNode, useEffect, useMemo } from "react"; +import React, { ReactNode, useEffect, useMemo } from "react"; import { + Cell, ColumnDef, ColumnSort, + ExpandedState, flexRender, getCoreRowModel, + getExpandedRowModel, + getGroupedRowModel, + GroupingState, + Row, RowData, RowSelectionState, useReactTable, } from "@tanstack/react-table"; import isFunction from "lodash/isFunction"; import isEmpty from "lodash/isEmpty"; +import isBoolean from "lodash/isBoolean"; import { Table, @@ -57,17 +64,37 @@ interface ResizeConfig { onColumnResize?: (data: Record) => void; } +interface SelectionConfig { + rowSelection?: RowSelectionState; + setRowSelection?: OnChangeFn; +} + +interface GroupingConfig { + groupedColumnMode: false | "reorder" | "remove"; + grouping: GroupingState; + setGrouping?: OnChangeFn; +} + +interface ExpandingConfig { + autoResetExpanded: boolean; + expanded: ExpandedState; + setExpanded: OnChangeFn; +} + interface DataTableProps { columns: ColumnDef[]; data: TData[]; onRowClick?: (row: TData) => void; + renderCustomRow?: (row: Row) => ReactNode | null; + getIsCustomRow?: (row: Row) => boolean; activeRowId?: string; sortConfig?: SortConfig; resizeConfig?: ResizeConfig; + selectionConfig?: SelectionConfig; + groupingConfig?: GroupingConfig; + expandingConfig?: ExpandingConfig; getRowId?: (row: TData) => string; getRowHeightClass?: (height: ROW_HEIGHT) => string; - rowSelection?: RowSelectionState; - setRowSelection?: OnChangeFn; rowHeight?: ROW_HEIGHT; noData?: ReactNode; autoWidth?: boolean; @@ -77,13 +104,16 @@ const DataTable = ({ columns, data, onRowClick, + renderCustomRow, + getIsCustomRow = () => false, activeRowId, sortConfig, resizeConfig, + selectionConfig, + groupingConfig, + expandingConfig, getRowId, getRowHeightClass = calculateHeightClass, - rowSelection, - setRowSelection, rowHeight = ROW_HEIGHT.small, noData, autoWidth = false, @@ -96,14 +126,33 @@ const DataTable = ({ columns, getRowId, columnResizeMode: "onChange", + ...(isBoolean(groupingConfig?.groupedColumnMode) || + groupingConfig?.groupedColumnMode + ? { + groupedColumnMode: groupingConfig!.groupedColumnMode, + } + : {}), + ...(isBoolean(expandingConfig?.autoResetExpanded) + ? { + autoResetExpanded: expandingConfig!.autoResetExpanded, + } + : {}), enableSorting: sortConfig?.enabled ?? false, enableSortingRemoval: false, - getCoreRowModel: getCoreRowModel(), - onRowSelectionChange: setRowSelection ? setRowSelection : undefined, onSortingChange: sortConfig?.setSorting, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getGroupedRowModel: getGroupedRowModel(), + onRowSelectionChange: selectionConfig?.setRowSelection, + onGroupingChange: groupingConfig?.setGrouping, + onExpandedChange: expandingConfig?.setExpanded, state: { ...(sortConfig?.sorting && { sorting: sortConfig.sorting }), - ...(rowSelection && { rowSelection }), + ...(selectionConfig?.rowSelection && { + rowSelection: selectionConfig.rowSelection, + }), + ...(groupingConfig?.grouping && { grouping: groupingConfig.grouping }), + ...(expandingConfig?.expanded && { expanded: expandingConfig.expanded }), }, meta: { rowHeight, @@ -140,6 +189,74 @@ const DataTable = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoWidth, columnSizeVars]); + const renderRow = (row: Row) => { + if (isFunction(renderCustomRow) && getIsCustomRow(row)) { + return renderCustomRow(row); + } + + return ( + onRowClick(row.original), + } + : {})} + className={cn({ + "cursor-pointer": isRowClickable, + "bg-muted/50": row.id === activeRowId, + })} + > + {row.getVisibleCells().map((cell) => renderCell(row, cell))} + + ); + }; + + const renderCell = (row: Row, cell: Cell) => { + if (cell.getIsGrouped()) { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + } + + if (cell.getIsAggregated()) { + return null; + } + + if (cell.getIsPlaceholder()) { + return ( + + ); + } + + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + }; + return (
@@ -171,34 +288,7 @@ const DataTable = ({ {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - onRowClick(row.original), - } - : {})} - className={cn({ - "cursor-pointer": isRowClickable, - "bg-muted/50": row.id === activeRowId, - })} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) + table.getRowModel().rows.map(renderRow) ) : ( diff --git a/apps/opik-frontend/src/components/shared/DataTable/utils.tsx b/apps/opik-frontend/src/components/shared/DataTable/utils.tsx index 8bb958e2c..5c9ac5dfb 100644 --- a/apps/opik-frontend/src/components/shared/DataTable/utils.tsx +++ b/apps/opik-frontend/src/components/shared/DataTable/utils.tsx @@ -10,7 +10,7 @@ export const calculateHeightClass = (rowHeight: ROW_HEIGHT) => { export const generateSelectColumDef = () => { return { - id: "select", + accessorKey: "select", header: ({ table }) => ( event.stopPropagation()} diff --git a/apps/opik-frontend/src/components/shared/DataTablePagination/DataTablePagination.tsx b/apps/opik-frontend/src/components/shared/DataTablePagination/DataTablePagination.tsx index 56f5bb494..6fcb495d4 100644 --- a/apps/opik-frontend/src/components/shared/DataTablePagination/DataTablePagination.tsx +++ b/apps/opik-frontend/src/components/shared/DataTablePagination/DataTablePagination.tsx @@ -19,6 +19,7 @@ type DataTableProps = { size: number; total: number; sizeChange: (number: number) => void; + disabledSizeChange?: boolean; }; const ITEMS_PER_PAGE = [5, 10, 25, 50, 100]; @@ -29,6 +30,7 @@ const DataTablePagination = ({ size = 10, total, sizeChange, + disabledSizeChange = false, }: DataTableProps) => { const from = Math.max(size * (page - 1) + 1, 0); const to = Math.min(size * page, total); @@ -64,7 +66,12 @@ const DataTablePagination = ({
- diff --git a/apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx b/apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx index d87d143d4..872d6eb71 100644 --- a/apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx +++ b/apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx @@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; +import { useObserveResizeNode } from "@/hooks/useObserveResizeNode"; import { DropdownOption } from "@/types/shared"; import NoOptions from "./NoOptions"; import SearchInput from "@/components/shared/SearchInput/SearchInput"; @@ -62,23 +63,9 @@ export const LoadableSelectBox = ({ setOpen(open); }, []); - const onChangeRef = useCallback((node: HTMLButtonElement) => { - if (!node) return null; - - const resizeObserver = new ResizeObserver(() => { - window.requestAnimationFrame(() => { - if (node) { - setWidth(node.clientWidth); - } - }); - }); - - resizeObserver.observe(node); - - return () => { - resizeObserver.disconnect(); - }; - }, []); + const { ref } = useObserveResizeNode((node) => + setWidth(node.clientWidth), + ); const hasFilteredOptions = Boolean(filteredOptions.length); const hasMoreSection = hasFilteredOptions && hasMore; @@ -89,7 +76,7 @@ export const LoadableSelectBox = ({