Browse Source

Prepare for extendability (#2433)

* Prepare for extendability
related to #450

* forgot to remove test

* linter

* lint

* unused

* unused

* unused

* date col

* tests

* tests

* workaround for ValueError: MultiIndex has no single backing array. Use 'MultiIndex.to_numpy()' to get a NumPy array of tuples.

* workaround for ValueError: MultiIndex has no single backing array. Use 'MultiIndex.to_numpy()' to get a NumPy array of tuples.

* return value

* support list[dict[str, scalar]]

* extensibility for upcoming geopandas

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide 3 months ago
parent
commit
7f3e8af4fc

+ 248 - 232
frontend/taipy-gui/package-lock.json

@@ -128,21 +128,21 @@
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.26.0",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
-      "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
+      "version": "7.26.7",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz",
+      "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==",
       "dev": true,
       "dependencies": {
         "@ampproject/remapping": "^2.2.0",
-        "@babel/code-frame": "^7.26.0",
-        "@babel/generator": "^7.26.0",
-        "@babel/helper-compilation-targets": "^7.25.9",
+        "@babel/code-frame": "^7.26.2",
+        "@babel/generator": "^7.26.5",
+        "@babel/helper-compilation-targets": "^7.26.5",
         "@babel/helper-module-transforms": "^7.26.0",
-        "@babel/helpers": "^7.26.0",
-        "@babel/parser": "^7.26.0",
+        "@babel/helpers": "^7.26.7",
+        "@babel/parser": "^7.26.7",
         "@babel/template": "^7.25.9",
-        "@babel/traverse": "^7.25.9",
-        "@babel/types": "^7.26.0",
+        "@babel/traverse": "^7.26.7",
+        "@babel/types": "^7.26.7",
         "convert-source-map": "^2.0.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
@@ -276,24 +276,24 @@
       }
     },
     "node_modules/@babel/helpers": {
-      "version": "7.26.0",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz",
-      "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==",
+      "version": "7.26.7",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz",
+      "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==",
       "dev": true,
       "dependencies": {
         "@babel/template": "^7.25.9",
-        "@babel/types": "^7.26.0"
+        "@babel/types": "^7.26.7"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.26.5",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.5.tgz",
-      "integrity": "sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==",
+      "version": "7.26.7",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz",
+      "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==",
       "dependencies": {
-        "@babel/types": "^7.26.5"
+        "@babel/types": "^7.26.7"
       },
       "bin": {
         "parser": "bin/babel-parser.js"
@@ -525,9 +525,9 @@
       }
     },
     "node_modules/@babel/runtime": {
-      "version": "7.26.0",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
-      "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
+      "version": "7.26.7",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz",
+      "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==",
       "dependencies": {
         "regenerator-runtime": "^0.14.0"
       },
@@ -549,15 +549,15 @@
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.26.5",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.5.tgz",
-      "integrity": "sha512-rkOSPOw+AXbgtwUga3U4u8RpoK9FEFWBNAlTpcnkLFjL5CT+oyHNuUUC/xx6XefEJ16r38r8Bc/lfp6rYuHeJQ==",
+      "version": "7.26.7",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz",
+      "integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==",
       "dependencies": {
         "@babel/code-frame": "^7.26.2",
         "@babel/generator": "^7.26.5",
-        "@babel/parser": "^7.26.5",
+        "@babel/parser": "^7.26.7",
         "@babel/template": "^7.25.9",
-        "@babel/types": "^7.26.5",
+        "@babel/types": "^7.26.7",
         "debug": "^4.3.1",
         "globals": "^11.1.0"
       },
@@ -566,9 +566,9 @@
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.26.5",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.5.tgz",
-      "integrity": "sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==",
+      "version": "7.26.7",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz",
+      "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==",
       "dependencies": {
         "@babel/helper-string-parser": "^7.25.9",
         "@babel/helper-validator-identifier": "^7.25.9"
@@ -874,9 +874,9 @@
       }
     },
     "node_modules/@eslint/js": {
-      "version": "9.18.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz",
-      "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==",
+      "version": "9.19.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz",
+      "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==",
       "dev": true,
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -905,13 +905,13 @@
       }
     },
     "node_modules/@gerrit0/mini-shiki": {
-      "version": "1.26.1",
-      "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-1.26.1.tgz",
-      "integrity": "sha512-gHFUvv9f1fU2Piou/5Y7Sx5moYxcERbC7CXc6rkDLQTUBg5Dgg9L4u29/nHqfoQ3Y9R0h0BcOhd14uOEZIBP7Q==",
+      "version": "1.27.2",
+      "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-1.27.2.tgz",
+      "integrity": "sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og==",
       "dev": true,
       "dependencies": {
-        "@shikijs/engine-oniguruma": "^1.26.1",
-        "@shikijs/types": "^1.26.1",
+        "@shikijs/engine-oniguruma": "^1.27.2",
+        "@shikijs/types": "^1.27.2",
         "@shikijs/vscode-textmate": "^10.0.1"
       }
     },
@@ -1592,18 +1592,18 @@
       "dev": true
     },
     "node_modules/@mui/core-downloads-tracker": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.1.tgz",
-      "integrity": "sha512-2OmnEyoHpj5//dJJpMuxOeLItCCHdf99pjMFfUFdBteCunAK9jW+PwEo4mtdGcLs7P+IgZ+85ypd52eY4AigoQ==",
+      "version": "6.4.1",
+      "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.1.tgz",
+      "integrity": "sha512-SfDLWMV5b5oXgDf3NTa2hCTPC1d2defhDH2WgFKmAiejC4mSfXYbyi+AFCLzpizauXhgBm8OaZy9BHKnrSpahQ==",
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/mui-org"
       }
     },
     "node_modules/@mui/icons-material": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.3.1.tgz",
-      "integrity": "sha512-nJmWj1PBlwS3t1PnoqcixIsftE+7xrW3Su7f0yrjPw4tVjYrgkhU0hrRp+OlURfZ3ptdSkoBkalee9Bhf1Erfw==",
+      "version": "6.4.1",
+      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.1.tgz",
+      "integrity": "sha512-wsxFcUTQxt4s+7Bg4GgobqRjyaHLmZGNOs+HJpbwrwmLbT6mhIJxhpqsKzzWq9aDY8xIe7HCjhpH7XI5UD6teA==",
       "dependencies": {
         "@babel/runtime": "^7.26.0"
       },
@@ -1615,7 +1615,7 @@
         "url": "https://opencollective.com/mui-org"
       },
       "peerDependencies": {
-        "@mui/material": "^6.3.1",
+        "@mui/material": "^6.4.1",
         "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
         "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
       },
@@ -1626,15 +1626,15 @@
       }
     },
     "node_modules/@mui/material": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.1.tgz",
-      "integrity": "sha512-ynG9ayhxgCsHJ/dtDcT1v78/r2GwQyP3E0hPz3GdPRl0uFJz/uUTtI5KFYwadXmbC+Uv3bfB8laZ6+Cpzh03gA==",
+      "version": "6.4.1",
+      "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.1.tgz",
+      "integrity": "sha512-MFBfia6UiKxyoLeGkAh8M15bkeDmfnsUTMRJd/vTQue6YQ8AQ6lw9HqDthyYghzDEWIvZO/lQQzLrZE8XwNJLA==",
       "dependencies": {
         "@babel/runtime": "^7.26.0",
-        "@mui/core-downloads-tracker": "^6.3.1",
-        "@mui/system": "^6.3.1",
+        "@mui/core-downloads-tracker": "^6.4.1",
+        "@mui/system": "^6.4.1",
         "@mui/types": "^7.2.21",
-        "@mui/utils": "^6.3.1",
+        "@mui/utils": "^6.4.1",
         "@popperjs/core": "^2.11.8",
         "@types/react-transition-group": "^4.4.12",
         "clsx": "^2.1.1",
@@ -1653,7 +1653,7 @@
       "peerDependencies": {
         "@emotion/react": "^11.5.0",
         "@emotion/styled": "^11.3.0",
-        "@mui/material-pigment-css": "^6.3.1",
+        "@mui/material-pigment-css": "^6.4.1",
         "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
         "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
         "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -1674,12 +1674,12 @@
       }
     },
     "node_modules/@mui/private-theming": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.1.tgz",
-      "integrity": "sha512-g0u7hIUkmXmmrmmf5gdDYv9zdAig0KoxhIQn1JN8IVqApzf/AyRhH3uDGx5mSvs8+a1zb4+0W6LC260SyTTtdQ==",
+      "version": "6.4.1",
+      "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.1.tgz",
+      "integrity": "sha512-DcT7mwK89owwgcEuiE7w458te4CIjHbYWW6Kn6PiR6eLtxBsoBYphA968uqsQAOBQDpbYxvkuFLwhgk4bxoN/Q==",
       "dependencies": {
         "@babel/runtime": "^7.26.0",
-        "@mui/utils": "^6.3.1",
+        "@mui/utils": "^6.4.1",
         "prop-types": "^15.8.1"
       },
       "engines": {
@@ -1700,9 +1700,9 @@
       }
     },
     "node_modules/@mui/styled-engine": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.1.tgz",
-      "integrity": "sha512-/7CC0d2fIeiUxN5kCCwYu4AWUDd9cCTxWCyo0v/Rnv6s8uk6hWgJC3VLZBoDENBHf/KjqDZuYJ2CR+7hD6QYww==",
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.0.tgz",
+      "integrity": "sha512-ek/ZrDujrger12P6o4luQIfRd2IziH7jQod2WMbLqGE03Iy0zUwYmckRTVhRQTLPNccpD8KXGcALJF+uaUQlbg==",
       "dependencies": {
         "@babel/runtime": "^7.26.0",
         "@emotion/cache": "^11.13.5",
@@ -1733,15 +1733,15 @@
       }
     },
     "node_modules/@mui/system": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.1.tgz",
-      "integrity": "sha512-AwqQ3EAIT2np85ki+N15fF0lFXX1iFPqenCzVOSl3QXKy2eifZeGd9dGtt7pGMoFw5dzW4dRGGzRpLAq9rkl7A==",
+      "version": "6.4.1",
+      "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.1.tgz",
+      "integrity": "sha512-rgQzgcsHCTtzF9MZ+sL0tOhf2ZBLazpjrujClcb4Siju5lTrK0xX4PsiropActzCemNfM+mOu+0jezAVnfRK8g==",
       "dependencies": {
         "@babel/runtime": "^7.26.0",
-        "@mui/private-theming": "^6.3.1",
-        "@mui/styled-engine": "^6.3.1",
+        "@mui/private-theming": "^6.4.1",
+        "@mui/styled-engine": "^6.4.0",
         "@mui/types": "^7.2.21",
-        "@mui/utils": "^6.3.1",
+        "@mui/utils": "^6.4.1",
         "clsx": "^2.1.1",
         "csstype": "^3.1.3",
         "prop-types": "^15.8.1"
@@ -1785,9 +1785,9 @@
       }
     },
     "node_modules/@mui/utils": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.1.tgz",
-      "integrity": "sha512-sjGjXAngoio6lniQZKJ5zGfjm+LD2wvLwco7FbKe1fu8A7VIFmz2SwkLb+MDPLNX1lE7IscvNNyh1pobtZg2tw==",
+      "version": "6.4.1",
+      "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.1.tgz",
+      "integrity": "sha512-iQUDUeYh87SvR4lVojaRaYnQix8BbRV51MxaV6MBmqthecQoxwSbS5e2wnbDJUeFxY2ppV505CiqPLtd0OWkqw==",
       "dependencies": {
         "@babel/runtime": "^7.26.0",
         "@mui/types": "^7.2.21",
@@ -1814,13 +1814,13 @@
       }
     },
     "node_modules/@mui/x-date-pickers": {
-      "version": "7.23.6",
-      "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.23.6.tgz",
-      "integrity": "sha512-jt6rEAYLju3NZe3y2S+I5KcTiSHV79FW0jeNUEUTceg1qsPzseHbND66k3zVF0hO3N2oZtLtPywof6vN5Doe+Q==",
+      "version": "7.24.1",
+      "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.24.1.tgz",
+      "integrity": "sha512-ykQugMQHuQKBk3kViW/r0PpubtHQOlrd54bgbdafgTMCeM2VpXvv4zimzOu5IGnM6wEN8hupC7EXZbkrT6x46w==",
       "dependencies": {
         "@babel/runtime": "^7.25.7",
         "@mui/utils": "^5.16.6 || ^6.0.0",
-        "@mui/x-internals": "7.23.6",
+        "@mui/x-internals": "7.24.1",
         "@types/react-transition-group": "^4.4.11",
         "clsx": "^2.1.1",
         "prop-types": "^15.8.1",
@@ -1879,9 +1879,9 @@
       }
     },
     "node_modules/@mui/x-internals": {
-      "version": "7.23.6",
-      "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.23.6.tgz",
-      "integrity": "sha512-hT1Pa4PNCnxwiauPbYMC3p4DiEF1x05Iu4C1MtC/jMJ1LtthymLmTuQ6ZQ53/R9FeqK6sYd6A6noR+vNMjp5DA==",
+      "version": "7.24.1",
+      "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.24.1.tgz",
+      "integrity": "sha512-9BvJzpLJnS9BDphvkiv6v0QOLxbnu8jhwcexFjtCQ2ZyxtVuVsWzGZ2npT9sGOil7+eaFDmWnJtea/tgrPvSwQ==",
       "dependencies": {
         "@babel/runtime": "^7.25.7",
         "@mui/utils": "^5.16.6 || ^6.0.0"
@@ -1898,13 +1898,13 @@
       }
     },
     "node_modules/@mui/x-tree-view": {
-      "version": "7.23.6",
-      "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-7.23.6.tgz",
-      "integrity": "sha512-4gXXQtgxNW4aHGtksLJUBkRdK7m7CdV/j2OwemrjdmU0bEOz82ta7X4vQrIsaXXfxatuomaOy+MXOzEv6xbNqA==",
+      "version": "7.24.1",
+      "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-7.24.1.tgz",
+      "integrity": "sha512-IR24GAw8e8NORlVxJzNf1RnJu/ThBLv6sNlHoh7sF82MQ89i3nUCErA2gqYnY4aZ4g3GfJSWnYikPP24OTEqRQ==",
       "dependencies": {
         "@babel/runtime": "^7.25.7",
         "@mui/utils": "^5.16.6 || ^6.0.0",
-        "@mui/x-internals": "7.23.6",
+        "@mui/x-internals": "7.24.1",
         "@types/react-transition-group": "^4.4.11",
         "clsx": "^2.1.1",
         "prop-types": "^15.8.1",
@@ -2054,19 +2054,19 @@
       }
     },
     "node_modules/@shikijs/engine-oniguruma": {
-      "version": "1.27.0",
-      "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.27.0.tgz",
-      "integrity": "sha512-x1XMJvQuToX2KhESav2cnaTFDEwpJ1bcczaXy8wlRWhPVVAGR/MxlWnJbhHFe+ETerQgdpLZN8l+EgO0rVfEFQ==",
+      "version": "1.29.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.1.tgz",
+      "integrity": "sha512-gSt2WhLNgEeLstcweQOSp+C+MhOpTsgdNXRqr3zP6M+BUBZ8Md9OU2BYwUYsALBxHza7hwaIWtFHjQ/aOOychw==",
       "dev": true,
       "dependencies": {
-        "@shikijs/types": "1.27.0",
+        "@shikijs/types": "1.29.1",
         "@shikijs/vscode-textmate": "^10.0.1"
       }
     },
     "node_modules/@shikijs/types": {
-      "version": "1.27.0",
-      "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.27.0.tgz",
-      "integrity": "sha512-oOJdIeOnGo+hbM7MH+Ejpksse2ASex4DVHdvBoKyY3+26GEzG9PwM85BeXNGxUZuVxtVKo43sZl0qtJs/K2Zow==",
+      "version": "1.29.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.1.tgz",
+      "integrity": "sha512-aBqAuhYRp5vSir3Pc9+QPu9WESBOjUo03ao0IHLC4TyTioSsp/SkbAZSrIH4ghYYC1T1KTEpRSBa83bas4RnPA==",
       "dev": true,
       "dependencies": {
         "@shikijs/vscode-textmate": "^10.0.1",
@@ -2188,9 +2188,9 @@
       "dev": true
     },
     "node_modules/@testing-library/react": {
-      "version": "16.1.0",
-      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.1.0.tgz",
-      "integrity": "sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==",
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz",
+      "integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==",
       "dev": true,
       "dependencies": {
         "@babel/runtime": "^7.12.5"
@@ -2215,9 +2215,9 @@
       }
     },
     "node_modules/@testing-library/user-event": {
-      "version": "14.5.2",
-      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
-      "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
+      "version": "14.6.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+      "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
       "dev": true,
       "engines": {
         "node": ">=12",
@@ -2411,9 +2411,9 @@
       }
     },
     "node_modules/@types/geojson": {
-      "version": "7946.0.15",
-      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.15.tgz",
-      "integrity": "sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA=="
+      "version": "7946.0.16",
+      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
     },
     "node_modules/@types/geojson-vt": {
       "version": "3.2.5",
@@ -2535,9 +2535,9 @@
       "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
     },
     "node_modules/@types/lodash": {
-      "version": "4.17.14",
-      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz",
-      "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==",
+      "version": "4.17.15",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
+      "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
       "dev": true
     },
     "node_modules/@types/mapbox__point-geometry": {
@@ -2564,14 +2564,14 @@
       }
     },
     "node_modules/@types/ms": {
-      "version": "0.7.34",
-      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
-      "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+      "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
     },
     "node_modules/@types/node": {
-      "version": "20.17.12",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.12.tgz",
-      "integrity": "sha512-vo/wmBgMIiEA23A/knMfn/cf37VnuF52nZh5ZoW0GWt4e4sxNquibrMRJ7UQsA06+MBx9r/H1jsI9grYjQCQlw==",
+      "version": "20.17.16",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz",
+      "integrity": "sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==",
       "dependencies": {
         "undici-types": "~6.19.2"
       }
@@ -2718,16 +2718,16 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz",
-      "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.22.0.tgz",
+      "integrity": "sha512-4Uta6REnz/xEJMvwf72wdUnC3rr4jAQf5jnTkeRQ9b6soxLxhDEbS/pfMPoJLDfFPNVRdryqWUIV/2GZzDJFZw==",
       "dev": true,
       "dependencies": {
         "@eslint-community/regexpp": "^4.10.0",
-        "@typescript-eslint/scope-manager": "8.20.0",
-        "@typescript-eslint/type-utils": "8.20.0",
-        "@typescript-eslint/utils": "8.20.0",
-        "@typescript-eslint/visitor-keys": "8.20.0",
+        "@typescript-eslint/scope-manager": "8.22.0",
+        "@typescript-eslint/type-utils": "8.22.0",
+        "@typescript-eslint/utils": "8.22.0",
+        "@typescript-eslint/visitor-keys": "8.22.0",
         "graphemer": "^1.4.0",
         "ignore": "^5.3.1",
         "natural-compare": "^1.4.0",
@@ -2747,15 +2747,15 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz",
-      "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.22.0.tgz",
+      "integrity": "sha512-MqtmbdNEdoNxTPzpWiWnqNac54h8JDAmkWtJExBVVnSrSmi9z+sZUt0LfKqk9rjqmKOIeRhO4fHHJ1nQIjduIQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "8.20.0",
-        "@typescript-eslint/types": "8.20.0",
-        "@typescript-eslint/typescript-estree": "8.20.0",
-        "@typescript-eslint/visitor-keys": "8.20.0",
+        "@typescript-eslint/scope-manager": "8.22.0",
+        "@typescript-eslint/types": "8.22.0",
+        "@typescript-eslint/typescript-estree": "8.22.0",
+        "@typescript-eslint/visitor-keys": "8.22.0",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -2771,13 +2771,13 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz",
-      "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.22.0.tgz",
+      "integrity": "sha512-/lwVV0UYgkj7wPSw0o8URy6YI64QmcOdwHuGuxWIYznO6d45ER0wXUbksr9pYdViAofpUCNJx/tAzNukgvaaiQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "8.20.0",
-        "@typescript-eslint/visitor-keys": "8.20.0"
+        "@typescript-eslint/types": "8.22.0",
+        "@typescript-eslint/visitor-keys": "8.22.0"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2788,13 +2788,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz",
-      "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.22.0.tgz",
+      "integrity": "sha512-NzE3aB62fDEaGjaAYZE4LH7I1MUwHooQ98Byq0G0y3kkibPJQIXVUspzlFOmOfHhiDLwKzMlWxaNv+/qcZurJA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "8.20.0",
-        "@typescript-eslint/utils": "8.20.0",
+        "@typescript-eslint/typescript-estree": "8.22.0",
+        "@typescript-eslint/utils": "8.22.0",
         "debug": "^4.3.4",
         "ts-api-utils": "^2.0.0"
       },
@@ -2811,9 +2811,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz",
-      "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.22.0.tgz",
+      "integrity": "sha512-0S4M4baNzp612zwpD4YOieP3VowOARgK2EkN/GBn95hpyF8E2fbMT55sRHWBq+Huaqk3b3XK+rxxlM8sPgGM6A==",
       "dev": true,
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2824,13 +2824,13 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz",
-      "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.22.0.tgz",
+      "integrity": "sha512-SJX99NAS2ugGOzpyhMza/tX+zDwjvwAtQFLsBo3GQxiGcvaKlqGBkmZ+Y1IdiSi9h4Q0Lr5ey+Cp9CGWNY/F/w==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "8.20.0",
-        "@typescript-eslint/visitor-keys": "8.20.0",
+        "@typescript-eslint/types": "8.22.0",
+        "@typescript-eslint/visitor-keys": "8.22.0",
         "debug": "^4.3.4",
         "fast-glob": "^3.3.2",
         "is-glob": "^4.0.3",
@@ -2850,15 +2850,15 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz",
-      "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.22.0.tgz",
+      "integrity": "sha512-T8oc1MbF8L+Bk2msAvCUzjxVB2Z2f+vXYfcucE2wOmYs7ZUwco5Ep0fYZw8quNwOiw9K8GYVL+Kgc2pETNTLOg==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.4.0",
-        "@typescript-eslint/scope-manager": "8.20.0",
-        "@typescript-eslint/types": "8.20.0",
-        "@typescript-eslint/typescript-estree": "8.20.0"
+        "@typescript-eslint/scope-manager": "8.22.0",
+        "@typescript-eslint/types": "8.22.0",
+        "@typescript-eslint/typescript-estree": "8.22.0"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2873,12 +2873,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz",
-      "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.22.0.tgz",
+      "integrity": "sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "8.20.0",
+        "@typescript-eslint/types": "8.22.0",
         "eslint-visitor-keys": "^4.2.0"
       },
       "engines": {
@@ -2902,9 +2902,9 @@
       }
     },
     "node_modules/@ungap/structured-clone": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz",
-      "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA=="
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+      "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="
     },
     "node_modules/@webassemblyjs/ast": {
       "version": "1.14.1",
@@ -3506,6 +3506,15 @@
       "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
       "dev": true
     },
+    "node_modules/async-function": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+      "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3923,9 +3932,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001692",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz",
-      "integrity": "sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==",
+      "version": "1.0.30001695",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz",
+      "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==",
       "funding": [
         {
           "type": "opencollective",
@@ -4957,9 +4966,9 @@
       }
     },
     "node_modules/decimal.js": {
-      "version": "10.4.3",
-      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
-      "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
+      "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
       "dev": true
     },
     "node_modules/decode-named-character-reference": {
@@ -5108,6 +5117,18 @@
         "node": ">=8"
       }
     },
+    "node_modules/doctrine": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+      "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/dom-accessibility-api": {
       "version": "0.5.16",
       "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -5337,9 +5358,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.5.80",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.80.tgz",
-      "integrity": "sha512-LTrKpW0AqIuHwmlVNV+cjFYTnXtM9K37OGhpe0ZI10ScPSxqVSryZHIY3WnCS5NSYbBODRTZyhRMS2h5FAEqAw=="
+      "version": "1.5.88",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.88.tgz",
+      "integrity": "sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw=="
     },
     "node_modules/element-size": {
       "version": "1.1.1",
@@ -5381,9 +5402,9 @@
       }
     },
     "node_modules/engine.io-client": {
-      "version": "6.6.2",
-      "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz",
-      "integrity": "sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==",
+      "version": "6.6.3",
+      "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
+      "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
       "dependencies": {
         "@socket.io/component-emitter": "~3.1.0",
         "debug": "~4.3.1",
@@ -5608,9 +5629,9 @@
       "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ=="
     },
     "node_modules/es-object-atoms": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.1.tgz",
-      "integrity": "sha512-BPOBuyUF9QIVhuNLhbToCLHP6+0MHwZ7xLBkPPCZqK4JmpJgGnv10035STzzQwFpqdzNFMB3irvDI63IagvDwA==",
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
       "dev": true,
       "dependencies": {
         "es-errors": "^1.3.0"
@@ -5757,9 +5778,9 @@
       }
     },
     "node_modules/eslint": {
-      "version": "9.18.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz",
-      "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==",
+      "version": "9.19.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz",
+      "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
@@ -5767,7 +5788,7 @@
         "@eslint/config-array": "^0.19.0",
         "@eslint/core": "^0.10.0",
         "@eslint/eslintrc": "^3.2.0",
-        "@eslint/js": "9.18.0",
+        "@eslint/js": "9.19.0",
         "@eslint/plugin-kit": "^0.2.5",
         "@humanfs/node": "^0.16.6",
         "@humanwhocodes/module-importer": "^1.0.1",
@@ -5869,18 +5890,6 @@
         "concat-map": "0.0.1"
       }
     },
-    "node_modules/eslint-plugin-react/node_modules/doctrine": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
-      "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
-      "dev": true,
-      "dependencies": {
-        "esutils": "^2.0.2"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
     "node_modules/eslint-plugin-react/node_modules/minimatch": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -6286,9 +6295,9 @@
       "dev": true
     },
     "node_modules/fast-uri": {
-      "version": "3.0.5",
-      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz",
-      "integrity": "sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==",
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
+      "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
       "funding": [
         {
           "type": "github",
@@ -6481,12 +6490,18 @@
       }
     },
     "node_modules/for-each": {
-      "version": "0.3.3",
-      "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
-      "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.4.tgz",
+      "integrity": "sha512-kKaIINnFpzW6ffJNDjjyjrk21BkDx38c0xa/klsT8VzLCaMEefv4ZTacrcVR4DmgTeBra++jMDAfS/tS799YDw==",
       "dev": true,
       "dependencies": {
-        "is-callable": "^1.1.3"
+        "is-callable": "^1.2.7"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
       }
     },
     "node_modules/form-data": {
@@ -7637,11 +7652,12 @@
       "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
     },
     "node_modules/is-async-function": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.0.tgz",
-      "integrity": "sha512-GExz9MtyhlZyXYLxzlJRj5WUCE661zhDa1Yna52CN57AJsymh+DvXXjyveSioqSRdxvUrdKdvqB1b5cVKsNpWQ==",
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+      "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
       "dev": true,
       "dependencies": {
+        "async-function": "^1.0.0",
         "call-bound": "^1.0.3",
         "get-proto": "^1.0.1",
         "has-tostringtag": "^1.0.2",
@@ -9356,21 +9372,21 @@
       }
     },
     "node_modules/lint-staged": {
-      "version": "15.3.0",
-      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.3.0.tgz",
-      "integrity": "sha512-vHFahytLoF2enJklgtOtCtIjZrKD/LoxlaUusd5nh7dWv/dkKQJY74ndFSzxCdv7g0ueGg1ORgTSt4Y9LPZn9A==",
-      "dev": true,
-      "dependencies": {
-        "chalk": "~5.4.1",
-        "commander": "~12.1.0",
-        "debug": "~4.4.0",
-        "execa": "~8.0.1",
-        "lilconfig": "~3.1.3",
-        "listr2": "~8.2.5",
-        "micromatch": "~4.0.8",
-        "pidtree": "~0.6.0",
-        "string-argv": "~0.3.2",
-        "yaml": "~2.6.1"
+      "version": "15.4.3",
+      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.4.3.tgz",
+      "integrity": "sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^5.4.1",
+        "commander": "^13.1.0",
+        "debug": "^4.4.0",
+        "execa": "^8.0.1",
+        "lilconfig": "^3.1.3",
+        "listr2": "^8.2.5",
+        "micromatch": "^4.0.8",
+        "pidtree": "^0.6.0",
+        "string-argv": "^0.3.2",
+        "yaml": "^2.7.0"
       },
       "bin": {
         "lint-staged": "bin/lint-staged.js"
@@ -9395,9 +9411,9 @@
       }
     },
     "node_modules/lint-staged/node_modules/commander": {
-      "version": "12.1.0",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
-      "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+      "version": "13.1.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
+      "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
       "dev": true,
       "engines": {
         "node": ">=18"
@@ -9538,9 +9554,9 @@
       }
     },
     "node_modules/lint-staged/node_modules/yaml": {
-      "version": "2.6.1",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz",
-      "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==",
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
+      "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
       "dev": true,
       "bin": {
         "yaml": "bin.mjs"
@@ -10521,9 +10537,9 @@
       }
     },
     "node_modules/micromark-util-subtokenize": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.3.tgz",
-      "integrity": "sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg==",
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.4.tgz",
+      "integrity": "sha512-N6hXjrin2GTJDe3MVjf5FuXpm12PGm80BrUAeub9XFXca8JZbP+oIwY4LJSVwFUCL1IPm/WwSVUN7goFHmSGGQ==",
       "funding": [
         {
           "type": "GitHub Sponsors",
@@ -10671,9 +10687,9 @@
       }
     },
     "node_modules/mock-xmlhttprequest": {
-      "version": "8.4.0",
-      "resolved": "https://registry.npmjs.org/mock-xmlhttprequest/-/mock-xmlhttprequest-8.4.0.tgz",
-      "integrity": "sha512-+77RbXL/KIvbK6yFWuhFZIotDEtzXvsP1F2nyzfAq+BngQ6MaOmzgwA5vzHxhm1D1tLznFnaZ+QFjlZBnm/jsQ==",
+      "version": "8.4.1",
+      "resolved": "https://registry.npmjs.org/mock-xmlhttprequest/-/mock-xmlhttprequest-8.4.1.tgz",
+      "integrity": "sha512-2ORxRN+h40+3/Ylw9LKOtYGfQIoX6grGQlmbvMKqaeZ5/l7oeMvqdJxyG/ax3Poy7VbqMTADI6BwTmO7u10Wrw==",
       "dev": true,
       "engines": {
         "node": ">=16.0.0"
@@ -10833,9 +10849,9 @@
       "integrity": "sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA=="
     },
     "node_modules/notistack": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz",
-      "integrity": "sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.2.tgz",
+      "integrity": "sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA==",
       "dependencies": {
         "clsx": "^1.1.0",
         "goober": "^2.0.33"
@@ -10849,8 +10865,8 @@
         "url": "https://opencollective.com/notistack"
       },
       "peerDependencies": {
-        "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
-        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+        "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
       }
     },
     "node_modules/notistack/node_modules/clsx": {
@@ -11446,9 +11462,9 @@
       }
     },
     "node_modules/postcss": {
-      "version": "8.5.0",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.0.tgz",
-      "integrity": "sha512-27VKOqrYfPncKA2NrFOVhP5MGAfHKLYn/Q0mz9cNQyRAKYi3VNHwYU2qKKqPCqgBmeeJ0uAFB56NumXZ5ZReXg==",
+      "version": "8.5.1",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
+      "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
       "funding": [
         {
           "type": "opencollective",
@@ -11889,9 +11905,9 @@
       }
     },
     "node_modules/react-router": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.1.tgz",
-      "integrity": "sha512-39sXJkftkKWRZ2oJtHhCxmoCrBCULr/HAH4IT5DHlgu/Q0FCPV0S4Lx+abjDTx/74xoZzNYDYbOZWlJjruyuDQ==",
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.3.tgz",
+      "integrity": "sha512-EezYymLY6Guk/zLQ2vRA8WvdUhWFEj5fcE3RfWihhxXBW7+cd1LsIiA3lmx+KCmneAGQuyBv820o44L2+TtkSA==",
       "dependencies": {
         "@types/cookie": "^0.6.0",
         "cookie": "^1.0.1",
@@ -11952,15 +11968,15 @@
       }
     },
     "node_modules/react-window-infinite-loader": {
-      "version": "1.0.9",
-      "resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.9.tgz",
-      "integrity": "sha512-5Hg89IdU4Vrp0RT8kZYKeTIxWZYhNkVXeI1HbKo01Vm/Z7qztDvXljwx16sMzsa9yapRJQW3ODZfMUw38SOWHw==",
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.10.tgz",
+      "integrity": "sha512-NO/csdHlxjWqA2RJZfzQgagAjGHspbO2ik9GtWZb0BY1Nnapq0auG8ErI+OhGCzpjYJsCYerqUlK6hkq9dfAAA==",
       "engines": {
         "node": ">8.0.0"
       },
       "peerDependencies": {
-        "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0",
-        "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0"
+        "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0"
       }
     },
     "node_modules/readable-stream": {

+ 4 - 2
frontend/taipy-gui/public/stylekit/controls/table.css

@@ -42,7 +42,8 @@
 
 /* Soft header on rows as Stylekit default */
 /* "header-plain" class to return to MUI default plain header */
-.taipy-table:where(:not(.header-plain)) thead th {
+.taipy-table:where(:not(.header-plain)) thead th,
+.taipy-table:where(:not(.header-plain)) tbody th {
   background: var(--color-paper);
   font-weight: bold;
 }
@@ -50,7 +51,8 @@
 /* No borders on rows as Stylekit default */
 /* "rows-bordered" class to return to MUI default rows with borders */
 .taipy-table:where(:not(.rows-bordered)) thead th,
-.taipy-table:where(:not(.rows-bordered)) tbody td {
+.taipy-table:where(:not(.rows-bordered)) tbody td,
+.taipy-table:where(:not(.rows-bordered)) tbody th {
   border-bottom: none;
 }
 

+ 281 - 193
frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx

@@ -60,6 +60,7 @@ import {
     FilterDesc,
     generateHeaderClassName,
     getClassName,
+    getColumnHeader,
     getFormatFn,
     getPageKey,
     getRowIndex,
@@ -288,107 +289,146 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
         e.stopPropagation();
     }, []);
 
-    const [colsOrder, columns, cellClassNames, tooltips, formats, handleNan, filter, partialEditable, calcWidth] =
-        useMemo(() => {
-            let hNan = !!props.nanValue;
-            if (baseColumns) {
-                try {
-                    let filter = false;
-                    let partialEditable = editable;
-                    const newCols: Record<string, ColumnDesc> = {};
-                    Object.entries(baseColumns).forEach(([cId, cDesc]) => {
-                        const nDesc = (newCols[cId] = { ...cDesc });
-                        if (typeof nDesc.filter != "boolean") {
-                            nDesc.filter = !!props.filter;
-                        }
-                        filter = filter || nDesc.filter;
-                        if (typeof nDesc.notEditable == "boolean") {
-                            nDesc.notEditable = !editable;
-                        } else {
-                            partialEditable = partialEditable || !nDesc.notEditable;
-                        }
-                        if (nDesc.tooltip === undefined) {
-                            nDesc.tooltip = props.tooltip;
-                        }
-                        if (typeof nDesc.sortable != "boolean") {
-                            nDesc.sortable = sortable;
-                        }
-                    });
-                    addActionColumn(
-                        (active && partialEditable && (onAdd || onDelete) ? 1 : 0) +
-                            (active && filter ? 1 : 0) +
-                            (active && downloadable ? 1 : 0),
-                        newCols
-                    );
-                    const colsOrder = Object.keys(newCols).sort(getSortByIndex(newCols));
-                    let nbWidth = 0;
-                    const styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
-                        if (newCols[col].className) {
-                            pv.classNames = pv.classNames || {};
-                            pv.classNames[newCols[col].dfid] = newCols[col].className as string;
-                        }
-                        hNan = hNan || !!newCols[col].nanValue;
-                        if (newCols[col].tooltip) {
-                            pv.tooltips = pv.tooltips || {};
-                            pv.tooltips[newCols[col].dfid] = newCols[col].tooltip as string;
-                        }
-                        if (newCols[col].formatFn) {
-                            pv.formats = pv.formats || {};
-                            pv.formats[newCols[col].dfid] = newCols[col].formatFn;
-                        }
-                        if (newCols[col].width !== undefined) {
-                            const cssWidth = getCssSize(newCols[col].width);
-                            if (cssWidth) {
-                                newCols[col].width = cssWidth;
-                                nbWidth++;
+    const [
+        colsOrder,
+        columns,
+        cellClassNames,
+        tooltips,
+        formats,
+        handleNan,
+        filter,
+        partialEditable,
+        calcWidth,
+        nbColHeaders,
+        headersInfo,
+    ] = useMemo(() => {
+        let hNan = !!props.nanValue;
+        let nbColHeaders = 1;
+        if (baseColumns) {
+            try {
+                let filter = false;
+                let partialEditable = editable;
+                const newCols: Record<string, ColumnDesc> = {};
+                Object.entries(baseColumns).forEach(([cId, cDesc]) => {
+                    const nDesc = (newCols[cId] = { ...cDesc });
+                    if (typeof nDesc.filter != "boolean") {
+                        nDesc.filter = !!props.filter;
+                    }
+                    filter = filter || nDesc.filter;
+                    if (typeof nDesc.notEditable == "boolean") {
+                        nDesc.notEditable = !editable;
+                    } else {
+                        partialEditable = partialEditable || !nDesc.notEditable;
+                    }
+                    if (nDesc.tooltip === undefined) {
+                        nDesc.tooltip = props.tooltip;
+                    }
+                    if (nDesc.multi !== undefined) {
+                        nDesc.sortable = false;
+                    } else if (typeof nDesc.sortable != "boolean") {
+                        nDesc.sortable = sortable;
+                    }
+                    nbColHeaders = Math.max(nbColHeaders, nDesc.headers?.length || 0);
+                });
+                addActionColumn(
+                    (active && partialEditable && (onAdd || onDelete) ? 1 : 0) +
+                        (active && filter ? 1 : 0) +
+                        (active && downloadable ? 1 : 0),
+                    newCols
+                );
+                const colsOrder = Object.keys(newCols).sort(getSortByIndex(newCols));
+                const headersInfo = [];
+                if (nbColHeaders > 1) {
+                    for (let i = 0; i < nbColHeaders; i++) {
+                        const headers = colsOrder.map((col, idx) => {
+                            const header = getColumnHeader(newCols, col, i);
+                            return idx > 0 && header === getColumnHeader(newCols, colsOrder[idx - 1], i)
+                                ? undefined
+                                : header;
+                        });
+                        const colSpans = headers.map((header, idx) => {
+                            if (header === undefined) {
+                                return 0;
                             }
+                            const nh = headers.slice(idx + 1);
+                            const nb = nh.findIndex((h) => h !== undefined);
+                            return nb == -1 ? nh.length + 1 : nb + 1;
+                        });
+                        headersInfo.push({ headers, colSpans });
+                    }
+                }
+                let nbWidth = 0;
+                const styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
+                    if (newCols[col].className) {
+                        pv.classNames = pv.classNames || {};
+                        pv.classNames[newCols[col].dfid] = newCols[col].className as string;
+                    }
+                    hNan = hNan || !!newCols[col].nanValue;
+                    if (newCols[col].tooltip) {
+                        pv.tooltips = pv.tooltips || {};
+                        pv.tooltips[newCols[col].dfid] = newCols[col].tooltip as string;
+                    }
+                    if (newCols[col].formatFn) {
+                        pv.formats = pv.formats || {};
+                        pv.formats[newCols[col].dfid] = newCols[col].formatFn;
+                    }
+                    if (newCols[col].width !== undefined) {
+                        const cssWidth = getCssSize(newCols[col].width);
+                        if (cssWidth) {
+                            newCols[col].width = cssWidth;
+                            nbWidth++;
                         }
-                        return pv;
-                    }, {});
-                    nbWidth = nbWidth ? colsOrder.length - nbWidth : 0;
-                    if (props.rowClassName) {
-                        styTt.classNames = styTt.classNames || {};
-                        styTt.classNames[ROW_CLASS_NAME] = props.rowClassName;
                     }
-                    return [
-                        colsOrder,
-                        newCols,
-                        styTt.classNames,
-                        styTt.tooltips,
-                        styTt.formats,
-                        hNan,
-                        filter,
-                        partialEditable,
-                        nbWidth > 0 ? `${100 / nbWidth}%` : undefined,
-                    ];
-                } catch (e) {
-                    console.info("ATable.columns: " + ((e as Error).message || e));
+                    return pv;
+                }, {});
+                nbWidth = nbWidth ? colsOrder.length - nbWidth : 0;
+                if (props.rowClassName) {
+                    styTt.classNames = styTt.classNames || {};
+                    styTt.classNames[ROW_CLASS_NAME] = props.rowClassName;
                 }
+                return [
+                    colsOrder,
+                    newCols,
+                    styTt.classNames,
+                    styTt.tooltips,
+                    styTt.formats,
+                    hNan,
+                    filter,
+                    partialEditable,
+                    nbWidth > 0 ? `${100 / nbWidth}%` : undefined,
+                    nbColHeaders,
+                    headersInfo,
+                ];
+            } catch (e) {
+                console.info("ATable.columns: " + ((e as Error).message || e));
             }
-            return [
-                [],
-                {} as Record<string, ColumnDesc>,
-                {} as Record<string, string>,
-                {} as Record<string, string>,
-                {} as Record<string, string>,
-                hNan,
-                false,
-                false,
-                "",
-            ];
-        }, [
-            active,
-            editable,
-            onAdd,
-            onDelete,
-            baseColumns,
-            props.rowClassName,
-            props.tooltip,
-            props.nanValue,
-            props.filter,
-            downloadable,
-            sortable,
-        ]);
+        }
+        return [
+            [],
+            {} as Record<string, ColumnDesc>,
+            {} as Record<string, string>,
+            {} as Record<string, string>,
+            {} as Record<string, string>,
+            hNan,
+            false,
+            false,
+            "",
+            1,
+            [],
+        ];
+    }, [
+        active,
+        editable,
+        onAdd,
+        onDelete,
+        baseColumns,
+        props.rowClassName,
+        props.tooltip,
+        props.nanValue,
+        props.filter,
+        downloadable,
+        sortable,
+    ]);
 
     const boxBodySx = useMemo(() => ({ height: height }), [height]);
 
@@ -643,105 +683,153 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
                     <TableContainer>
                         <MuiTable sx={tableSx} aria-labelledby="tableTitle" size={size} stickyHeader={true}>
                             <TableHead>
-                                <TableRow ref={headerRow}>
-                                    {colsOrder.map((col) => (
-                                        <TableCell
-                                            key={`head${columns[col].dfid}`}
-                                            sortDirection={orderBy === columns[col].dfid && order}
-                                            sx={
-                                                columns[col].width
-                                                    ? { minWidth: columns[col].width }
-                                                    : calcWidth
-                                                    ? { width: calcWidth }
-                                                    : undefined
-                                            }
-                                            className={
-                                                col === "EDIT_COL"
-                                                    ? getSuffixedClassNames(className, "-action")
-                                                    : getSuffixedClassNames(
-                                                          className,
-                                                          generateHeaderClassName(columns[col].dfid)
-                                                      )
-                                            }
-                                        >
-                                            {columns[col].dfid === EDIT_COL ? (
-                                                [
-                                                    active && (editable || partialEditable) && onAdd ? (
-                                                        <Tooltip title="Add a row" key="addARow">
-                                                            <IconButton
-                                                                onClick={onAddRowClick}
-                                                                size="small"
-                                                                sx={iconInRowSx}
-                                                            >
-                                                                <AddIcon fontSize="inherit" />
-                                                            </IconButton>
-                                                        </Tooltip>
-                                                    ) : null,
-                                                    active && filter ? (
-                                                        <TableFilter
-                                                            key="filter"
-                                                            columns={columns}
-                                                            colsOrder={colsOrder}
-                                                            onValidate={setAppliedFilters}
-                                                            appliedFilters={appliedFilters}
-                                                            className={className}
-                                                            filteredCount={filteredCount}
-                                                        />
-                                                    ) : null,
-                                                    active && downloadable ? (
-                                                        <Tooltip title="Download as CSV" key="downloadCsv">
-                                                            <IconButton
-                                                                onClick={onDownload}
-                                                                size="small"
-                                                                sx={iconInRowSx}
-                                                            >
-                                                                <Download fontSize="inherit" />
-                                                            </IconButton>
-                                                        </Tooltip>
-                                                    ) : null,
-                                                ]
-                                            ) : (
-                                                <TableSortLabel
-                                                    active={orderBy === columns[col].dfid}
-                                                    direction={orderBy === columns[col].dfid ? order : "asc"}
-                                                    data-dfid={columns[col].dfid}
-                                                    onClick={onSort}
-                                                    disabled={!active || !columns[col].sortable}
-                                                    hideSortIcon={!active || !columns[col].sortable}
-                                                >
-                                                    <Box sx={headBoxSx}>
-                                                        {columns[col].groupBy ? (
-                                                            <IconButton
-                                                                onClick={onAggregate}
-                                                                size="small"
-                                                                title="aggregate"
+                                {Array.from(Array(nbColHeaders).keys()).map((idx) => {
+                                    if (idx < nbColHeaders - 1) {
+                                        return (
+                                            <TableRow key={`rowheader${idx}`}>
+                                                {colsOrder.map((col, i) => {
+                                                    const colSpan =
+                                                        headersInfo[idx] && headersInfo[idx].colSpans.length > i
+                                                            ? headersInfo[idx].colSpans[i]
+                                                            : 1;
+                                                    return colSpan == 0 ? null : (
+                                                        <TableCell
+                                                            colSpan={1}
+                                                            key={`head${columns[col].dfid}`}
+                                                            sx={
+                                                                columns[col].width
+                                                                    ? { minWidth: columns[col].width }
+                                                                    : calcWidth
+                                                                    ? { width: calcWidth }
+                                                                    : undefined
+                                                            }
+                                                            className={
+                                                                col === "EDIT_COL"
+                                                                    ? getSuffixedClassNames(className, "-action")
+                                                                    : getSuffixedClassNames(
+                                                                          className,
+                                                                          generateHeaderClassName(columns[col].dfid)
+                                                                      )
+                                                            }
+                                                        >
+                                                            {(headersInfo[idx] &&
+                                                                headersInfo[idx].headers.length > i &&
+                                                                headersInfo[idx].headers[i]) ||
+                                                                ""}
+                                                        </TableCell>
+                                                    );
+                                                })}
+                                            </TableRow>
+                                        );
+                                    } else {
+                                        return (
+                                            <TableRow ref={headerRow} key={`rowheader${idx}`}>
+                                                {colsOrder.map((col, i) => (
+                                                    <TableCell
+                                                        key={`head${columns[col].dfid}`}
+                                                        sortDirection={orderBy === columns[col].dfid && order}
+                                                        sx={
+                                                            columns[col].width
+                                                                ? { minWidth: columns[col].width }
+                                                                : calcWidth
+                                                                ? { width: calcWidth }
+                                                                : undefined
+                                                        }
+                                                        className={
+                                                            col === "EDIT_COL"
+                                                                ? getSuffixedClassNames(className, "-action")
+                                                                : getSuffixedClassNames(
+                                                                      className,
+                                                                      generateHeaderClassName(columns[col].dfid)
+                                                                  )
+                                                        }
+                                                    >
+                                                        {columns[col].dfid === EDIT_COL ? (
+                                                            [
+                                                                active && (editable || partialEditable) && onAdd ? (
+                                                                    <Tooltip title="Add a row" key="addARow">
+                                                                        <IconButton
+                                                                            onClick={onAddRowClick}
+                                                                            size="small"
+                                                                            sx={iconInRowSx}
+                                                                        >
+                                                                            <AddIcon fontSize="inherit" />
+                                                                        </IconButton>
+                                                                    </Tooltip>
+                                                                ) : null,
+                                                                active && filter ? (
+                                                                    <TableFilter
+                                                                        key="filter"
+                                                                        columns={columns}
+                                                                        colsOrder={colsOrder}
+                                                                        onValidate={setAppliedFilters}
+                                                                        appliedFilters={appliedFilters}
+                                                                        className={className}
+                                                                        filteredCount={filteredCount}
+                                                                    />
+                                                                ) : null,
+                                                                active && downloadable ? (
+                                                                    <Tooltip title="Download as CSV" key="downloadCsv">
+                                                                        <IconButton
+                                                                            onClick={onDownload}
+                                                                            size="small"
+                                                                            sx={iconInRowSx}
+                                                                        >
+                                                                            <Download fontSize="inherit" />
+                                                                        </IconButton>
+                                                                    </Tooltip>
+                                                                ) : null,
+                                                            ]
+                                                        ) : (
+                                                            <TableSortLabel
+                                                                active={orderBy === columns[col].dfid}
+                                                                direction={
+                                                                    orderBy === columns[col].dfid ? order : "asc"
+                                                                }
                                                                 data-dfid={columns[col].dfid}
-                                                                disabled={!active}
-                                                                sx={iconInRowSx}
+                                                                onClick={onSort}
+                                                                disabled={!active || !columns[col].sortable}
+                                                                hideSortIcon={!active || !columns[col].sortable}
                                                             >
-                                                                {aggregates.includes(columns[col].dfid) ? (
-                                                                    <DataSaverOff fontSize="inherit" />
-                                                                ) : (
-                                                                    <DataSaverOn fontSize="inherit" />
-                                                                )}
-                                                            </IconButton>
-                                                        ) : null}
-                                                        {columns[col].title === undefined
-                                                            ? columns[col].dfid
-                                                            : columns[col].title}
-                                                    </Box>
-                                                    {orderBy === columns[col].dfid ? (
-                                                        <Box component="span" sx={visuallyHidden}>
-                                                            {order === "desc"
-                                                                ? "sorted descending"
-                                                                : "sorted ascending"}
-                                                        </Box>
-                                                    ) : null}
-                                                </TableSortLabel>
-                                            )}
-                                        </TableCell>
-                                    ))}
-                                </TableRow>
+                                                                <Box sx={headBoxSx}>
+                                                                    {columns[col].groupBy ? (
+                                                                        <IconButton
+                                                                            onClick={onAggregate}
+                                                                            size="small"
+                                                                            title="aggregate"
+                                                                            data-dfid={columns[col].dfid}
+                                                                            disabled={!active}
+                                                                            sx={iconInRowSx}
+                                                                        >
+                                                                            {aggregates.includes(columns[col].dfid) ? (
+                                                                                <DataSaverOff fontSize="inherit" />
+                                                                            ) : (
+                                                                                <DataSaverOn fontSize="inherit" />
+                                                                            )}
+                                                                        </IconButton>
+                                                                    ) : null}
+                                                                    {columns[col].title === undefined
+                                                                        ? (headersInfo[idx] &&
+                                                                              headersInfo[idx].headers.length > i &&
+                                                                              headersInfo[idx].headers[i]) ||
+                                                                          columns[col].dfid
+                                                                        : columns[col].title}
+                                                                </Box>
+                                                                {orderBy === columns[col].dfid ? (
+                                                                    <Box component="span" sx={visuallyHidden}>
+                                                                        {order === "desc"
+                                                                            ? "sorted descending"
+                                                                            : "sorted ascending"}
+                                                                    </Box>
+                                                                ) : null}
+                                                            </TableSortLabel>
+                                                        )}
+                                                    </TableCell>
+                                                ))}
+                                            </TableRow>
+                                        );
+                                    }
+                                })}
                             </TableHead>
                         </MuiTable>
                         <Box sx={boxBodySx}>

+ 340 - 222
frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx

@@ -66,6 +66,7 @@ import {
     FilterDesc,
     generateHeaderClassName,
     getClassName,
+    getColumnHeader,
     getFormatFn,
     getPageKey,
     getRowIndex,
@@ -139,111 +140,150 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
     const baseColumns = useDynamicJsonProperty(props.columns, props.defaultColumns, defaultColumns);
 
-    const [colsOrder, columns, cellClassNames, tooltips, formats, handleNan, filter, partialEditable, calcWidth] =
-        useMemo(() => {
-            let hNan = !!props.nanValue;
-            if (baseColumns) {
-                try {
-                    let filter = false;
-                    let partialEditable = editable;
-                    const newCols: Record<string, ColumnDesc> = {};
-                    Object.entries(baseColumns).forEach(([cId, cDesc]) => {
-                        const nDesc = (newCols[cId] = { ...cDesc });
-                        if (typeof nDesc.filter != "boolean") {
-                            nDesc.filter = !!props.filter;
-                        }
-                        filter = filter || nDesc.filter;
-                        if (typeof nDesc.notEditable == "boolean") {
-                            partialEditable = partialEditable || !nDesc.notEditable;
-                        } else {
-                            nDesc.notEditable = !editable;
-                        }
-                        if (nDesc.tooltip === undefined) {
-                            nDesc.tooltip = props.tooltip;
-                        }
-                        if (typeof nDesc.sortable != "boolean") {
-                            nDesc.sortable = sortable;
-                        }
-                    });
-                    addActionColumn(
-                        (active && partialEditable && (onAdd || onDelete) ? 1 : 0) +
-                            (active && filter ? 1 : 0) +
-                            (active && downloadable ? 1 : 0),
-                        newCols
-                    );
-                    const colsOrder = Object.keys(newCols).sort(getSortByIndex(newCols));
-                    let nbWidth = 0;
-                    let widthRate = 0;
-                    const functions = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
-                        if (newCols[col].className) {
-                            pv.classNames = pv.classNames || {};
-                            pv.classNames[newCols[col].dfid] = newCols[col].className;
-                        }
-                        hNan = hNan || !!newCols[col].nanValue;
-                        if (newCols[col].tooltip) {
-                            pv.tooltips = pv.tooltips || {};
-                            pv.tooltips[newCols[col].dfid] = newCols[col].tooltip;
-                        }
-                        if (newCols[col].formatFn) {
-                            pv.formats = pv.formats || {};
-                            pv.formats[newCols[col].dfid] = newCols[col].formatFn;
-                        }
-                        if (newCols[col].width !== undefined) {
-                            const cssWidth = getCssSize(newCols[col].width);
-                            if (cssWidth) {
-                                newCols[col].width = cssWidth;
-                                nbWidth++;
-                                if (cssWidth.endsWith("%")) {
-                                    widthRate += parseInt(cssWidth, 10);
-                                }
+    const [
+        colsOrder,
+        columns,
+        cellClassNames,
+        tooltips,
+        formats,
+        handleNan,
+        filter,
+        partialEditable,
+        calcWidth,
+        nbColHeaders,
+        headersInfo,
+    ] = useMemo(() => {
+        let hNan = !!props.nanValue;
+        let nbColHeaders = 1;
+        if (baseColumns) {
+            try {
+                let filter = false;
+                let partialEditable = editable;
+                const newCols: Record<string, ColumnDesc> = {};
+                Object.entries(baseColumns).forEach(([cId, cDesc]) => {
+                    const nDesc = (newCols[cId] = { ...cDesc });
+                    if (typeof nDesc.filter != "boolean") {
+                        nDesc.filter = !!props.filter;
+                    }
+                    filter = filter || nDesc.filter;
+                    if (typeof nDesc.notEditable == "boolean") {
+                        partialEditable = partialEditable || !nDesc.notEditable;
+                    } else {
+                        nDesc.notEditable = !editable;
+                    }
+                    if (nDesc.tooltip === undefined) {
+                        nDesc.tooltip = props.tooltip;
+                    }
+                    if (nDesc.multi !== undefined) {
+                        nDesc.sortable = false;
+                    } else if (typeof nDesc.sortable != "boolean") {
+                        nDesc.sortable = sortable;
+                    }
+                    nbColHeaders = Math.max(nbColHeaders, nDesc.headers?.length || 0);
+                });
+                addActionColumn(
+                    (active && partialEditable && (onAdd || onDelete) ? 1 : 0) +
+                        (active && filter ? 1 : 0) +
+                        (active && downloadable ? 1 : 0),
+                    newCols
+                );
+                const colsOrder = Object.keys(newCols).sort(getSortByIndex(newCols));
+                const headersInfo = [];
+                if (nbColHeaders > 1) {
+                    for (let i = 0; i < nbColHeaders; i++) {
+                        const headers = colsOrder.map((col, idx) => {
+                            const header = getColumnHeader(newCols, col, i);
+                            return idx > 0 && header === getColumnHeader(newCols, colsOrder[idx - 1], i)
+                                ? undefined
+                                : header;
+                        });
+                        const colSpans = headers.map((header, idx) => {
+                            if (header === undefined) {
+                                return 0;
+                            }
+                            const nh = headers.slice(idx + 1);
+                            const nb = nh.findIndex((h) => h !== undefined);
+                            return nb == -1 ? nh.length + 1 : nb + 1;
+                        });
+                        headersInfo.push({ headers, colSpans });
+                    }
+                }
+                let nbWidth = 0;
+                let widthRate = 0;
+                const functions = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
+                    if (newCols[col].className) {
+                        pv.classNames = pv.classNames || {};
+                        pv.classNames[newCols[col].dfid] = newCols[col].className;
+                    }
+                    hNan = hNan || !!newCols[col].nanValue;
+                    if (newCols[col].tooltip) {
+                        pv.tooltips = pv.tooltips || {};
+                        pv.tooltips[newCols[col].dfid] = newCols[col].tooltip;
+                    }
+                    if (newCols[col].formatFn) {
+                        pv.formats = pv.formats || {};
+                        pv.formats[newCols[col].dfid] = newCols[col].formatFn;
+                    }
+                    if (newCols[col].width !== undefined) {
+                        const cssWidth = getCssSize(newCols[col].width);
+                        if (cssWidth) {
+                            newCols[col].width = cssWidth;
+                            nbWidth++;
+                            if (cssWidth.endsWith("%")) {
+                                widthRate += parseInt(cssWidth, 10);
                             }
                         }
-                        return pv;
-                    }, {});
-                    nbWidth = nbWidth ? colsOrder.length - nbWidth : 0;
-                    if (props.rowClassName) {
-                        functions.classNames = functions.classNames || {};
-                        functions.classNames[ROW_CLASS_NAME] = props.rowClassName;
                     }
-                    return [
-                        colsOrder,
-                        newCols,
-                        functions.classNames,
-                        functions.tooltips,
-                        functions.formats,
-                        hNan,
-                        filter,
-                        partialEditable,
-                        nbWidth > 0 ? `${(100 - widthRate) / nbWidth}%` : undefined
-                    ];
-                } catch (e) {
-                    console.info("PaginatedTable.columns: ", (e as Error).message || e);
+                    return pv;
+                }, {});
+                nbWidth = nbWidth ? colsOrder.length - nbWidth : 0;
+                if (props.rowClassName) {
+                    functions.classNames = functions.classNames || {};
+                    functions.classNames[ROW_CLASS_NAME] = props.rowClassName;
                 }
+                return [
+                    colsOrder,
+                    newCols,
+                    functions.classNames,
+                    functions.tooltips,
+                    functions.formats,
+                    hNan,
+                    filter,
+                    partialEditable,
+                    nbWidth > 0 ? `${(100 - widthRate) / nbWidth}%` : undefined,
+                    nbColHeaders,
+                    headersInfo,
+                ];
+            } catch (e) {
+                console.info("PaginatedTable.columns: ", (e as Error).message || e);
             }
-            return [
-                [] as string[],
-                {} as Record<string, ColumnDesc>,
-                {} as Record<string, string>,
-                {} as Record<string, string>,
-                {} as Record<string, string>,
-                hNan,
-                false,
-                false,
-                ""
-            ];
-        }, [
-            active,
-            editable,
-            onAdd,
-            onDelete,
-            baseColumns,
-            props.rowClassName,
-            props.tooltip,
-            props.nanValue,
-            props.filter,
-            downloadable,
-            sortable,
-        ]);
+        }
+        return [
+            [] as string[],
+            {} as Record<string, ColumnDesc>,
+            {} as Record<string, string>,
+            {} as Record<string, string>,
+            {} as Record<string, string>,
+            hNan,
+            false,
+            false,
+            "",
+            1,
+            [],
+        ];
+    }, [
+        active,
+        editable,
+        onAdd,
+        onDelete,
+        baseColumns,
+        props.rowClassName,
+        props.tooltip,
+        props.nanValue,
+        props.filter,
+        downloadable,
+        sortable,
+    ]);
 
     useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars);
 
@@ -482,7 +522,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
             dispatch(
                 createSendActionNameAction(updateVarName, module, {
                     action: onDelete,
-                    index: rows ? getRowIndex(rows[rowIndex], rowIndex, startIndex): startIndex,
+                    index: rows ? getRowIndex(rows[rowIndex], rowIndex, startIndex) : startIndex,
                     user_data: userData,
                 })
             ),
@@ -494,7 +534,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
             dispatch(
                 createSendActionNameAction(updateVarName, module, {
                     action: onAction,
-                    index: rows ? getRowIndex(rows[rowIndex], rowIndex, startIndex): startIndex,
+                    index: rows ? getRowIndex(rows[rowIndex], rowIndex, startIndex) : startIndex,
                     col: colName === undefined ? null : colName,
                     value,
                     reason: value === undefined ? "click" : "button",
@@ -517,6 +557,28 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
 
     const boxSx = useMemo(() => ({ ...baseBoxSx, width: width }), [width]);
 
+    const rowSpans = useMemo(
+        () =>
+            rows
+                ? colsOrder
+                      .filter((col) => columns[col].multi !== undefined)
+                      .map((col) => {
+                          const values = rows.map((r, idx) =>
+                              idx > 0 && rows[idx - 1][col] == r[col] ? undefined : r[col]
+                          );
+                          return values.map((value, idx) => {
+                              if (value === undefined) {
+                                  return 0;
+                              }
+                              const nv = values.slice(idx + 1);
+                              const nb = nv.findIndex((v) => v !== undefined);
+                              return nb == -1 ? nv.length + 1 : nb + 1;
+                          });
+                      })
+                : [],
+        [colsOrder, columns, rows]
+    );
+
     return (
         <Box
             id={id}
@@ -530,101 +592,153 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                     <TableContainer sx={tableContainerSx}>
                         <Table sx={tableSx} aria-labelledby="tableTitle" size={size} stickyHeader={true}>
                             <TableHead>
-                                <TableRow>
-                                    {colsOrder.map((col) => (
-                                        <TableCell
-                                            key={`head${columns[col].dfid}`}
-                                            sortDirection={orderBy === columns[col].dfid && order}
-                                            sx={
-                                                columns[col].width
-                                                    ? { minWidth:columns[col].width }
-                                                    : calcWidth
-                                                    ? { width: calcWidth }
-                                                    : undefined
-                                            }
-                                            className={col === "EDIT_COL"
-                                                ? getSuffixedClassNames(className, "-action")
-                                                : getSuffixedClassNames(className, generateHeaderClassName(columns[col].dfid))
-                                            }
-                                        >
-                                            {columns[col].dfid === EDIT_COL ? (
-                                                [
-                                                    active && (editable || partialEditable) && onAdd ? (
-                                                        <Tooltip title="Add a row" key="addARow">
-                                                            <IconButton
-                                                                onClick={onAddRowClick}
-                                                                size="small"
-                                                                sx={iconInRowSx}
-                                                            >
-                                                                <AddIcon fontSize="inherit" />
-                                                            </IconButton>
-                                                        </Tooltip>
-                                                    ) : null,
-                                                    active && filter ? (
-                                                        <TableFilter
-                                                            key="filter"
-                                                            columns={columns}
-                                                            colsOrder={colsOrder}
-                                                            onValidate={setAppliedFilters}
-                                                            appliedFilters={appliedFilters}
-                                                            className={className}
-                                                            filteredCount={filteredCount}
-                                                        />
-                                                    ) : null,
-                                                    active && downloadable ? (
-                                                        <Tooltip title="Download as CSV" key="downloadCsv">
-                                                            <IconButton
-                                                                onClick={onDownload}
-                                                                size="small"
-                                                                sx={iconInRowSx}
-                                                            >
-                                                                <Download fontSize="inherit" />
-                                                            </IconButton>
-                                                        </Tooltip>
-                                                    ) : null,
-                                                ]
-                                            ) : (
-                                                <TableSortLabel
-                                                    active={orderBy === columns[col].dfid}
-                                                    direction={orderBy === columns[col].dfid ? order : "asc"}
-                                                    data-dfid={columns[col].dfid}
-                                                    onClick={onSort}
-                                                    disabled={!active || !columns[col].sortable}
-                                                    hideSortIcon={!active || !columns[col].sortable}
-                                                >
-                                                    <Box sx={headBoxSx}>
-                                                        {columns[col].groupBy ? (
-                                                            <IconButton
-                                                                onClick={onAggregate}
-                                                                size="small"
-                                                                title="aggregate"
+                                {Array.from(Array(nbColHeaders).keys()).map((idx) => {
+                                    if (idx < nbColHeaders - 1) {
+                                        return (
+                                            <TableRow key={`rowheader${idx}`}>
+                                                {colsOrder.map((col, i) => {
+                                                    const colSpan =
+                                                        headersInfo[idx] && headersInfo[idx].colSpans.length > i
+                                                            ? headersInfo[idx].colSpans[i]
+                                                            : 1;
+                                                    return colSpan == 0 ? null : (
+                                                        <TableCell
+                                                            colSpan={colSpan}
+                                                            key={`head${columns[col].dfid}`}
+                                                            sx={
+                                                                columns[col].width
+                                                                    ? { minWidth: columns[col].width }
+                                                                    : calcWidth
+                                                                    ? { width: calcWidth }
+                                                                    : undefined
+                                                            }
+                                                            className={
+                                                                col === "EDIT_COL"
+                                                                    ? getSuffixedClassNames(className, "-action")
+                                                                    : getSuffixedClassNames(
+                                                                          className,
+                                                                          generateHeaderClassName(columns[col].dfid)
+                                                                      )
+                                                            }
+                                                        >
+                                                            {(headersInfo[idx] &&
+                                                                headersInfo[idx].headers.length > i &&
+                                                                headersInfo[idx].headers[i]) ||
+                                                                ""}
+                                                        </TableCell>
+                                                    );
+                                                })}
+                                            </TableRow>
+                                        );
+                                    } else {
+                                        return (
+                                            <TableRow key={`rowheader${idx}`}>
+                                                {colsOrder.map((col, i) => (
+                                                    <TableCell
+                                                        key={`head${columns[col].dfid}`}
+                                                        sortDirection={orderBy === columns[col].dfid && order}
+                                                        sx={
+                                                            columns[col].width
+                                                                ? { minWidth: columns[col].width }
+                                                                : calcWidth
+                                                                ? { width: calcWidth }
+                                                                : undefined
+                                                        }
+                                                        className={
+                                                            col === "EDIT_COL"
+                                                                ? getSuffixedClassNames(className, "-action")
+                                                                : getSuffixedClassNames(
+                                                                      className,
+                                                                      generateHeaderClassName(columns[col].dfid)
+                                                                  )
+                                                        }
+                                                    >
+                                                        {columns[col].dfid === EDIT_COL ? (
+                                                            [
+                                                                active && (editable || partialEditable) && onAdd ? (
+                                                                    <Tooltip title="Add a row" key="addARow">
+                                                                        <IconButton
+                                                                            onClick={onAddRowClick}
+                                                                            size="small"
+                                                                            sx={iconInRowSx}
+                                                                        >
+                                                                            <AddIcon fontSize="inherit" />
+                                                                        </IconButton>
+                                                                    </Tooltip>
+                                                                ) : null,
+                                                                active && filter ? (
+                                                                    <TableFilter
+                                                                        key="filter"
+                                                                        columns={columns}
+                                                                        colsOrder={colsOrder}
+                                                                        onValidate={setAppliedFilters}
+                                                                        appliedFilters={appliedFilters}
+                                                                        className={className}
+                                                                        filteredCount={filteredCount}
+                                                                    />
+                                                                ) : null,
+                                                                active && downloadable ? (
+                                                                    <Tooltip title="Download as CSV" key="downloadCsv">
+                                                                        <IconButton
+                                                                            onClick={onDownload}
+                                                                            size="small"
+                                                                            sx={iconInRowSx}
+                                                                        >
+                                                                            <Download fontSize="inherit" />
+                                                                        </IconButton>
+                                                                    </Tooltip>
+                                                                ) : null,
+                                                            ]
+                                                        ) : (
+                                                            <TableSortLabel
+                                                                active={orderBy === columns[col].dfid}
+                                                                direction={
+                                                                    orderBy === columns[col].dfid ? order : "asc"
+                                                                }
                                                                 data-dfid={columns[col].dfid}
-                                                                disabled={!active}
-                                                                sx={iconInRowSx}
+                                                                onClick={onSort}
+                                                                disabled={!active || !columns[col].sortable}
+                                                                hideSortIcon={!active || !columns[col].sortable}
                                                             >
-                                                                {aggregates.includes(columns[col].dfid) ? (
-                                                                    <DataSaverOff fontSize="inherit" />
-                                                                ) : (
-                                                                    <DataSaverOn fontSize="inherit" />
-                                                                )}
-                                                            </IconButton>
-                                                        ) : null}
-                                                        {columns[col].title === undefined
-                                                            ? columns[col].dfid
-                                                            : columns[col].title}
-                                                    </Box>
-                                                    {orderBy === columns[col].dfid ? (
-                                                        <Box component="span" sx={visuallyHidden}>
-                                                            {order === "desc"
-                                                                ? "sorted descending"
-                                                                : "sorted ascending"}
-                                                        </Box>
-                                                    ) : null}
-                                                </TableSortLabel>
-                                            )}
-                                        </TableCell>
-                                    ))}
-                                </TableRow>
+                                                                <Box sx={headBoxSx}>
+                                                                    {columns[col].groupBy ? (
+                                                                        <IconButton
+                                                                            onClick={onAggregate}
+                                                                            size="small"
+                                                                            title="aggregate"
+                                                                            data-dfid={columns[col].dfid}
+                                                                            disabled={!active}
+                                                                            sx={iconInRowSx}
+                                                                        >
+                                                                            {aggregates.includes(columns[col].dfid) ? (
+                                                                                <DataSaverOff fontSize="inherit" />
+                                                                            ) : (
+                                                                                <DataSaverOn fontSize="inherit" />
+                                                                            )}
+                                                                        </IconButton>
+                                                                    ) : null}
+                                                                    {columns[col].title === undefined
+                                                                        ? (headersInfo[idx] &&
+                                                                              headersInfo[idx].headers.length > i &&
+                                                                              headersInfo[idx].headers[i]) ||
+                                                                          columns[col].dfid
+                                                                        : columns[col].title}
+                                                                </Box>
+                                                                {orderBy === columns[col].dfid ? (
+                                                                    <Box component="span" sx={visuallyHidden}>
+                                                                        {order === "desc"
+                                                                            ? "sorted descending"
+                                                                            : "sorted ascending"}
+                                                                    </Box>
+                                                                ) : null}
+                                                            </TableSortLabel>
+                                                        )}
+                                                    </TableCell>
+                                                ))}
+                                            </TableRow>
+                                        );
+                                    }
+                                })}
                             </TableHead>
                             <TableBody>
                                 {rows?.map((row, index) => {
@@ -647,33 +761,37 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                                             data-index={index}
                                             onClick={active && onAction ? onRowClick : undefined}
                                         >
-                                            {colsOrder.map((col) => (
-                                                <EditableCell
-                                                    key={`cell${index}${columns[col].dfid}`}
-                                                    className={getClassName(row, columns[col].className, col)}
-                                                    tableClassName={className}
-                                                    colDesc={columns[col]}
-                                                    value={row[col]}
-                                                    formattedVal={getFormatFn(row, columns[col].formatFn, col)}
-                                                    formatConfig={formatConfig}
-                                                    rowIndex={index}
-                                                    onValidation={
-                                                        active && !columns[col].notEditable && onEdit
-                                                            ? onCellValidation
-                                                            : undefined
-                                                    }
-                                                    onDeletion={
-                                                        active && (editable || partialEditable) && onDelete
-                                                            ? onRowDeletion
-                                                            : undefined
-                                                    }
-                                                    onSelection={active && onAction ? onRowSelection : undefined}
-                                                    nanValue={columns[col].nanValue || props.nanValue}
-                                                    tooltip={getTooltip(row, columns[col].tooltip, col)}
-                                                    comp={compRows && compRows[index] && compRows[index][col]}
-                                                    useCheckbox={useCheckbox}
-                                                />
-                                            ))}
+                                            {colsOrder.map((col, idx) => {
+                                                const rowSpan = idx < rowSpans.length && index < rowSpans[idx].length ? rowSpans[idx][index] : 1;
+                                                return (
+                                                    <EditableCell
+                                                        key={`cell${index}${columns[col].dfid}`}
+                                                        className={getClassName(row, columns[col].className, col)}
+                                                        tableClassName={className}
+                                                        colDesc={columns[col]}
+                                                        value={row[col]}
+                                                        formattedVal={getFormatFn(row, columns[col].formatFn, col)}
+                                                        formatConfig={formatConfig}
+                                                        rowIndex={index}
+                                                        onValidation={
+                                                            active && !columns[col].notEditable && onEdit
+                                                                ? onCellValidation
+                                                                : undefined
+                                                        }
+                                                        onDeletion={
+                                                            active && (editable || partialEditable) && onDelete
+                                                                ? onRowDeletion
+                                                                : undefined
+                                                        }
+                                                        onSelection={active && onAction ? onRowSelection : undefined}
+                                                        nanValue={columns[col].nanValue || props.nanValue}
+                                                        tooltip={getTooltip(row, columns[col].tooltip, col)}
+                                                        comp={compRows && compRows[index] && compRows[index][col]}
+                                                        useCheckbox={useCheckbox}
+                                                        rowSpan={rowSpan}
+                                                    />
+                                                );
+                                            })}
                                         </TableRow>
                                     );
                                 })}

+ 19 - 1
frontend/taipy-gui/src/components/Taipy/tableUtils.spec.tsx

@@ -2,7 +2,7 @@ import { render } from "@testing-library/react";
 import "@testing-library/jest-dom";
 import userEvent from "@testing-library/user-event";
 
-import { EditableCell, generateHeaderClassName, RowValue } from "./tableUtils";
+import { ColumnDesc, EditableCell, generateHeaderClassName, getSortByIndex, RowValue } from "./tableUtils";
 
 describe("generateHeaderClassName", () => {
     it("should generate a CSS class name with a hyphen prefix and convert to lowercase", () => {
@@ -116,3 +116,21 @@ describe("Editable cell", () => {
         });
     });
 });
+
+describe("getSortByIndex", () => {
+    it("should return a sorted list for indexed columns", () => {
+        const columns = { col0: { index: 0 } as ColumnDesc, col1: { index: 1 } as ColumnDesc, col2: { index: 2 } as ColumnDesc };
+        const result = Object.keys(columns).sort(getSortByIndex(columns));
+        expect(result).toEqual(["col0", "col1", "col2"]);
+    });
+    it("should return a sorted list for multi columns", () => {
+        const columns = { col0: { multi: 0 } as ColumnDesc, col1: { multi: 1 } as ColumnDesc, col2: { multi: 2 } as ColumnDesc };
+        const result = Object.keys(columns).sort(getSortByIndex(columns));
+        expect(result).toEqual(["col0", "col1", "col2"]);
+    });
+    it("should return a sorted list for indexed and multi columns", () => {
+        const columns = { col0: { index: 0 } as ColumnDesc, col1: { index: 1 } as ColumnDesc, col2: { multi: 1, index: 2 } as ColumnDesc, col3: { multi: 0, index: 3 } as ColumnDesc };
+        const result = Object.keys(columns).sort(getSortByIndex(columns));
+        expect(result).toEqual(["col3", "col2", "col0", "col1"]);
+    });
+});

+ 53 - 34
frontend/taipy-gui/src/components/Taipy/tableUtils.tsx

@@ -56,11 +56,11 @@ import { TaipyActiveProps, TaipyMultiSelectProps, getSuffixedClassNames } from "
 
 export const generateHeaderClassName = (columnName: string | undefined): string => {
     // logic for the css header classname
-    if (!columnName){
+    if (!columnName) {
         // return an empty string if columname is undefined or empty
         return "";
     }
-    return '-' + columnName.replace(/\W+/g, '-').replace(/-+/g, '-').toLowerCase();
+    return "-" + columnName.replace(/\W+/g, "-").replace(/-+/g, "-").toLowerCase();
 };
 
 export interface ColumnDesc {
@@ -104,6 +104,10 @@ export interface ColumnDesc {
     freeLov?: boolean;
     /** If false, the column cannot be sorted */
     sortable?: boolean;
+    /** The column headers if more than one. */
+    headers?: string[];
+    /** The index of the multi index if exists. */
+    multi?: number;
 }
 
 export const DEFAULT_SIZE = "small";
@@ -183,8 +187,8 @@ export const tableSx = { minWidth: 250 };
 export const headBoxSx = { display: "flex", alignItems: "flex-start" };
 export const iconInRowSx = { fontSize: "body2.fontSize" };
 export const iconsWrapperSx = { gridColumnStart: 2, display: "flex", alignItems: "center" } as CSSProperties;
-const cellBoxSx = { display: "grid", gridTemplateColumns: "1fr auto", alignItems: "center" } as CSSProperties;
-const tableFontSx = { fontSize: "body2.fontSize" };
+const CellBoxSx = { display: "grid", gridTemplateColumns: "1fr auto", alignItems: "center" } as CSSProperties;
+const TableFontSx = { fontSize: "body2.fontSize" };
 const ButtonSx = { minHeight: "unset", mb: "unset", padding: "unset", lineHeight: "unset" };
 export interface OnCellValidation {
     (value: RowValue, rowIndex: number, colName: string, userValue: string, tz?: string): void;
@@ -218,6 +222,7 @@ interface EditableCellProps {
     comp?: RowValue;
     useCheckbox?: boolean;
     formattedVal?: string;
+    rowSpan?: number;
 }
 
 export interface FilterDesc {
@@ -232,7 +237,13 @@ export interface FilterDesc {
 export const defaultColumns = {} as Record<string, ColumnDesc>;
 
 export const getSortByIndex = (cols: Record<string, ColumnDesc>) => (key1: string, key2: string) =>
-    cols[key1].index < cols[key2].index ? -1 : cols[key1].index > cols[key2].index ? 1 : 0;
+    cols[key1].multi !== undefined
+        ? cols[key2].multi !== undefined
+            ? cols[key1].multi - cols[key2].multi
+            : -1
+        : cols[key2].multi !== undefined
+        ? 1
+        : cols[key1].index - cols[key2].index;
 
 const formatValue = (val: RowValue, col: ColumnDesc, formatConf: FormatConfig, nanValue?: string): string => {
     if (val === undefined) {
@@ -352,6 +363,11 @@ export const getPageKey = (
         .filter((v) => v)
         .join("-");
 
+export const getColumnHeader = (columns: Record<string, ColumnDesc>, columnKey: string, headerLevel: number) =>
+    columns[columnKey].headers && columns[columnKey].headers?.length > headerLevel
+        ? columns[columnKey].headers[headerLevel]
+        : undefined;
+
 const setInputFocus = (input: HTMLInputElement) => input && input.focus();
 
 const textFieldProps = { textField: { margin: "dense" } } as BaseDateTimePickerSlotProps<Date>;
@@ -381,6 +397,7 @@ export const EditableCell = (props: EditableCellProps) => {
         comp,
         useCheckbox = false,
         formattedVal: formattedValue,
+        rowSpan = 1,
     } = props;
     const [val, setVal] = useState<RowValue | Date>(value);
     const [edit, setEdit] = useState(false);
@@ -551,7 +568,7 @@ export const EditableCell = (props: EditableCellProps) => {
         !onValidation && setEdit(false);
     }, [onValidation]);
 
-    return (
+    return rowSpan == 0 ? null : (
         <TableCell
             {...getCellProps(colDesc, tableCellProps)}
             className={
@@ -564,31 +581,33 @@ export const EditableCell = (props: EditableCellProps) => {
                       }`
                     : undefined
             }
+            component={colDesc.multi !== undefined ? "th" : undefined}
+            rowSpan={rowSpan}
         >
             <Badge color="primary" variant="dot" invisible={comp === undefined || comp === null}>
                 {edit ? (
                     colDesc.type?.startsWith("bool") ? (
-                        <Box sx={cellBoxSx}>
+                        <Box sx={CellBoxSx}>
                             {useCheckbox ? (
-                            <input
-                                type="checkbox"
-                                checked={val as boolean}
-                                title={boolTitle}
-                                style={iconInRowSx}
-                                className={getSuffixedClassNames(tableClassName, "-bool")}
-                                ref={setInputFocus}
-                                onChange={onBoolChange}
-                            />
+                                <input
+                                    type="checkbox"
+                                    checked={val as boolean}
+                                    title={boolTitle}
+                                    style={iconInRowSx}
+                                    className={getSuffixedClassNames(tableClassName, "-bool")}
+                                    ref={setInputFocus}
+                                    onChange={onBoolChange}
+                                />
                             ) : (
-                            <Switch
-                                checked={val as boolean}
-                                size="small"
-                                title={boolTitle}
-                                sx={iconInRowSx}
-                                onChange={onBoolChange}
-                                inputRef={setInputFocus}
-                                className={getSuffixedClassNames(tableClassName, "-bool")}
-                            />
+                                <Switch
+                                    checked={val as boolean}
+                                    size="small"
+                                    title={boolTitle}
+                                    sx={iconInRowSx}
+                                    onChange={onBoolChange}
+                                    inputRef={setInputFocus}
+                                    className={getSuffixedClassNames(tableClassName, "-bool")}
+                                />
                             )}
                             <Box sx={iconsWrapperSx}>
                                 <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
@@ -600,14 +619,14 @@ export const EditableCell = (props: EditableCellProps) => {
                             </Box>
                         </Box>
                     ) : colDesc.type?.startsWith("date") ? (
-                        <Box sx={cellBoxSx}>
+                        <Box sx={CellBoxSx}>
                             {withTime ? (
                                 <DateTimePicker
                                     value={val as Date}
                                     onChange={onDateChange}
                                     slotProps={textFieldProps}
                                     inputRef={setInputFocus}
-                                    sx={tableFontSx}
+                                    sx={TableFontSx}
                                     className={getSuffixedClassNames(tableClassName, "-date")}
                                 />
                             ) : (
@@ -616,7 +635,7 @@ export const EditableCell = (props: EditableCellProps) => {
                                     onChange={onDateChange}
                                     slotProps={textFieldProps}
                                     inputRef={setInputFocus}
-                                    sx={tableFontSx}
+                                    sx={TableFontSx}
                                     className={getSuffixedClassNames(tableClassName, "-date")}
                                 />
                             )}
@@ -630,7 +649,7 @@ export const EditableCell = (props: EditableCellProps) => {
                             </Box>
                         </Box>
                     ) : colDesc.lov ? (
-                        <Box sx={cellBoxSx}>
+                        <Box sx={CellBoxSx}>
                             <Autocomplete
                                 autoComplete={true}
                                 fullWidth
@@ -653,7 +672,7 @@ export const EditableCell = (props: EditableCellProps) => {
                                         onChange={colDesc.freeLov ? onChange : undefined}
                                         margin="dense"
                                         variant="standard"
-                                        sx={tableFontSx}
+                                        sx={TableFontSx}
                                         className={getSuffixedClassNames(tableClassName, "-input")}
                                     />
                                 )}
@@ -675,7 +694,7 @@ export const EditableCell = (props: EditableCellProps) => {
                             onKeyDown={onKeyDown}
                             inputRef={setInputFocus}
                             margin="dense"
-                            sx={tableFontSx}
+                            sx={TableFontSx}
                             className={getSuffixedClassNames(tableClassName, "-input")}
                             endAdornment={
                                 <Box sx={iconsWrapperSx}>
@@ -695,7 +714,7 @@ export const EditableCell = (props: EditableCellProps) => {
                             value="Confirm"
                             onKeyDown={onDeleteKeyDown}
                             inputRef={setInputFocus}
-                            sx={tableFontSx}
+                            sx={TableFontSx}
                             className={getSuffixedClassNames(tableClassName, "-delete")}
                             endAdornment={
                                 <Box sx={iconsWrapperSx}>
@@ -716,7 +735,7 @@ export const EditableCell = (props: EditableCellProps) => {
                         </Box>
                     ) : null
                 ) : (
-                    <Box sx={cellBoxSx} onClick={onSelect}>
+                    <Box sx={CellBoxSx} onClick={onSelect}>
                         {buttonImg ? (
                             buttonImg.img ? (
                                 <img
@@ -767,7 +786,7 @@ export const EditableCell = (props: EditableCellProps) => {
                                 )}
                             </span>
                         )}
-                        {onValidation && !buttonImg ? (
+                        {onValidation && !buttonImg && colDesc.multi === undefined ? (
                             <Box sx={iconsWrapperSx}>
                                 <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
                                     <EditIcon fontSize="inherit" />

+ 12 - 9
taipy/gui/_renderers/builder.py

@@ -556,9 +556,12 @@ class _Builder:
                     + "}"
                 )
                 self.__update_vars.append(f"comparedatas={','.join(cmp_datas_hash)}")
-        col_types = self.__gui._get_accessor().get_col_types(data_hash, _TaipyData(data, data_hash))
+        cols_description = self.__gui._get_accessor().get_cols_description(data_hash, _TaipyData(data, data_hash))
         col_dict = _get_columns_dict(
-            data, self.__attributes.get("columns", {}), col_types, date_format, self.__attributes.get("number_format")
+            self.__attributes.get("columns", {}),
+            cols_description,
+            date_format,
+            self.__attributes.get("number_format"),
         )
 
         rebuild_fn_hash = self.__build_rebuild_fn(
@@ -588,7 +591,7 @@ class _Builder:
                 value = row_class_name.strip()
             else:
                 value = None
-            if value in col_types.keys():
+            if value in cols_description.keys():
                 _warn(f"{self.__element_name}: row_class_name={value} must not be a column name.")
             elif value:
                 self.set_attribute("rowClassName", value)
@@ -599,7 +602,7 @@ class _Builder:
                 value = tooltip.strip()
             else:
                 value = None
-            if value in col_types.keys():
+            if value in cols_description.keys():
                 _warn(f"{self.__element_name}: tooltip={value} must not be a column name.")
             elif value:
                 self.set_attribute("tooltip", value)
@@ -618,7 +621,7 @@ class _Builder:
         # read column definitions
         data = self.__attributes.get("data")
         data_hash = self.__hashes.get("data", "")
-        col_types = [self.__gui._get_accessor().get_col_types(data_hash, _TaipyData(data, data_hash))]
+        cols_description = [self.__gui._get_accessor().get_cols_description(data_hash, _TaipyData(data, data_hash))]
 
         if data_hash:
             data_updates: t.List[str] = []
@@ -627,16 +630,16 @@ class _Builder:
             while add_data_hash := self.__hashes.get(name_idx):
                 typed_hash = self.__get_typed_hash_name(add_data_hash, _TaipyData)
                 data_updates.append(typed_hash)
-                self.__set_react_attribute(f"data{data_idx}",_get_client_var_name(typed_hash))
+                self.__set_react_attribute(f"data{data_idx}", _get_client_var_name(typed_hash))
                 add_data = self.__attributes.get(name_idx)
                 data_idx += 1
                 name_idx = f"data[{data_idx}]"
-                col_types.append(
-                    self.__gui._get_accessor().get_col_types(add_data_hash, _TaipyData(add_data, add_data_hash))
+                cols_description.append(
+                    self.__gui._get_accessor().get_cols_description(add_data_hash, _TaipyData(add_data, add_data_hash))
                 )
             self.set_attribute("dataVarNames", ";".join(data_updates))
 
-        config = _build_chart_config(self.__gui, self.__attributes, col_types)
+        config = _build_chart_config(self.__gui, self.__attributes, cols_description)
 
         self.__set_json_attribute("defaultConfig", config)
         self._set_chart_selected(max=len(config.get("traces", [])))

+ 32 - 48
taipy/gui/_renderers/utils.py

@@ -13,7 +13,6 @@ import datetime
 import typing as t
 from pathlib import Path
 
-import pandas as pd
 from watchdog.events import FileSystemEventHandler
 
 from taipy.common.logger._taipy_logger import _TaipyLogger
@@ -37,56 +36,35 @@ def _get_tuple_val(attr: tuple, index: int, default_val: t.Any) -> t.Any:
 
 
 def _get_columns_dict_from_list(
-    col_list: t.Union[t.List[str], t.Tuple[str]], col_types_keys: t.List[str], value: t.Any
+    col_list: t.Union[t.List[str], t.Tuple[str]], cols_description: t.Dict[str, t.Dict[str, str]]
 ):
-    col_dict = {}
+    col_dict: t.Dict[str, t.Dict[str, t.Any]] = {}
     idx = 0
-    cols = None
-
     for col in col_list:
-        if col in col_types_keys:
-            col_dict[col] = {"index": idx}
+        if col in cols_description:
+            col_dict[col] = cols_description[col].copy()
+            col_dict[col]["index"] = idx
             idx += 1
-        elif col:
-            if cols is None:
-                cols = (
-                    list(value.columns)
-                    if isinstance(value, pd.DataFrame)
-                    else list(value.keys())
-                    if isinstance(value, (dict, _MapDict))
-                    else value
-                    if isinstance(value, (list, tuple))
-                    else []
-                )
-
-            if cols and (col not in cols):
-                _warn(
-                    f'Column "{col}" is not present. Available columns: {cols}.'  # noqa: E501
-                )
-            else:
-                _warn(
-                    "The 'data' property value is of an unsupported type."
-                    + " Only DataFrame, dict, list, or tuple are supported."
-                )
+        elif col and col not in cols_description:
+            _warn(f'Column "{col}" is not present. Available columns: {list(cols_description)}.')
     return col_dict
 
 
 def _get_columns_dict(  # noqa: C901
-    value: t.Any,
     columns: t.Union[str, t.List[str], t.Tuple[str], t.Dict[str, t.Any], _MapDict],
-    col_types: t.Optional[t.Dict[str, str]] = None,
+    cols_description: t.Optional[t.Dict[str, t.Dict[str, str]]] = None,
     date_format: t.Optional[str] = None,
     number_format: t.Optional[str] = None,
     opt_columns: t.Optional[t.Set[str]] = None,
 ):
-    if col_types is None:
+    if cols_description is None:
         return None
-    col_types_keys = [str(c) for c in col_types.keys()]
+    col_types_keys = [str(c) for c in cols_description.keys()]
     col_dict: t.Optional[dict] = None
     if isinstance(columns, str):
-        col_dict = _get_columns_dict_from_list([s.strip() for s in columns.split(";")], col_types_keys, value)
+        col_dict = _get_columns_dict_from_list([s.strip() for s in columns.split(";")], cols_description)
     elif isinstance(columns, (list, tuple)):
-        col_dict = _get_columns_dict_from_list(columns, col_types_keys, value)  # type: ignore[arg-type]
+        col_dict = _get_columns_dict_from_list(columns, cols_description)
     elif isinstance(columns, _MapDict):
         col_dict = columns._dict.copy()
     elif isinstance(columns, dict):
@@ -96,8 +74,8 @@ def _get_columns_dict(  # noqa: C901
         col_dict = {}
     nb_cols = len(col_dict)
     if nb_cols == 0:
-        for col in col_types_keys:
-            col_dict[col] = {"index": nb_cols}
+        for col in cols_description:
+            col_dict[str(col)] = {"index": nb_cols}
             nb_cols += 1
     else:
         col_dict = {str(k): v for k, v in col_dict.items()}
@@ -107,23 +85,29 @@ def _get_columns_dict(  # noqa: C901
                     col_dict[col] = {"index": nb_cols}
                     nb_cols += 1
     idx = 0
-    for col, ctype in col_types.items():
+    for col, col_description in cols_description.items():
         col = str(col)
         if col in col_dict:
-            re_type = _RE_PD_TYPE.match(ctype)
-            grps = re_type.groups() if re_type else ()
-            ctype = grps[0] if grps else ctype
-            col_dict[col]["type"] = ctype
-            col_dict[col]["dfid"] = col
-            if len(grps) > 4 and grps[4]:
-                col_dict[col]["tz"] = grps[4]
-            idx = _add_to_dict_and_get(col_dict[col], "index", idx) + 1
-            if ctype == "datetime":
+            col_type = col_description.get("type", "")
+            re_type = _RE_PD_TYPE.match(col_type)
+            groups = re_type.groups() if re_type else ()
+            col_type = groups[0] if groups else col_type
+            if len(groups) > 4 and groups[4]:
+                col_dict[col]["tz"] = groups[4]
+            old_col = None
+            if col_type == "datetime":
                 if date_format:
                     _add_to_dict_and_get(col_dict[col], "format", date_format)
-                col_dict[_get_date_col_str_name(col_types.keys(), col)] = col_dict.pop(col)  # type: ignore
-            elif number_format and ctype in NumberTypes:
+                old_col = col
+                col = _get_date_col_str_name(cols_description.keys(), col)
+                col_dict[col] = col_dict.pop(old_col)
+            elif number_format and col_type in NumberTypes:
                 _add_to_dict_and_get(col_dict[col], "format", number_format)
+            if "index" not in col_dict[col]:
+                col_dict[col]["index"] = idx
+            idx += 1
+            col_dict[col]["type"] = col_type
+            col_dict[col]["dfid"] = old_col or col
     return col_dict
 
 

+ 6 - 4
taipy/gui/data/array_dict_data_accessor.py

@@ -39,9 +39,11 @@ class _ArrayDictDataAccessor(_PandasDataAccessor):
                         if len(lengths) == 1
                         else [pd.DataFrame({f"{i}/0": v}) for i, v in enumerate(value)]
                     )
-                elif type_elt is dict:
+                elif type_elt is dict and isinstance(next(iter(t.cast(dict, value[0]).values()), None), (list, tuple)):
                     return [pd.DataFrame(v) for v in value]
-                elif type_elt is _MapDict:
+                elif type_elt is _MapDict and isinstance(
+                    next(iter(t.cast(_MapDict, value[0])._dict.values()), None), (list, tuple)
+                ):
                     return [pd.DataFrame(v._dict) for v in value]
                 elif type_elt is pd.DataFrame:
                     return t.cast(t.List[pd.DataFrame], value)
@@ -64,8 +66,8 @@ class _ArrayDictDataAccessor(_PandasDataAccessor):
                 return tuple(value.iloc[:, 0].to_list())
         return super()._from_pandas(value, data_type)
 
-    def get_col_types(self, var_name: str, value: t.Any) -> t.Union[None, t.Dict[str, str]]:  # type: ignore
-        return super().get_col_types(var_name, self.to_pandas(value))
+    def get_cols_description(self, var_name: str, value: t.Any) -> t.Union[None, t.Dict[str, t.Dict[str, str]]]:  # type: ignore
+        return super().get_cols_description(var_name, self.to_pandas(value))
 
     def get_data(  # noqa: C901
         self, var_name: str, value: t.Any, payload: t.Dict[str, t.Any], data_format: _DataFormat

+ 11 - 10
taipy/gui/data/data_accessor.py

@@ -39,7 +39,7 @@ class _DataAccessor(ABC):
         pass
 
     @abstractmethod
-    def get_col_types(self, var_name: str, value: t.Any) -> t.Dict[str, str]:
+    def get_cols_description(self, var_name: str, value: t.Any) -> t.Dict[str, t.Dict[str, str]]:
         pass
 
     @abstractmethod
@@ -75,7 +75,7 @@ class _InvalidDataAccessor(_DataAccessor):
     ) -> t.Dict[str, t.Any]:
         return {}
 
-    def get_col_types(self, var_name: str, value: t.Any) -> t.Dict[str, str]:
+    def get_cols_description(self, var_name: str, value: t.Any) -> t.Dict[str, t.Dict[str, str]]:
         return {}
 
     def to_pandas(self, value: t.Any) -> t.Union[t.List[t.Any], t.Any]:
@@ -109,7 +109,7 @@ class _DataAccessors(object):
         self._register(_ArrayDictDataAccessor)
         self._register(_NumpyDataAccessor)
 
-    def _register(self, cls: t.Type[_DataAccessor]) -> None:
+    def _register(self, cls: t.Type[_DataAccessor], force: t.Optional[bool] = False) -> None:
         """Register a new DataAccessor type."""
         if not inspect.isclass(cls):
             raise AttributeError("The argument of 'DataAccessors.register()' should be a class")
@@ -120,10 +120,11 @@ class _DataAccessors(object):
             raise TypeError(f"{cls.__name__}.get_supported_classes() returned an invalid value")
         # check existence
         inst: t.Optional[_DataAccessor] = None
-        for cl in classes:
-            inst = self.__access_4_type.get(cl)
-            if inst:
-                break
+        if force:
+            for cl in classes:
+                inst = self.__access_4_type.get(cl)
+                if inst:
+                    break
         if inst is None:
             try:
                 inst = cls(self.__gui)
@@ -134,7 +135,7 @@ class _DataAccessors(object):
                     self.__access_4_type[cl] = inst  # type: ignore
 
     def _unregister(self, cls: t.Type[_DataAccessor]) -> None:
-        """Unregisters a DataAccessor type."""
+        """Unregister a DataAccessor type."""
         if cls in self.__access_4_type:
             del self.__access_4_type[cls]
 
@@ -155,8 +156,8 @@ class _DataAccessors(object):
     def get_data(self, var_name: str, value: _TaipyData, payload: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
         return self.__get_instance(value).get_data(var_name, value.get(), payload, self.__data_format)
 
-    def get_col_types(self, var_name: str, value: _TaipyData) -> t.Dict[str, str]:
-        return self.__get_instance(value).get_col_types(var_name, value.get())
+    def get_cols_description(self, var_name: str, value: _TaipyData) -> t.Dict[str, t.Dict[str, str]]:
+        return self.__get_instance(value).get_cols_description(var_name, value.get())
 
     def set_data_format(self, data_format: _DataFormat):
         self.__data_format = data_format

+ 102 - 63
taipy/gui/data/pandas_data_accessor.py

@@ -32,6 +32,8 @@ if util.find_spec("pyarrow"):
     _has_arrow_module = True
     import pyarrow as pa
 
+_ORIENT_TYPE = t.Literal["records", "list"]
+
 
 class _PandasDataAccessor(_DataAccessor):
     __types = (pd.DataFrame, pd.Series)
@@ -40,10 +42,14 @@ class _PandasDataAccessor(_DataAccessor):
 
     __AGGREGATE_FUNCTIONS: t.List[str] = ["count", "sum", "mean", "median", "min", "max", "std", "first", "last"]
 
+    @staticmethod
+    def get_supported_classes() -> t.List[t.Type]:
+        return list(_PandasDataAccessor.__types)
+
     def to_pandas(self, value: t.Union[pd.DataFrame, pd.Series]) -> t.Union[t.List[pd.DataFrame], pd.DataFrame]:
-        return self.__to_dataframe(value)
+        return self._to_dataframe(value)
 
-    def __to_dataframe(self, value: t.Union[pd.DataFrame, pd.Series]) -> pd.DataFrame:
+    def _to_dataframe(self, value: t.Union[pd.DataFrame, pd.Series]) -> pd.DataFrame:
         if isinstance(value, pd.Series):
             return pd.DataFrame(value)
         return t.cast(pd.DataFrame, value)
@@ -53,10 +59,6 @@ class _PandasDataAccessor(_DataAccessor):
             return value.iloc[:, 0]
         return value
 
-    @staticmethod
-    def get_supported_classes() -> t.List[t.Type]:
-        return list(_PandasDataAccessor.__types)
-
     @staticmethod
     def __user_function(
         row: pd.Series, gui: Gui, column_name: t.Optional[str], user_function: t.Callable, function_name: str
@@ -73,11 +75,14 @@ class _PandasDataAccessor(_DataAccessor):
             _warn(f"Exception raised when calling user function {function_name}()", e)
         return ""
 
-    def __is_date_column(self, data: pd.DataFrame, col_name: str) -> bool:
-        col_types = data.dtypes[data.dtypes.index.astype(str) == col_name]
-        return len(col_types[col_types.astype(str).str.startswith("datetime")]) > 0  # type: ignore
+    def __get_column_names(self, df: pd.DataFrame, *cols: str):
+        col_names = [t for t in df.columns if str(t) in cols]
+        return (col_names[0] if len(cols) == 1 else col_names) if col_names else None
+
+    def get_dataframe_with_cols(self, df: pd.DataFrame, cols: t.List[str]) -> pd.DataFrame:
+        return df.loc[:, df.dtypes[df.columns.astype(str).isin(cols)].index]  # type: ignore[index]
 
-    def __build_transferred_cols(
+    def __build_transferred_cols(  # noqa: C901
         self,
         payload_cols: t.Any,
         dataframe: pd.DataFrame,
@@ -90,10 +95,10 @@ class _PandasDataAccessor(_DataAccessor):
     ) -> pd.DataFrame:
         dataframe = dataframe.iloc[new_indexes] if new_indexes is not None else dataframe
         if isinstance(payload_cols, list) and len(payload_cols):
-            col_types = dataframe.dtypes[dataframe.dtypes.index.astype(str).isin(payload_cols)]
+            cols_description = {k: v for k, v in self.get_cols_description("", dataframe).items() if k in payload_cols}
         else:
-            col_types = dataframe.dtypes
-        cols = col_types.index.astype(str).tolist()
+            cols_description = self.get_cols_description("", dataframe)
+        cols = list(cols_description.keys())
         new_cols = {}
         if styles:
             for k, v in styles.items():
@@ -124,19 +129,20 @@ class _PandasDataAccessor(_DataAccessor):
                     if col_applied:
                         new_cols[col_applied] = new_data
         # deal with dates
-        date_cols = col_types[col_types.astype(str).str.startswith("datetime")].index.tolist()
+        date_cols = [c for c, d in cols_description.items() if d.get("type", "").startswith("datetime")]
         if len(date_cols) != 0:
             if not is_copied:
                 # copy the df so that we don't "mess" with the user's data
                 dataframe = dataframe.copy()
             tz = Gui._get_timezone()
             for col in date_cols:
+                col_name = self.__get_column_names(dataframe, col)
                 new_col = _get_date_col_str_name(cols, col)
-                re_type = _RE_PD_TYPE.match(str(col_types[col]))
+                re_type = _RE_PD_TYPE.match(cols_description[col].get("type", ""))
                 groups = re_type.groups() if re_type else ()
                 if len(groups) > 4 and groups[4]:
                     new_cols[new_col] = (
-                        dataframe[col]
+                        dataframe[col_name]
                         .dt.tz_convert("UTC")
                         .dt.strftime(_DataAccessor._WS_DATE_FORMAT)
                         .astype(str)
@@ -144,7 +150,7 @@ class _PandasDataAccessor(_DataAccessor):
                     )
                 else:
                     new_cols[new_col] = (
-                        dataframe[col]
+                        dataframe[col_name]
                         .dt.tz_localize(tz)
                         .dt.tz_convert("UTC")
                         .dt.strftime(_DataAccessor._WS_DATE_FORMAT)
@@ -157,8 +163,7 @@ class _PandasDataAccessor(_DataAccessor):
         if new_cols:
             dataframe = dataframe.assign(**new_cols)
         cols += list(new_cols.keys())
-        dataframe = dataframe.loc[:, dataframe.dtypes[dataframe.dtypes.index.astype(str).isin(cols)].index]  # type: ignore[index]
-        return dataframe
+        return self.get_dataframe_with_cols(dataframe, cols)
 
     def __apply_user_function(
         self,
@@ -173,17 +178,22 @@ class _PandasDataAccessor(_DataAccessor):
             return new_col_name, data.apply(
                 _PandasDataAccessor.__user_function,
                 axis=1,
-                args=(self._gui, column_name, user_function, function_name),
+                args=(
+                    self._gui,
+                    self.__get_column_names(data, column_name) if column_name else column_name,
+                    user_function,
+                    function_name,
+                ),
             )
         except Exception as e:
             _warn(f"Exception raised when invoking user function {function_name}()", e)
         return "", data
 
-    def __format_data(
+    def _format_data(
         self,
         data: pd.DataFrame,
         data_format: _DataFormat,
-        orient: str,
+        orient: _ORIENT_TYPE,
         start: t.Optional[int] = None,
         rowcount: t.Optional[int] = None,
         data_extraction: t.Optional[bool] = None,
@@ -220,18 +230,28 @@ class _PandasDataAccessor(_DataAccessor):
             ret["orient"] = orient
         else:
             # Workaround for Python built in JSON encoder that does not yet support ignore_nan
-            ret["data"] = data.replace([np.nan, pd.NA], [None, None]).to_dict(orient=orient)  # type: ignore
+            ret["data"] = self.get_json_ready_dict(data.replace([np.nan, pd.NA], [None, None]), orient)
         return ret
 
-    def get_col_types(self, var_name: str, value: t.Any) -> t.Union[None, t.Dict[str, str]]:  # type: ignore
+    def get_json_ready_dict(self, df: pd.DataFrame, orient: _ORIENT_TYPE) -> t.Dict[t.Hashable, t.Any]:
+        return df.to_dict(orient=orient)  # type: ignore[return-value]
+
+    def get_cols_description(self, var_name: str, value: t.Any) -> t.Dict[str, t.Dict[str, str]]:
         if isinstance(value, list):
-            ret_dict: t.Dict[str, str] = {}
+            ret_dict: t.Dict[str, t.Dict[str, str]] = {}
             for i, v in enumerate(value):
-                ret_dict.update(
-                    {f"{i}/{k}": v for k, v in self.__to_dataframe(v).dtypes.apply(lambda x: x.name.lower()).items()}
-                )
+                res = self.get_cols_description("", v)
+                if res:
+                    ret_dict.update({f"{i}/{k}": desc for k, desc in res.items()})
             return ret_dict
-        return {str(k): v for k, v in self.__to_dataframe(value).dtypes.apply(lambda x: x.name.lower()).items()}
+        df = self._to_dataframe(value)
+        return {str(k): {"type": v} for k, v in df.dtypes.apply(lambda x: x.name.lower()).items()}
+
+    def add_optional_columns(self, df: pd.DataFrame, columns: t.List[str]) -> t.Tuple[pd.DataFrame, t.List[str]]:
+        return df, []
+
+    def is_dataframe_supported(self, df: pd.DataFrame) -> bool:
+        return not isinstance(df.columns, pd.MultiIndex)
 
     def __get_data(  # noqa: C901
         self,
@@ -241,10 +261,15 @@ class _PandasDataAccessor(_DataAccessor):
         data_format: _DataFormat,
         col_prefix: t.Optional[str] = "",
     ) -> t.Dict[str, t.Any]:
+        ret_payload = {"pagekey": payload.get("pagekey", "unknown page")}
+        if not self.is_dataframe_supported(df):
+            ret_payload["value"] = {}
+            ret_payload["error"] = "MultiIndex columns are not supported."
+            _warn("MultiIndex columns are not supported.")
+            return ret_payload
         columns = payload.get("columns", [])
         if col_prefix:
             columns = [c[len(col_prefix) :] if c.startswith(col_prefix) else c for c in columns]
-        ret_payload = {"pagekey": payload.get("pagekey", "unknown page")}
         paged = not payload.get("alldata", False)
         is_copied = False
 
@@ -253,9 +278,12 @@ class _PandasDataAccessor(_DataAccessor):
         if paged:
             if _PandasDataAccessor.__INDEX_COL not in df.columns:
                 is_copied = True
-                df = df.assign(**{_PandasDataAccessor.__INDEX_COL: df.index})
+                df = df.assign(**{_PandasDataAccessor.__INDEX_COL: df.index.to_numpy()})
             if columns and _PandasDataAccessor.__INDEX_COL not in columns:
                 columns.append(_PandasDataAccessor.__INDEX_COL)
+        # optional columns
+        df, optional_columns = self.add_optional_columns(df, columns)
+        is_copied = is_copied or bool(optional_columns)
 
         fullrowcount = len(df)
         # filtering
@@ -263,6 +291,7 @@ class _PandasDataAccessor(_DataAccessor):
         if isinstance(filters, list) and len(filters) > 0:
             query = ""
             vars = []
+            cols_description = self.get_cols_description(var_name, df)
             for fd in filters:
                 col = fd.get("col")
                 val = fd.get("value")
@@ -272,7 +301,7 @@ class _PandasDataAccessor(_DataAccessor):
                 col_expr = f"`{col}`"
 
                 if isinstance(val, str):
-                    if self.__is_date_column(t.cast(pd.DataFrame, df), col):
+                    if cols_description.get(col, {}).get("type", "").startswith("datetime"):
                         val = datetime.fromisoformat(val[:-1])
                     elif not match_case:
                         if action != "contains":
@@ -307,15 +336,21 @@ class _PandasDataAccessor(_DataAccessor):
             applies = payload.get("applies")
             if isinstance(aggregates, list) and len(aggregates) and isinstance(applies, dict):
                 applies_with_fn = {
-                    k: v if v in _PandasDataAccessor.__AGGREGATE_FUNCTIONS else self._gui._get_user_function(v)
+                    self.__get_column_names(df, k): v
+                    if v in _PandasDataAccessor.__AGGREGATE_FUNCTIONS
+                    else self._gui._get_user_function(v)
                     for k, v in applies.items()
                 }
 
-                for col in columns:
-                    if col not in applies_with_fn.keys():
+                for col in df.columns:
+                    if col not in applies_with_fn:
                         applies_with_fn[col] = "first"
                 try:
-                    df = t.cast(pd.DataFrame, df).groupby(aggregates).agg(applies_with_fn)
+                    col_names = self.__get_column_names(df, *aggregates)
+                    if col_names:
+                        df = t.cast(pd.DataFrame, df).groupby(aggregates).agg(applies_with_fn)
+                    else:
+                        raise Exception()
                 except Exception:
                     _warn(f"Cannot aggregate {var_name} with groupby {aggregates} and aggregates {applies}.")
             inf = payload.get("infinite")
@@ -355,20 +390,22 @@ class _PandasDataAccessor(_DataAccessor):
             order_by = payload.get("orderby")
             if isinstance(order_by, str) and len(order_by):
                 try:
-                    if df.columns.dtype.name == "int64":
-                        order_by = int(order_by)
-                    new_indexes = t.cast(pd.DataFrame, df)[order_by].values.argsort(axis=0)
-                    if payload.get("sort") == "desc":
-                        # reverse order
-                        new_indexes = new_indexes[::-1]
-                    new_indexes = new_indexes[slice(start, end + 1)]
+                    col_name = self.__get_column_names(df, order_by)
+                    if col_name:
+                        new_indexes = t.cast(pd.DataFrame, df)[col_name].values.argsort(axis=0)
+                        if payload.get("sort") == "desc":
+                            # reverse order
+                            new_indexes = new_indexes[::-1]
+                        new_indexes = new_indexes[slice(start, end + 1)]
+                    else:
+                        raise Exception()
                 except Exception:
                     _warn(f"Cannot sort {var_name} on columns {order_by}.")
                     new_indexes = slice(start, end + 1)  # type: ignore
             else:
                 new_indexes = slice(start, end + 1)  # type: ignore
             df = self.__build_transferred_cols(
-                columns,
+                columns + optional_columns,
                 t.cast(pd.DataFrame, df),
                 styles=payload.get("styles"),
                 tooltips=payload.get("tooltips"),
@@ -377,7 +414,7 @@ class _PandasDataAccessor(_DataAccessor):
                 handle_nan=payload.get("handlenan", False),
                 formats=payload.get("formats"),
             )
-            dict_ret = self.__format_data(
+            dict_ret = self._format_data(
                 df,
                 data_format,
                 "records",
@@ -401,7 +438,7 @@ class _PandasDataAccessor(_DataAccessor):
                         comp_df = self.__build_transferred_cols(
                             columns, comp_df, new_indexes=t.cast(np.ndarray, new_indexes)
                         )
-                        dict_ret["comp"] = self.__format_data(comp_df, data_format, "records").get("data")
+                        dict_ret["comp"] = self._format_data(comp_df, data_format, "records").get("data")
                     except Exception as e:
                         _warn("Pandas accessor compare raised an exception", e)
 
@@ -454,7 +491,7 @@ class _PandasDataAccessor(_DataAccessor):
                     cols_to_combine = merged_df.loc[:, col].columns
                     merged_df[col] = merged_df[cols_to_combine].bfill(axis=1).iloc[:, 0]
                 # drop duplicated col since they are now the same
-                df = merged_df.loc[:,~merged_df.columns.duplicated()]
+                df = merged_df.loc[:, ~merged_df.columns.duplicated()]
             elif len(decimated_dfs) == 1:
                 df = decimated_dfs[0]
             if data_format is _DataFormat.CSV:
@@ -476,7 +513,7 @@ class _PandasDataAccessor(_DataAccessor):
                     handle_nan=payload.get("handlenan", False),
                     formats=payload.get("formats"),
                 )
-                dict_ret = self.__format_data(df, data_format, "list", data_extraction=True)
+                dict_ret = self._format_data(df, data_format, "list", data_extraction=True)
 
         ret_payload["value"] = dict_ret
         return ret_payload
@@ -495,7 +532,7 @@ class _PandasDataAccessor(_DataAccessor):
                 data = []
                 for i, v in enumerate(value):
                     ret = (
-                        self.__get_data(var_name, self.__to_dataframe(v), payload, data_format, f"{i}/")
+                        self.__get_data(var_name, self._to_dataframe(v), payload, data_format, f"{i}/")
                         if isinstance(v, _PandasDataAccessor.__types)
                         else {}
                     )
@@ -506,34 +543,36 @@ class _PandasDataAccessor(_DataAccessor):
                 return ret_payload
             else:
                 value = value[0]
-        return self.__get_data(var_name, self.__to_dataframe(value), payload, data_format)
+        return self.__get_data(var_name, self._to_dataframe(value), payload, data_format)
+
+    def _get_index_value(self, index: t.Any) -> t.Any:
+        return tuple(index) if isinstance(index, list) else index
 
     def on_edit(self, value: t.Any, payload: t.Dict[str, t.Any]):
         df = self.to_pandas(value)
-        if not isinstance(df, pd.DataFrame):
-            raise ValueError(f"Cannot edit {type(value)}.")
-        df.at[payload["index"], payload["col"]] = payload["value"]
+        if not isinstance(df, pd.DataFrame) or not isinstance(payload.get("index"), (int, float)):
+            raise ValueError(f"Cannot edit {type(value)} at {payload.get('index')}.")
+        df.at[self._get_index_value(payload.get("index", 0)), payload["col"]] = payload["value"]
         return self._from_pandas(df, type(value))
 
     def on_delete(self, value: t.Any, payload: t.Dict[str, t.Any]):
         df = self.to_pandas(value)
-        if not isinstance(df, pd.DataFrame):
-            raise ValueError(f"Cannot delete a row from {type(value)}.")
-        return self._from_pandas(df.drop(payload["index"]), type(value))
+        if not isinstance(df, pd.DataFrame) or not isinstance(payload.get("index"), (int, float)):
+            raise ValueError(f"Cannot delete a row from {type(value)} at {payload.get('index')}.")
+        return self._from_pandas(df.drop(self._get_index_value(payload.get("index", 0))), type(value))
 
     def on_add(self, value: t.Any, payload: t.Dict[str, t.Any], new_row: t.Optional[t.List[t.Any]] = None):
         df = self.to_pandas(value)
-        if not isinstance(df, pd.DataFrame):
-            raise ValueError(f"Cannot add a row to {type(value)}.")
+        if not isinstance(df, pd.DataFrame) or not isinstance(payload.get("index"), (int, float)):
+            raise ValueError(f"Cannot add a row to {type(value)} at {payload.get('index')}.")
         # Save the insertion index
-        index = payload["index"]
+        index = payload.get("index", 0)
         # Create the new row (Column value types must match the original DataFrame's)
-        col_types = self.get_col_types("", df)
-        if col_types:
-            new_row = [0 if is_numeric_dtype(df[c]) else "" for c in list(col_types)] if new_row is None else new_row
+        if list(df.columns):
+            new_row = [0 if is_numeric_dtype(dt) else "" for dt in df.dtypes] if new_row is None else new_row
             if index > 0:
                 # Column names and value types must match the original DataFrame
-                new_df = pd.DataFrame([new_row], columns=list(col_types))
+                new_df = pd.DataFrame([new_row], columns=df.columns.copy())
                 # Split the DataFrame
                 rows_before = df.iloc[:index]
                 rows_after = df.iloc[index:]

+ 4 - 3
taipy/gui/gui.py

@@ -1909,9 +1909,8 @@ class Gui:
                     data_hash = hashes.get("data", "")
                     data = kwargs.get(data_hash)
                     col_dict = _get_columns_dict(
-                        data,
                         attributes.get("columns", {}),
-                        self._get_accessor().get_col_types(data_hash, _TaipyData(data, data_hash)),
+                        self._get_accessor().get_cols_description(data_hash, _TaipyData(data, data_hash)),
                         attributes.get("date_format"),
                         attributes.get("number_format"),
                     )
@@ -1939,7 +1938,9 @@ class Gui:
                         self,
                         attributes,
                         [
-                            self._get_accessor().get_col_types(data_hash, _TaipyData(kwargs.get(data_hash), data_hash))
+                            self._get_accessor().get_cols_description(
+                                data_hash, _TaipyData(kwargs.get(data_hash), data_hash)
+                            )
                             for data_hash in data_hashes
                         ],
                     )

+ 5 - 4
taipy/gui/utils/chart_config_builder.py

@@ -112,7 +112,9 @@ def __get_col_from_indexed(col_name: str, idx: int) -> t.Optional[str]:
     return col_name
 
 
-def _build_chart_config(gui: "Gui", attributes: t.Dict[str, t.Any], col_types_list: t.List[t.Dict[str, str]]):  # noqa: C901
+def _build_chart_config(  # noqa: C901
+    gui: "Gui", attributes: t.Dict[str, t.Any], cols_descriptions_list: t.List[t.Dict[str, t.Dict[str, str]]]
+):
     if "data" not in attributes and "figure" in attributes:
         return {"traces": []}
     default_type = attributes.get("_default_type", "scatter")
@@ -200,11 +202,10 @@ def _build_chart_config(gui: "Gui", attributes: t.Dict[str, t.Any], col_types_li
 
     # Validate the column names
     col_dicts = []
-    for idx, col_types in enumerate(col_types_list):
+    for idx, cols_description in enumerate(cols_descriptions_list):
         if add_col_dict := _get_columns_dict(
-            attributes.get("data" if idx == 0 else f"data[{idx}]"),
             list(columns[idx] if idx < len(columns) else columns[0]),
-            col_types,
+            cols_description,
             opt_columns=opt_cols[idx] if idx < len(opt_cols) else opt_cols[0],
         ):
             col_dicts.append(add_col_dict)

+ 1 - 1
taipy/gui/utils/getdatecolstrname.py

@@ -15,7 +15,7 @@ import typing as t
 _RE_PD_TYPE = re.compile(r"^([^\s\d\[]+)(\d+)(\[(.*,\s(\S+))\])?")
 
 
-def _get_date_col_str_name(columns: t.List[str], col: str) -> str:
+def _get_date_col_str_name(columns: t.Iterable[str], col: str) -> str:
     suffix = "_str"
     while col + suffix in columns:
         suffix += "_"

+ 1 - 1
taipy/gui_core/viselements.json

@@ -461,7 +461,7 @@
                         "doc": "If False, the data node expiration date is not visible."
                     },
                     {
-                        "name": "chart_config",
+                        "name": "chart_configs",
                         "type": "dict",
                         "doc": "Chart configs by data node configuration id."
                     },

+ 2 - 2
tests/gui/builder/control/test_chart.py

@@ -174,8 +174,8 @@ def test_map_builder(gui: Gui, helpers):
     gui._set_frame(inspect.currentframe())
     expected_list = [
         "<Chart",
-        "&quot;Lat&quot;: &#x7B;&quot;index&quot;:",
-        "&quot;Lon&quot;: &#x7B;&quot;index&quot;:",
+        "&quot;Lat&quot;: &#x7B;&quot;",
+        "&quot;Lon&quot;: &#x7B;&quot;",
         "data={_TpD_tpec_TpExPr_mapData_TPMDL_0}",
         'defaultLayout="{&quot;dragmode&quot;: &quot;zoom&quot;, &quot;map&quot;: &#x7B;&quot;style&quot;: &quot;open-street-map&quot;, &quot;center&quot;: &#x7B;&quot;lat&quot;: 38, &quot;lon&quot;: -90&#x7D;, &quot;zoom&quot;: 3&#x7D;, &quot;margin&quot;: &#x7B;&quot;r&quot;: 0, &quot;t&quot;: 0, &quot;b&quot;: 0, &quot;l&quot;: 0&#x7D;}"',  # noqa: E501
         'updateVarName="_TpD_tpec_TpExPr_mapData_TPMDL_0"',

+ 3 - 3
tests/gui/builder/control/test_table.py

@@ -26,7 +26,7 @@ def test_table_builder_1(gui: Gui, helpers, csvdata):
         )
     expected_list = [
         "<Table",
-        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;&#x7D;}"',  # noqa: E501
+        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 1, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 2, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;type&quot;: &quot;int&quot;, &quot;index&quot;: 3, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;type&quot;: &quot;datetime&quot;, &quot;index&quot;: 0, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',   # noqa: E501
         'height="80vh"',
         'width="100%"',
         'pageSizeOptions="[10, 30, 100]"',
@@ -51,7 +51,7 @@ def test_table_reset_builder(gui: Gui, helpers, csvdata):
         )
     expected_list = [
         "<Table",
-        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;&#x7D;}"',  # noqa: E501
+        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 1, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 2, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;type&quot;: &quot;int&quot;, &quot;index&quot;: 3, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;type&quot;: &quot;datetime&quot;, &quot;index&quot;: 0, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
         'height="80vh"',
         'width="100%"',
         'pageSizeOptions="[10, 30, 100]"',
@@ -91,7 +91,7 @@ def test_table_builder_2(gui: Gui, helpers, csvdata):
         'onEdit="__gui.table_on_edit',
         'onDelete="__gui.table_on_delete',
         'onAdd="__gui.table_on_add',
-        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;, &quot;format&quot;: &quot;%.3f&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;format&quot;: &quot;dd/MM/yyyy&quot;, &quot;title&quot;: &quot;Date of measure&quot;, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
+        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;format&quot;: &quot;%.3f&quot;, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;format&quot;: &quot;dd/MM/yyyy&quot;, &quot;title&quot;: &quot;Date of measure&quot;, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
         'height="60vh"',
         'width="60vw"',
         'pageSizeOptions="[10, 50, 100, 500]"',

+ 2 - 2
tests/gui/control/test_chart.py

@@ -150,8 +150,8 @@ def test_map_md(gui: Gui, helpers):
     gui._set_frame(inspect.currentframe())
     expected_list = [
         "<Chart",
-        "&quot;Lat&quot;: &#x7B;&quot;index&quot;:",
-        "&quot;Lon&quot;: &#x7B;&quot;index&quot;:",
+        "&quot;Lat&quot;: &#x7B;&quot;",
+        "&quot;Lon&quot;: &#x7B;&quot;",
         "data={_TpD_tpec_TpExPr_mapData_TPMDL_0}",
         'defaultLayout="{&quot;dragmode&quot;: &quot;zoom&quot;, &quot;map&quot;: &#x7B;&quot;style&quot;: &quot;open-street-map&quot;, &quot;center&quot;: &#x7B;&quot;lat&quot;: 38, &quot;lon&quot;: -90&#x7D;, &quot;zoom&quot;: 3&#x7D;, &quot;margin&quot;: &#x7B;&quot;r&quot;: 0, &quot;t&quot;: 0, &quot;b&quot;: 0, &quot;l&quot;: 0&#x7D;}"',  # noqa: E501
         'updateVarName="_TpD_tpec_TpExPr_mapData_TPMDL_0"',

+ 5 - 5
tests/gui/control/test_table.py

@@ -18,7 +18,7 @@ def test_table_md_1(gui: Gui, helpers, csvdata):
     md_string = "<|{csvdata}|table|page_size=10|page_size_options=10;30;100|columns=Day;Entity;Code;Daily hospital occupancy|date_format=eee dd MMM yyyy|>"  # noqa: E501
     expected_list = [
         "<Table",
-        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;&#x7D;}"',  # noqa: E501
+        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 1, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 2, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;type&quot;: &quot;int&quot;, &quot;index&quot;: 3, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;type&quot;: &quot;datetime&quot;, &quot;index&quot;: 0, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
         'height="80vh"',
         'width="100%"',
         'pageSizeOptions="[10, 30, 100]"',
@@ -35,7 +35,7 @@ def test_table_reset_md(gui: Gui, helpers, csvdata):
     md_string = "<|{csvdata}|table|rebuild|page_size=10|page_size_options=10;30;100|columns=Day;Entity;Code;Daily hospital occupancy|date_format=eee dd MMM yyyy|>"  # noqa: E501
     expected_list = [
         "<Table",
-        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;&#x7D;}"',  # noqa: E501
+        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 1, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 2, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;type&quot;: &quot;int&quot;, &quot;index&quot;: 3, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;type&quot;: &quot;datetime&quot;, &quot;index&quot;: 0, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
         'height="80vh"',
         'width="100%"',
         'pageSizeOptions="[10, 30, 100]"',
@@ -74,7 +74,7 @@ def test_table_md_2(gui: Gui, helpers, csvdata):
         'onEdit="__gui.table_on_edit',
         'onDelete="__gui.table_on_delete',
         'onAdd="__gui.table_on_add',
-        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;, &quot;format&quot;: &quot;%.3f&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;format&quot;: &quot;dd/MM/yyyy&quot;, &quot;title&quot;: &quot;Date of measure&quot;, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
+        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;format&quot;: &quot;%.3f&quot;, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;format&quot;: &quot;dd/MM/yyyy&quot;, &quot;title&quot;: &quot;Date of measure&quot;, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
         'height="60vh"',
         'width="60vw"',
         'pageSizeOptions="[10, 50, 100, 500]"',
@@ -91,7 +91,7 @@ def test_table_html_1(gui: Gui, helpers, csvdata):
     html_string = '<taipy:table data="{csvdata}" page_size="10" page_size_options="10;30;100" columns="Day;Entity;Code;Daily hospital occupancy" date_format="eee dd MMM yyyy" />'  # noqa: E501
     expected_list = [
         "<Table",
-        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;&#x7D;}"',  # noqa: E501
+        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 1, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 2, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;type&quot;: &quot;int&quot;, &quot;index&quot;: 3, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;type&quot;: &quot;datetime&quot;, &quot;index&quot;: 0, &quot;format&quot;: &quot;eee dd MMM yyyy&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
         'height="80vh"',
         'width="100%"',
         'pageSizeOptions="[10, 30, 100]"',
@@ -126,7 +126,7 @@ def test_table_html_2(gui: Gui, helpers, csvdata):
         "allowAllRows={true}",
         "autoLoading={true}",
         "showAll={true}",
-        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;, &quot;format&quot;: &quot;%.3f&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;format&quot;: &quot;dd/MM/yyyy&quot;, &quot;title&quot;: &quot;Date of measure&quot;, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
+        'defaultColumns="{&quot;Entity&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Entity&quot;&#x7D;, &quot;Code&quot;: &#x7B;&quot;index&quot;: 2, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Code&quot;&#x7D;, &quot;Daily hospital occupancy&quot;: &#x7B;&quot;index&quot;: 3, &quot;format&quot;: &quot;%.3f&quot;, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Daily hospital occupancy&quot;&#x7D;, &quot;Day_str&quot;: &#x7B;&quot;index&quot;: 0, &quot;format&quot;: &quot;dd/MM/yyyy&quot;, &quot;title&quot;: &quot;Date of measure&quot;, &quot;type&quot;: &quot;datetime&quot;, &quot;dfid&quot;: &quot;Day&quot;&#x7D;}"',  # noqa: E501
         'height="60vh"',
         'width="60vw"',
         'pageSizeOptions="[10, 50, 100, 500]"',

+ 1 - 1
tests/gui/data/test_accessors.py

@@ -25,7 +25,7 @@ class MyDataAccessor(_DataAccessor):
     ) -> t.Dict[str, t.Any]:
         return {"value": 2 * int(value)}
 
-    def get_col_types(self, var_name: str, value: t.Any) -> t.Dict[str, str]:  # type: ignore
+    def get_cols_description(self, var_name: str, value: t.Any) -> t.Dict[str, t.Dict[str, str]]:  # type: ignore
         pass
 
     def to_pandas(self, value: t.Any) -> t.Union[t.List[t.Any], t.Any]:

+ 47 - 1
tests/gui/data/test_array_dict_data_accessor.py

@@ -138,6 +138,29 @@ def test_array_of_dicts(gui: Gui, helpers, small_dataframe):
     assert len(data[1]["seasons"]) == 4
 
 
+def test_array_of_dicts_of_scalar(gui: Gui, helpers, small_dataframe):
+    accessor = _ArrayDictDataAccessor(gui)
+    an_array_of_dicts = [
+        {
+            "temperature": 17.2,
+            "city": "Hanoi",
+        },
+        {
+            "temperature": 5.62,
+            "city": "Paris",
+        },
+    ]
+    ret_data = accessor.get_data("x", an_array_of_dicts, {"start": 0, "end": -1, "alldata": True}, _DataFormat.JSON)
+    assert ret_data
+    value = ret_data["value"]
+    assert value
+    assert "multi" not in value
+    data = value["data"]
+    assert len(data) == 2
+    assert len(data["temperature"]) == 2
+    assert len(data["city"]) == 2
+
+
 def test_array_of_Mapdicts(gui: Gui, helpers, small_dataframe):
     accessor = _ArrayDictDataAccessor(gui)
     dict1 = _MapDict(
@@ -164,6 +187,29 @@ def test_array_of_Mapdicts(gui: Gui, helpers, small_dataframe):
     assert len(data[1]["seasons"]) == 4
 
 
+def test_array_of_Mapdicts_of_scalar(gui: Gui, helpers, small_dataframe):
+    accessor = _ArrayDictDataAccessor(gui)
+    an_array_of_dicts = [
+        {
+            "temperature": 17.2,
+            "city": "Hanoi",
+        },
+        {
+            "temperature": 5.62,
+            "city": "Paris",
+        },
+    ]
+    ret_data = accessor.get_data("x", an_array_of_dicts, {"start": 0, "end": -1, "alldata": True}, _DataFormat.JSON)
+    assert ret_data
+    value = ret_data["value"]
+    assert value
+    assert "multi" not in value
+    data = value["data"]
+    assert len(data) == 2
+    assert len(data["temperature"]) == 2
+    assert len(data["city"]) == 2
+
+
 def test_edit_dict(gui, small_dataframe):
     accessor = _ArrayDictDataAccessor(gui)
     pd = small_dataframe
@@ -178,7 +224,7 @@ def test_edit_dict(gui, small_dataframe):
 def test_delete_dict(gui, small_dataframe):
     accessor = _ArrayDictDataAccessor(gui)
     pd = small_dataframe
-    ln = len(pd['name'])
+    ln = len(pd["name"])
     ret_data = accessor.on_delete(pd, {"index": 0})
     assert isinstance(ret_data, dict)
     assert len(ret_data["name"]) == ln - 1

+ 34 - 9
tests/gui/data/test_pandas_data_accessor.py

@@ -11,12 +11,13 @@
 
 import inspect
 import os
+import warnings
 from datetime import datetime
 from importlib import util
 from unittest.mock import Mock
 
+import numpy
 import pandas
-import pandas as pd
 import pytest
 from flask import g
 
@@ -26,7 +27,7 @@ from taipy.gui.data.decimator import ScatterDecimator
 from taipy.gui.data.pandas_data_accessor import _PandasDataAccessor
 
 
-# Define a mock to simulate _DataFormat behavior with a 'value' attribute
+# Define a mock to simulate _DataFormat behavior with a "value" attribute
 class MockDataFormat:
     LIST = Mock(value="list")
     CSV = Mock(value="csv")
@@ -42,9 +43,9 @@ def sample_df():
         "StringCol": ["Apple", "Banana", "Cherry", "apple"],
         "NumberCol": [10, 20, 30, 40],
         "BoolCol": [True, False, True, False],
-        "DateCol": pd.to_datetime(["2020-01-01", "2021-06-15", "2022-08-22", "2023-03-05"])
+        "DateCol": pandas.to_datetime(["2020-01-01", "2021-06-15", "2022-08-22", "2023-03-05"])
     }
-    return pd.DataFrame(data)
+    return pandas.DataFrame(data)
 
 def test_simple_data(gui: Gui, helpers, small_dataframe):
     accessor = _PandasDataAccessor(gui)
@@ -283,7 +284,7 @@ def test_contains_case_sensitive(pandas_accessor, sample_df):
         "filters": [{"col": "StringCol", "value": "Apple", "action": "contains", "matchCase": True}]
     }
     result = pandas_accessor.get_data("test_var", sample_df, payload, MockDataFormat.LIST)
-    filtered_data = pd.DataFrame(result['value']['data'])
+    filtered_data = pandas.DataFrame(result["value"]['data'])
 
     assert len(filtered_data) == 1
     assert filtered_data.iloc[0]['StringCol'] == 'Apple'
@@ -293,7 +294,7 @@ def test_contains_case_insensitive(pandas_accessor, sample_df):
         "filters": [{"col": "StringCol", "value": "apple", "action": "contains", "matchCase": False}]
     }
     result = pandas_accessor.get_data("test_var", sample_df, payload, MockDataFormat.LIST)
-    filtered_data = pd.DataFrame(result['value']['data'])
+    filtered_data = pandas.DataFrame(result["value"]['data'])
 
     assert len(filtered_data) == 2
     assert 'Apple' in filtered_data['StringCol'].values
@@ -304,7 +305,7 @@ def test_equals_case_sensitive(pandas_accessor, sample_df):
         "filters": [{"col": "StringCol", "value": "Apple", "action": "==", "matchCase": True}]
     }
     result = pandas_accessor.get_data("test_var", sample_df, payload, MockDataFormat.LIST)
-    filtered_data = pd.DataFrame(result['value']['data'])
+    filtered_data = pandas.DataFrame(result["value"]['data'])
 
     assert len(filtered_data) == 1
     assert filtered_data.iloc[0]['StringCol'] == 'Apple'
@@ -314,7 +315,7 @@ def test_equals_case_insensitive(pandas_accessor, sample_df):
         "filters": [{"col": "StringCol", "value": "apple", "action": "==", "matchCase": False}]
     }
     result = pandas_accessor.get_data("test_var", sample_df, payload, MockDataFormat.LIST)
-    filtered_data = pd.DataFrame(result['value']['data'])
+    filtered_data = pandas.DataFrame(result["value"]['data'])
 
     assert len(filtered_data) == 2
     assert 'Apple' in filtered_data['StringCol'].values
@@ -325,7 +326,7 @@ def test_not_equals_case_insensitive(pandas_accessor, sample_df):
         "filters": [{"col": "StringCol", "value": "apple", "action": "!=", "matchCase": False}]
     }
     result = pandas_accessor.get_data("test_var", sample_df, payload, MockDataFormat.LIST)
-    filtered_data = pd.DataFrame(result['value']['data'])
+    filtered_data = pandas.DataFrame(result["value"]['data'])
 
     assert len(filtered_data) == 2
     assert 'Banana' in filtered_data['StringCol'].values
@@ -429,3 +430,27 @@ def test_csv(gui, small_dataframe):
     path = accessor.to_csv("", pd)
     assert path is not None
     assert os.path.getsize(path) > 0
+
+def test_multi_index(gui):
+    pandas_accessor = _PandasDataAccessor(gui)
+
+    iterables = [["bar", "baz", "foo", "qux"], ["one", "two"]]
+    index = pandas.MultiIndex.from_product(iterables, names=["first", "second"])
+    df = pandas.DataFrame({"col 1": numpy.random.randn(8), "col 2": numpy.random.randn(8)}, index=index)
+
+    with warnings.catch_warnings(record=True):
+        result = pandas_accessor.get_data("test_var", df, {}, MockDataFormat.LIST)
+        assert result.get("error") is None
+        assert result["value"] is not None
+
+def test_multi_index_columns(gui):
+    pandas_accessor = _PandasDataAccessor(gui)
+
+    iterables = [["bar", "baz", "foo", "qux"], ["one", "two"]]
+    index = pandas.MultiIndex.from_product(iterables, names=["first", "second"])
+    df = pandas.DataFrame(numpy.random.randn(3, 8), index=["A", "B", "C"], columns=index)
+
+    with warnings.catch_warnings(record=True):
+        result = pandas_accessor.get_data("test_var", df, {}, MockDataFormat.LIST)
+        assert result.get("error") is not None
+        assert result.get("value") is not None

+ 1 - 1
tests/gui/gui_specific/test_expression.py

@@ -100,7 +100,7 @@ def test_expression_table_control(gui: Gui, test_client, helpers):
     md_string = "<|{pd.concat([series_1, series_2], axis=1)}|table|columns=Letters;Numbers|>"
     expected_list = [
         "<Table",
-        'defaultColumns="{&quot;Letters&quot;: &#x7B;&quot;index&quot;: 0, &quot;type&quot;: &quot;object&quot;, &quot;dfid&quot;: &quot;Letters&quot;&#x7D;, &quot;Numbers&quot;: &#x7B;&quot;index&quot;: 1, &quot;type&quot;: &quot;int&quot;, &quot;dfid&quot;: &quot;Numbers&quot;&#x7D;}"',  # noqa: E501
+        'defaultColumns="{&quot;Letters&quot;: &#x7B;&quot;type&quot;: &quot;object&quot;, &quot;index&quot;: 0, &quot;dfid&quot;: &quot;Letters&quot;&#x7D;, &quot;Numbers&quot;: &#x7B;&quot;type&quot;: &quot;int&quot;, &quot;index&quot;: 1, &quot;dfid&quot;: &quot;Numbers&quot;&#x7D;}"',  # noqa: E501
         'updateVarName="_TpD_tp_TpExPr_pd_concat_series_1_series_2_axis_1_TPMDL_0_0"',
         "data={_TpD_tp_TpExPr_pd_concat_series_1_series_2_axis_1_TPMDL_0_0}",
     ]