From d15c00c29b2ac73dcd8fab25d3250715ee62b85d Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 3 Jul 2025 15:31:54 +0530 Subject: [PATCH] feat(task-management): enhance task list with new components and improved state management - Introduced TaskListV2 and TaskGroupHeader components for a more organized task display. - Implemented virtualized rendering using react-virtuoso for efficient task list handling. - Updated Redux state management to include new selectors and improved task grouping logic. - Added task filtering capabilities with TaskListFilters component for better user experience. - Enhanced task selection handling and integrated drag-and-drop functionality for task rows. - Updated package dependencies to include new libraries for icons and forms. --- worklenz-frontend/package-lock.json | 139 ++-- worklenz-frontend/package.json | 5 +- worklenz-frontend/src/app/store.ts | 15 +- .../task-list-v2/TaskGroupHeader.tsx | 58 ++ .../components/task-list-v2/TaskListV2.tsx | 264 ++++++++ .../src/components/task-list-v2/TaskRow.tsx | 261 ++++++++ .../components/task-management/task-group.tsx | 46 +- .../task-management/task-list-board.tsx | 54 +- .../task-management/task-list-filters.tsx | 54 ++ .../task-management/task-list-group.tsx | 94 +++ .../task-management/task-list-header.tsx | 32 + .../task-management/virtualized-task-list.tsx | 8 + .../task-management/grouping.slice.ts | 52 +- .../task-management/selection.slice.ts | 112 +--- .../task-management/task-management.slice.ts | 607 ++++++++---------- .../src/lib/project/project-view-constants.ts | 3 +- .../projects/projectView/project-view.tsx | 6 +- .../src/types/task-list-field.types.ts | 28 + .../src/types/task-management.types.ts | 80 +-- 19 files changed, 1316 insertions(+), 602 deletions(-) create mode 100644 worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx create mode 100644 worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx create mode 100644 worklenz-frontend/src/components/task-list-v2/TaskRow.tsx create mode 100644 worklenz-frontend/src/components/task-management/task-list-filters.tsx create mode 100644 worklenz-frontend/src/components/task-management/task-list-group.tsx create mode 100644 worklenz-frontend/src/components/task-management/task-list-header.tsx create mode 100644 worklenz-frontend/src/types/task-list-field.types.ts diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index b208dc5a..5e5154f3 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -17,8 +17,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", + "@heroicons/react": "^2.2.0", "@paddle/paddle-js": "^1.3.3", "@reduxjs/toolkit": "^2.2.7", + "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "^8.20.6", "@tanstack/react-virtual": "^3.11.2", "@tinymce/tinymce-react": "^5.1.1", @@ -48,6 +50,7 @@ "react-responsive": "^10.0.0", "react-router-dom": "^6.28.1", "react-timer-hook": "^3.0.8", + "react-virtuoso": "^4.13.0", "react-window": "^1.8.11", "react-window-infinite-loader": "^1.0.10", "socket.io-client": "^4.8.1", @@ -73,7 +76,7 @@ "postcss": "^8.5.2", "prettier-plugin-tailwindcss": "^0.6.13", "rollup": "^4.40.2", - "tailwindcss": "^3.4.15", + "tailwindcss": "^3.4.17", "terser": "^5.39.0", "typescript": "^5.7.3", "vite": "^6.3.5", @@ -92,7 +95,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1657,11 +1659,19 @@ "node": ">=18" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -1744,7 +1754,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1758,7 +1767,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1768,7 +1776,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -1788,7 +1795,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -2296,6 +2302,18 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -2841,7 +2859,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2851,7 +2868,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2952,14 +2968,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -2973,7 +2987,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/aria-query": { @@ -3111,7 +3124,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-arraybuffer": { @@ -3127,7 +3139,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3140,7 +3151,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3150,7 +3160,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3247,7 +3256,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -3363,7 +3371,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -3388,7 +3395,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -3407,7 +3413,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3420,7 +3425,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3439,7 +3443,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -3539,7 +3542,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3576,7 +3578,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -3668,7 +3669,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/diff-sequences": { @@ -3685,7 +3685,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -3739,7 +3738,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -3753,7 +3751,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/engine.io-client": { @@ -3943,7 +3940,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -3960,7 +3956,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -3973,7 +3968,6 @@ "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -3989,7 +3983,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4028,7 +4021,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -4074,7 +4066,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4157,7 +4148,6 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -4178,7 +4168,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -4388,7 +4377,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -4416,7 +4404,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4426,7 +4413,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4436,7 +4422,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4449,7 +4434,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -4459,14 +4443,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -4851,7 +4833,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -4968,7 +4949,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -4978,7 +4958,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -5019,11 +4998,19 @@ "node": ">=4" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -5039,7 +5026,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -5080,7 +5066,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -5137,7 +5122,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5166,7 +5150,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5176,7 +5159,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -5213,7 +5195,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5229,7 +5210,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -5246,7 +5226,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/path-to-regexp": { @@ -5306,7 +5285,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5319,7 +5297,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5329,7 +5306,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5367,7 +5343,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -5385,7 +5360,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -5405,7 +5379,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5441,7 +5414,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5467,7 +5439,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -5481,7 +5452,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/postcss/node_modules/nanoid": { @@ -5677,7 +5647,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -6578,6 +6547,16 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-virtuoso": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.13.0.tgz", + "integrity": "sha512-XHv2Fglpx80yFPdjZkV9d1baACKghg/ucpDFEXwaix7z0AfVQj+mF6lM+YQR6UC/TwzXG2rJKydRMb3+7iV3PA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, "node_modules/react-window": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", @@ -6621,7 +6600,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -6631,7 +6609,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -6721,7 +6698,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -6816,7 +6792,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -6889,7 +6864,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6902,7 +6876,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6919,7 +6892,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -7063,7 +7035,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -7082,7 +7053,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7097,14 +7067,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7117,7 +7085,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -7134,7 +7101,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7147,7 +7113,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7179,7 +7144,6 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -7250,7 +7214,6 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -7288,7 +7251,6 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7333,7 +7295,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -7343,7 +7304,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -7466,7 +7426,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -7491,7 +7450,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/tsconfck": { @@ -7586,7 +7544,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utrie": { @@ -7868,7 +7825,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7905,7 +7861,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -7924,7 +7879,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7942,14 +7896,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7964,7 +7916,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7977,7 +7928,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -8026,7 +7976,6 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 969f7461..b83e80fd 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -21,8 +21,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", + "@heroicons/react": "^2.2.0", "@paddle/paddle-js": "^1.3.3", "@reduxjs/toolkit": "^2.2.7", + "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "^8.20.6", "@tanstack/react-virtual": "^3.11.2", "@tinymce/tinymce-react": "^5.1.1", @@ -52,6 +54,7 @@ "react-responsive": "^10.0.0", "react-router-dom": "^6.28.1", "react-timer-hook": "^3.0.8", + "react-virtuoso": "^4.13.0", "react-window": "^1.8.11", "react-window-infinite-loader": "^1.0.10", "socket.io-client": "^4.8.1", @@ -77,7 +80,7 @@ "postcss": "^8.5.2", "prettier-plugin-tailwindcss": "^0.6.13", "rollup": "^4.40.2", - "tailwindcss": "^3.4.15", + "tailwindcss": "^3.4.17", "terser": "^5.39.0", "typescript": "^5.7.3", "vite": "^6.3.5", diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 262f654b..63c738a0 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -1,4 +1,5 @@ import { configureStore } from '@reduxjs/toolkit'; +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; // Auth & User import authReducer from '@features/auth/authSlice'; @@ -76,14 +77,14 @@ import teamMembersReducer from '@features/team-members/team-members.slice'; import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice'; // Task Management System -import taskManagementReducer from '@features/task-management/task-management.slice'; -import groupingReducer from '@features/task-management/grouping.slice'; -import selectionReducer from '@features/task-management/selection.slice'; +import taskManagementReducer from '@/features/task-management/task-management.slice'; +import groupingReducer from '@/features/task-management/grouping.slice'; +import selectionReducer from '@/features/task-management/selection.slice'; import homePageApiService from '@/api/home-page/home-page.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service'; import projectViewReducer from '@features/project/project-view-slice'; -import taskManagementFields from '@features/task-management/taskListFields.slice'; +import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice'; export const store = configureStore({ middleware: getDefaultMiddleware => @@ -172,9 +173,13 @@ export const store = configureStore({ taskManagement: taskManagementReducer, grouping: groupingReducer, taskManagementSelection: selectionReducer, - taskManagementFields, + taskManagementFields: taskManagementFieldsReducer, }, }); export type RootState = ReturnType; + export type AppDispatch = typeof store.dispatch; + +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx new file mode 100644 index 00000000..21410c3b --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; + +interface TaskGroupHeaderProps { + group: { + id: string; + name: string; + count: number; + color?: string; // Color for the group indicator + }; + isCollapsed: boolean; + onToggle: () => void; +} + +const TaskGroupHeader: React.FC = ({ group, isCollapsed, onToggle }) => { + return ( +
+ {/* Chevron button */} + + + {/* Group indicator and name */} +
+ {/* Color indicator */} +
+ + {/* Group name and count */} +
+ + {group.name} + + + {group.count} + +
+
+
+ ); +}; + +export default TaskGroupHeader; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx new file mode 100644 index 00000000..58769bf0 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -0,0 +1,264 @@ +import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { GroupedVirtuoso } from 'react-virtuoso'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { + selectAllTasksArray, + selectGroups, + selectGrouping, + selectLoading, + selectError, + selectSelectedPriorities, + selectSearch, + fetchTasksV3, +} from '@/features/task-management/task-management.slice'; +import { + selectCurrentGrouping, + selectCollapsedGroups, + selectIsGroupCollapsed, + toggleGroupCollapsed, +} from '@/features/task-management/grouping.slice'; +import { + selectSelectedTaskIds, + selectLastSelectedTaskId, + selectIsTaskSelected, + selectTask, + deselectTask, + toggleTaskSelection, + selectRange, + clearSelection, +} from '@/features/task-management/selection.slice'; +import TaskRow from './TaskRow'; +import TaskGroupHeader from './TaskGroupHeader'; +import { Task, TaskGroup } from '@/types/task-management.types'; +import { RootState } from '@/app/store'; +import { TaskListField } from '@/types/task-list-field.types'; +import { useParams } from 'react-router-dom'; +import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; +import { Bars3Icon } from '@heroicons/react/24/outline'; +import { COLUMN_KEYS } from '@/features/tasks/tasks.slice'; + +// Base column configuration +const BASE_COLUMNS = [ + { id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' }, + { id: 'taskKey', label: 'Key', width: '100px', isSticky: true, key: COLUMN_KEYS.KEY }, + { id: 'title', label: 'Title', width: '300px', isSticky: true, key: COLUMN_KEYS.NAME }, + { id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS }, + { id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, + { id: 'priority', label: 'Priority', width: '120px', key: COLUMN_KEYS.PRIORITY }, + { id: 'dueDate', label: 'Due Date', width: '120px', key: COLUMN_KEYS.DUE_DATE }, + { id: 'progress', label: 'Progress', width: '120px', key: COLUMN_KEYS.PROGRESS }, + { id: 'labels', label: 'Labels', width: '150px', key: COLUMN_KEYS.LABELS }, + { id: 'phase', label: 'Phase', width: '120px', key: COLUMN_KEYS.PHASE }, + { id: 'timeTracking', label: 'Time Tracking', width: '120px', key: COLUMN_KEYS.TIME_TRACKING }, + { id: 'estimation', label: 'Estimation', width: '120px', key: COLUMN_KEYS.ESTIMATION }, + { id: 'startDate', label: 'Start Date', width: '120px', key: COLUMN_KEYS.START_DATE }, + { id: 'dueTime', label: 'Due Time', width: '120px', key: COLUMN_KEYS.DUE_TIME }, + { id: 'completedDate', label: 'Completed Date', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE }, + { id: 'createdDate', label: 'Created Date', width: '120px', key: COLUMN_KEYS.CREATED_DATE }, + { id: 'lastUpdated', label: 'Last Updated', width: '120px', key: COLUMN_KEYS.LAST_UPDATED }, + { id: 'reporter', label: 'Reporter', width: '120px', key: COLUMN_KEYS.REPORTER }, +]; + +type ColumnStyle = { + width: string; + position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed'; + left?: number; + backgroundColor?: string; + zIndex?: number; +}; + +interface TaskListV2Props { + projectId: string; +} + +const TaskListV2: React.FC = ({ projectId }) => { + const dispatch = useAppDispatch(); + const { projectId: urlProjectId } = useParams(); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + // Selectors + const tasks = useAppSelector(selectAllTasksArray); + const groups = useAppSelector(selectGroups); + const grouping = useAppSelector(selectGrouping); + const loading = useAppSelector(selectLoading); + const error = useAppSelector(selectError); + const selectedPriorities = useAppSelector(selectSelectedPriorities); + const searchQuery = useAppSelector(selectSearch); + const currentGrouping = useAppSelector(selectCurrentGrouping); + const selectedTaskIds = useAppSelector(selectSelectedTaskIds); + const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId); + + const fields = useAppSelector(state => state.taskManagementFields) || []; + + // Filter visible columns based on fields + const visibleColumns = useMemo(() => { + return BASE_COLUMNS.filter(column => { + // Always show drag handle, task key, and title + if (column.isSticky) return true; + // Check if field is visible + const field = fields.find(f => f.key === column.key); + return field?.visible ?? false; + }); + }, [fields]); + + // Effects + useEffect(() => { + if (urlProjectId) { + dispatch(fetchTasksV3(urlProjectId)); + } + }, [dispatch, urlProjectId]); + + // Handlers + const handleTaskSelect = useCallback((taskId: string, event: React.MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + dispatch(toggleTaskSelection(taskId)); + } else if (event.shiftKey && lastSelectedTaskId) { + const taskIds = tasks.map(t => t.id); + const startIdx = taskIds.indexOf(lastSelectedTaskId); + const endIdx = taskIds.indexOf(taskId); + const rangeIds = taskIds.slice( + Math.min(startIdx, endIdx), + Math.max(startIdx, endIdx) + 1 + ); + dispatch(selectRange(rangeIds)); + } else { + dispatch(clearSelection()); + dispatch(selectTask(taskId)); + } + }, [dispatch, lastSelectedTaskId, tasks]); + + const handleGroupCollapse = useCallback((groupId: string) => { + setCollapsedGroups(prev => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + + // Memoized values + const groupCounts = useMemo(() => { + return groups.map(group => { + const visibleTasks = tasks.filter(task => group.taskIds.includes(task.id)); + return visibleTasks.length; + }); + }, [groups, tasks]); + + const visibleGroups = useMemo(() => { + return groups.filter(group => !collapsedGroups.has(group.id)); + }, [groups, collapsedGroups]); + + // Render functions + const renderGroup = useCallback((groupIndex: number) => { + const group = groups[groupIndex]; + return ( + handleGroupCollapse(group.id)} + /> + ); + }, [groups, groupCounts, collapsedGroups, handleGroupCollapse]); + + const renderTask = useCallback((taskIndex: number) => { + const task = tasks[taskIndex]; + return ( + + ); + }, [tasks, visibleColumns]); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + // Log data for debugging + console.log('Rendering with:', { + groups, + tasks, + groupCounts + }); + + return ( +
+ {/* Task Filters */} +
+ +
+ + {/* Column Headers */} +
+
+
+ {visibleColumns.map((column, index) => { + const columnStyle: ColumnStyle = { + width: column.width, + ...(column.isSticky ? { + position: 'sticky', + left: index === 0 ? 0 : index === 1 ? 32 : 132, + backgroundColor: 'inherit', + zIndex: 2, + } : {}), + }; + + return ( +
+ {column.id === 'dragHandle' ? ( + + ) : ( + column.label + )} +
+ ); + })} +
+
+ + {/* Task List */} +
+ ( +
+ {children} +
+ ), + List: React.forwardRef(({ style, children }, ref) => ( +
+ {children} +
+ )), + }} + /> +
+
+
+ ); +}; + +export default TaskListV2; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx new file mode 100644 index 00000000..043fc67c --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { Task } from '@/types/task-management.types'; +import Avatar from '@/components/Avatar'; +import { format } from 'date-fns'; +import { Bars3Icon } from '@heroicons/react/24/outline'; +import { ClockIcon } from '@heroicons/react/24/outline'; + +interface TaskRowProps { + task: Task; + visibleColumns: Array<{ + id: string; + width: string; + isSticky?: boolean; + }>; +} + +const TaskRow: React.FC = ({ task, visibleColumns }) => { + const renderColumn = (columnId: string, width: string, isSticky?: boolean, index?: number) => { + const baseStyle = { + width, + ...(isSticky ? { + position: 'sticky' as const, + left: index === 0 ? 0 : index === 1 ? 32 : 132, + backgroundColor: 'inherit', + zIndex: 1, + } : {}), + }; + + switch (columnId) { + case 'dragHandle': + return ( +
+ +
+ ); + + case 'taskKey': + return ( +
+ + {task.task_key} + +
+ ); + + case 'title': + return ( +
+ + {task.title || task.name} + +
+ ); + + case 'status': + return ( +
+ + {task.status} + +
+ ); + + case 'assignees': + return ( +
+ {task.assignee_names?.slice(0, 3).map((assignee, index) => ( + + ))} + {(task.assignee_names?.length || 0) > 3 && ( + + +{task.assignee_names!.length - 3} + + )} +
+ ); + + case 'priority': + return ( +
+ + {task.priority} + +
+ ); + + case 'dueDate': + return ( +
+ {task.dueDate && ( + + {format(new Date(task.dueDate), 'MMM d')} + + )} +
+ ); + + case 'progress': + return ( +
+
+
+
+
+ ); + + case 'labels': + return ( +
+ {task.labels?.slice(0, 2).map((label, index) => ( + + {label.name} + + ))} + {(task.labels?.length || 0) > 2 && ( + + +{task.labels!.length - 2} + + )} +
+ ); + + case 'phase': + return ( +
+ + {task.phase} + +
+ ); + + case 'timeTracking': + return ( +
+ + + {task.timeTracking?.logged || 0}h + + {task.timeTracking?.estimated && ( + + /{task.timeTracking.estimated}h + + )} +
+ ); + + case 'estimation': + return ( +
+ {task.timeTracking.estimated && ( + + {task.timeTracking.estimated}h + + )} +
+ ); + + case 'startDate': + return ( +
+ {task.startDate && ( + + {format(new Date(task.startDate), 'MMM d')} + + )} +
+ ); + + case 'completedDate': + return ( +
+ {task.completedAt && ( + + {format(new Date(task.completedAt), 'MMM d')} + + )} +
+ ); + + case 'createdDate': + return ( +
+ {task.createdAt && ( + + {format(new Date(task.createdAt), 'MMM d')} + + )} +
+ ); + + case 'lastUpdated': + return ( +
+ {task.updatedAt && ( + + {format(new Date(task.updatedAt), 'MMM d')} + + )} +
+ ); + + case 'reporter': + return ( +
+ {task.reporter && ( + + {task.reporter} + + )} +
+ ); + + default: + return null; + } + }; + + return ( +
+ {visibleColumns.map((column, index) => renderColumn(column.id, column.width, column.isSticky, index))} +
+ ); +}; + +export default TaskRow; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index a79c0b3f..07d0b679 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -1,7 +1,7 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { Button, Typography, @@ -11,12 +11,15 @@ import { DownOutlined, } from '@/shared/antd-imports'; import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types'; -import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; +import { taskManagementSelectors, selectAllTasks } from '@/features/task-management/task-management.slice'; import { RootState } from '@/app/store'; import TaskRow from './task-row'; import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; import { TaskListField } from '@/features/task-management/taskListFields.slice'; import { Checkbox } from '@/components'; +import { selectIsGroupCollapsed, toggleGroupCollapsed } from '@/features/task-management/grouping.slice'; +import { selectIsTaskSelected } from '@/features/task-management/selection.slice'; +import { Draggable } from 'react-beautiful-dnd'; const { Text } = Typography; @@ -58,6 +61,7 @@ const TaskGroup: React.FC = React.memo( onSelectTask, onToggleSubtasks, }) => { + const dispatch = useDispatch(); const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false); const { setNodeRef, isOver } = useDroppable({ @@ -69,7 +73,7 @@ const TaskGroup: React.FC = React.memo( }); // Get all tasks from the store - const allTasks = useSelector(taskManagementSelectors.selectAll); + const allTasks = useSelector(selectAllTasks); // Get theme from Redux store const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); @@ -328,19 +332,29 @@ const TaskGroup: React.FC = React.memo(
{groupTasks.map((task, index) => ( - + + {(provided, snapshot) => ( +
+ +
+ )} +
))}
diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx index 8c82609f..d4fd98cc 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -17,35 +17,50 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { Card, Spin, Empty, Alert } from 'antd'; import { RootState } from '@/app/store'; import { - taskManagementSelectors, + selectAllTasks, + selectGroups, + selectGrouping, + selectLoading, + selectError, + selectSelectedPriorities, + selectSearch, reorderTasks, moveTaskToGroup, + moveTaskBetweenGroups, optimisticTaskMove, reorderTasksInGroup, setLoading, - fetchTasks, - fetchTasksV3, - selectTaskGroupsV3, - selectCurrentGroupingV3, - fetchSubTasks, + setError, + setSelectedPriorities, + setSearch, + resetTaskManagement, toggleTaskExpansion, + addSubtaskToParent, + fetchTasksV3, } from '@/features/task-management/task-management.slice'; import { - selectTaskGroups, selectCurrentGrouping, - setCurrentGrouping, + selectCollapsedGroups, + selectIsGroupCollapsed, + toggleGroupCollapsed, + expandAllGroups, + collapseAllGroups, } from '@/features/task-management/grouping.slice'; import { selectSelectedTaskIds, + selectLastSelectedTaskId, + selectIsTaskSelected, + selectTask, + deselectTask, toggleTaskSelection, + selectRange, clearSelection, } from '@/features/task-management/selection.slice'; import { - selectTaskIds, selectTasks, deselectAll as deselectAllBulk, } from '@/features/projects/bulkActions/bulkActionSlice'; -import { Task } from '@/types/task-management.types'; +import { Task, TaskGroup } from '@/types/task-management.types'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; @@ -157,16 +172,19 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const { socket, connected } = useSocket(); // Redux selectors using V3 API (pre-processed data, minimal loops) - const tasks = useSelector(taskManagementSelectors.selectAll); + const tasks = useSelector(selectAllTasks); + const groups = useSelector(selectGroups); + const grouping = useSelector(selectGrouping); + const loading = useSelector(selectLoading); + const error = useSelector(selectError); + const selectedPriorities = useSelector(selectSelectedPriorities); + const searchQuery = useSelector(selectSearch); const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual); - const currentGrouping = useSelector(selectCurrentGroupingV3, shallowEqual); - // Use bulk action slice for selected tasks instead of selection slice - const selectedTaskIds = useSelector( - (state: RootState) => state.bulkActionReducer.selectedTaskIdsList - ); + const currentGrouping = useSelector(selectCurrentGrouping); + const collapsedGroups = useSelector(selectCollapsedGroups); + const selectedTaskIds = useSelector(selectSelectedTaskIds); + const lastSelectedTaskId = useSelector(selectLastSelectedTaskId); const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks); - const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual); - const error = useSelector((state: RootState) => state.taskManagement.error); // Bulk action selectors const statusList = useSelector((state: RootState) => state.taskStatusReducer.status); diff --git a/worklenz-frontend/src/components/task-management/task-list-filters.tsx b/worklenz-frontend/src/components/task-management/task-list-filters.tsx new file mode 100644 index 00000000..0e5db48c --- /dev/null +++ b/worklenz-frontend/src/components/task-management/task-list-filters.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +interface TaskListFiltersProps { + selectedPriorities: string[]; + onPriorityChange: (priorities: string[]) => void; + searchQuery: string; + onSearchChange: (query: string) => void; +} + +const TaskListFilters: React.FC = ({ + selectedPriorities, + onPriorityChange, + searchQuery, + onSearchChange, +}) => { + const priorities = ['High', 'Medium', 'Low']; + + return ( +
+
+ +
+ {priorities.map(priority => ( + + ))} +
+
+
+ + onSearchChange(e.target.value)} + placeholder="Search tasks..." + className="search-input" + /> +
+
+ ); +}; + +export default TaskListFilters; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-list-group.tsx b/worklenz-frontend/src/components/task-management/task-list-group.tsx new file mode 100644 index 00000000..47e9dda6 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/task-list-group.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Task, TaskGroup } from '@/types/task-management.types'; +import TaskRow from './task-row'; + +interface TaskListGroupProps { + group: TaskGroup; + tasks: Task[]; + isCollapsed: boolean; + onCollapse: () => void; + onTaskSelect: (taskId: string, event: React.MouseEvent) => void; + selectedTaskIds: string[]; + projectId: string; + currentGrouping: 'status' | 'priority' | 'phase'; +} + +const TaskListGroup: React.FC = ({ + group, + tasks, + isCollapsed, + onCollapse, + onTaskSelect, + selectedTaskIds, + projectId, + currentGrouping, +}) => { + const groupStyle = { + backgroundColor: group.color ? `${group.color}10` : undefined, + borderColor: group.color, + }; + + const headerStyle = { + backgroundColor: group.color ? `${group.color}20` : undefined, + }; + + return ( +
+
+
+ + {isCollapsed ? '►' : '▼'} + +

{group.title}

+ ({tasks.length}) +
+
+ {!isCollapsed && ( +
+ {tasks.map((task, index) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: task.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ onTaskSelect(taskId, {} as React.MouseEvent)} + index={index} + /> +
+ ); + })} +
+ )} +
+ ); +}; + +export default TaskListGroup; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-list-header.tsx b/worklenz-frontend/src/components/task-management/task-list-header.tsx new file mode 100644 index 00000000..0bbdac89 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/task-list-header.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +interface TaskListHeaderProps { + onExpandAll: () => void; + onCollapseAll: () => void; +} + +const TaskListHeader: React.FC = ({ + onExpandAll, + onCollapseAll, +}) => { + return ( +
+
+ + +
+
+ ); +}; + +export default TaskListHeader; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx index 548b3e9c..0304c3a1 100644 --- a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -9,6 +9,14 @@ import { taskManagementSelectors, toggleTaskExpansion, fetchSubTasks, + selectAllTasks, + selectTaskIds, + selectGroups, + selectGrouping, + selectLoading, + selectError, + selectSelectedPriorities, + selectSearch, } from '@/features/task-management/task-management.slice'; import { toggleGroupCollapsed } from '@/features/task-management/grouping.slice'; import { Task } from '@/types/task-management.types'; diff --git a/worklenz-frontend/src/features/task-management/grouping.slice.ts b/worklenz-frontend/src/features/task-management/grouping.slice.ts index 531dac24..7af5cd82 100644 --- a/worklenz-frontend/src/features/task-management/grouping.slice.ts +++ b/worklenz-frontend/src/features/task-management/grouping.slice.ts @@ -1,10 +1,22 @@ import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit'; -import { GroupingState, TaskGroup } from '@/types/task-management.types'; +import { GroupingState, TaskGroup, TaskGrouping } from '@/types/task-management.types'; import { RootState } from '@/app/store'; -import { taskManagementSelectors } from './task-management.slice'; +import { selectAllTasks } from './task-management.slice'; + +interface GroupingState { + currentGrouping: TaskGrouping | null; + customPhases: string[]; + groupOrder: { + status: string[]; + priority: string[]; + phase: string[]; + }; + groupStates: Record; + collapsedGroups: Set; +} const initialState: GroupingState = { - currentGrouping: 'status', + currentGrouping: null, customPhases: ['Planning', 'Development', 'Testing', 'Deployment'], groupOrder: { status: ['todo', 'doing', 'done'], @@ -12,13 +24,14 @@ const initialState: GroupingState = { phase: ['Planning', 'Development', 'Testing', 'Deployment'], }, groupStates: {}, + collapsedGroups: new Set(), }; const groupingSlice = createSlice({ name: 'grouping', initialState, reducers: { - setCurrentGrouping: (state, action: PayloadAction<'status' | 'priority' | 'phase'>) => { + setCurrentGrouping: (state, action: PayloadAction) => { state.currentGrouping = action.payload; }, @@ -48,10 +61,13 @@ const groupingSlice = createSlice({ toggleGroupCollapsed: (state, action: PayloadAction) => { const groupId = action.payload; - if (!state.groupStates[groupId]) { - state.groupStates[groupId] = { collapsed: false }; + const collapsedGroups = new Set(state.collapsedGroups); + if (collapsedGroups.has(groupId)) { + collapsedGroups.delete(groupId); + } else { + collapsedGroups.add(groupId); } - state.groupStates[groupId].collapsed = !state.groupStates[groupId].collapsed; + state.collapsedGroups = collapsedGroups; }, setGroupCollapsed: (state, action: PayloadAction<{ groupId: string; collapsed: boolean }>) => { @@ -62,16 +78,12 @@ const groupingSlice = createSlice({ state.groupStates[groupId].collapsed = collapsed; }, - collapseAllGroups: state => { - Object.keys(state.groupStates).forEach(groupId => { - state.groupStates[groupId].collapsed = true; - }); + collapseAllGroups: (state, action: PayloadAction) => { + state.collapsedGroups = new Set(action.payload); }, expandAllGroups: state => { - Object.keys(state.groupStates).forEach(groupId => { - state.groupStates[groupId].collapsed = false; - }); + state.collapsedGroups = new Set(); }, resetGrouping: () => initialState, @@ -96,6 +108,9 @@ export const selectCurrentGrouping = (state: RootState) => state.grouping.curren export const selectCustomPhases = (state: RootState) => state.grouping.customPhases; export const selectGroupOrder = (state: RootState) => state.grouping.groupOrder; export const selectGroupStates = (state: RootState) => state.grouping.groupStates; +export const selectCollapsedGroups = (state: RootState) => state.grouping.collapsedGroups; +export const selectIsGroupCollapsed = (state: RootState, groupId: string) => + state.grouping.collapsedGroups.has(groupId); // Complex selectors using createSelector for memoization export const selectCurrentGroupOrder = createSelector( @@ -104,12 +119,7 @@ export const selectCurrentGroupOrder = createSelector( ); export const selectTaskGroups = createSelector( - [ - taskManagementSelectors.selectAll, - selectCurrentGrouping, - selectCurrentGroupOrder, - selectGroupStates, - ], + [selectAllTasks, selectCurrentGrouping, selectCurrentGroupOrder, selectGroupStates], (tasks, currentGrouping, groupOrder, groupStates) => { const groups: TaskGroup[] = []; @@ -154,7 +164,7 @@ export const selectTaskGroups = createSelector( ); export const selectTasksByCurrentGrouping = createSelector( - [taskManagementSelectors.selectAll, selectCurrentGrouping], + [selectAllTasks, selectCurrentGrouping], (tasks, currentGrouping) => { const grouped: Record = {}; diff --git a/worklenz-frontend/src/features/task-management/selection.slice.ts b/worklenz-frontend/src/features/task-management/selection.slice.ts index 7157facc..97f69e7e 100644 --- a/worklenz-frontend/src/features/task-management/selection.slice.ts +++ b/worklenz-frontend/src/features/task-management/selection.slice.ts @@ -1,121 +1,71 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { SelectionState } from '@/types/task-management.types'; +import { TaskSelection } from '@/types/task-management.types'; import { RootState } from '@/app/store'; -const initialState: SelectionState = { +const initialState: TaskSelection = { selectedTaskIds: [], - lastSelectedId: null, + lastSelectedTaskId: null, }; const selectionSlice = createSlice({ - name: 'selection', + name: 'taskManagementSelection', initialState, reducers: { - toggleTaskSelection: (state, action: PayloadAction) => { - const taskId = action.payload; - const index = state.selectedTaskIds.indexOf(taskId); - - if (index === -1) { - state.selectedTaskIds.push(taskId); - } else { - state.selectedTaskIds.splice(index, 1); - } - - state.lastSelectedId = taskId; - }, - selectTask: (state, action: PayloadAction) => { const taskId = action.payload; if (!state.selectedTaskIds.includes(taskId)) { state.selectedTaskIds.push(taskId); } - state.lastSelectedId = taskId; + state.lastSelectedTaskId = taskId; }, - deselectTask: (state, action: PayloadAction) => { const taskId = action.payload; state.selectedTaskIds = state.selectedTaskIds.filter(id => id !== taskId); - if (state.lastSelectedId === taskId) { - state.lastSelectedId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null; + if (state.lastSelectedTaskId === taskId) { + state.lastSelectedTaskId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null; } }, - - selectMultipleTasks: (state, action: PayloadAction) => { + toggleTaskSelection: (state, action: PayloadAction) => { + const taskId = action.payload; + const index = state.selectedTaskIds.indexOf(taskId); + if (index === -1) { + state.selectedTaskIds.push(taskId); + state.lastSelectedTaskId = taskId; + } else { + state.selectedTaskIds.splice(index, 1); + state.lastSelectedTaskId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null; + } + }, + selectRange: (state, action: PayloadAction) => { const taskIds = action.payload; - // Add new task IDs that aren't already selected - taskIds.forEach(id => { - if (!state.selectedTaskIds.includes(id)) { - state.selectedTaskIds.push(id); - } - }); - state.lastSelectedId = taskIds[taskIds.length - 1] || state.lastSelectedId; + const uniqueIds = Array.from(new Set([...state.selectedTaskIds, ...taskIds])); + state.selectedTaskIds = uniqueIds; + state.lastSelectedTaskId = taskIds[taskIds.length - 1]; }, - - selectRangeTasks: ( - state, - action: PayloadAction<{ startId: string; endId: string; allTaskIds: string[] }> - ) => { - const { startId, endId, allTaskIds } = action.payload; - const startIndex = allTaskIds.indexOf(startId); - const endIndex = allTaskIds.indexOf(endId); - - if (startIndex !== -1 && endIndex !== -1) { - const [start, end] = - startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; - const rangeIds = allTaskIds.slice(start, end + 1); - - // Add range IDs that aren't already selected - rangeIds.forEach(id => { - if (!state.selectedTaskIds.includes(id)) { - state.selectedTaskIds.push(id); - } - }); - - state.lastSelectedId = endId; - } - }, - - selectAllTasks: (state, action: PayloadAction) => { - state.selectedTaskIds = action.payload; - state.lastSelectedId = action.payload[action.payload.length - 1] || null; - }, - clearSelection: state => { state.selectedTaskIds = []; - state.lastSelectedId = null; + state.lastSelectedTaskId = null; }, - - setSelection: (state, action: PayloadAction) => { - state.selectedTaskIds = action.payload; - state.lastSelectedId = action.payload[action.payload.length - 1] || null; + resetSelection: state => { + state.selectedTaskIds = []; + state.lastSelectedTaskId = null; }, - - resetSelection: () => initialState, }, }); export const { - toggleTaskSelection, selectTask, deselectTask, - selectMultipleTasks, - selectRangeTasks, - selectAllTasks, + toggleTaskSelection, + selectRange, clearSelection, - setSelection, resetSelection, } = selectionSlice.actions; // Selectors -export const selectSelectedTaskIds = (state: RootState) => - state.taskManagementSelection.selectedTaskIds; -export const selectLastSelectedId = (state: RootState) => - state.taskManagementSelection.lastSelectedId; -export const selectHasSelection = (state: RootState) => - state.taskManagementSelection.selectedTaskIds.length > 0; -export const selectSelectionCount = (state: RootState) => - state.taskManagementSelection.selectedTaskIds.length; -export const selectIsTaskSelected = (taskId: string) => (state: RootState) => +export const selectSelectedTaskIds = (state: RootState) => state.taskManagementSelection.selectedTaskIds; +export const selectLastSelectedTaskId = (state: RootState) => state.taskManagementSelection.lastSelectedTaskId; +export const selectIsTaskSelected = (state: RootState, taskId: string) => state.taskManagementSelection.selectedTaskIds.includes(taskId); export default selectionSlice.reducer; diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 29199214..b2f4c4cb 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -3,8 +3,10 @@ import { createEntityAdapter, PayloadAction, createAsyncThunk, + EntityState, + EntityId, } from '@reduxjs/toolkit'; -import { Task, TaskManagementState } from '@/types/task-management.types'; +import { Task, TaskManagementState, TaskGroup, TaskGrouping } from '@/types/task-management.types'; import { RootState } from '@/app/store'; import { tasksApiService, @@ -13,6 +15,24 @@ import { } from '@/api/tasks/tasks.api.service'; import logger from '@/utils/errorLogger'; +// Helper function to safely convert time values +const convertTimeValue = (value: any): number => { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsed = parseFloat(value); + return isNaN(parsed) ? 0 : parsed; + } + if (typeof value === 'object' && value !== null) { + // Handle time objects like {hours: 2, minutes: 30} + if ('hours' in value || 'minutes' in value) { + const hours = Number(value.hours || 0); + const minutes = Number(value.minutes || 0); + return hours + minutes / 60; + } + } + return 0; +}; + export enum IGroupBy { STATUS = 'status', PRIORITY = 'priority', @@ -21,17 +41,16 @@ export enum IGroupBy { } // Entity adapter for normalized state -const tasksAdapter = createEntityAdapter({ - sortComparer: (a, b) => a.order - b.order, -}); +const tasksAdapter = createEntityAdapter(); +// Get the initial state from the adapter const initialState: TaskManagementState = { - entities: {}, ids: [], + entities: {}, loading: false, error: null, groups: [], - grouping: null, + grouping: undefined, selectedPriorities: [], search: '', }; @@ -47,7 +66,7 @@ export const fetchTasks = createAsyncThunk( const config: ITaskListConfigV2 = { id: projectId, archived: false, - group: currentGrouping, + group: currentGrouping || '', field: '', order: '', search: '', @@ -167,24 +186,18 @@ export const fetchTasksV3 = createAsyncThunk( // Get selected labels from taskReducer const selectedLabels = state.taskReducer.labels - ? state.taskReducer.labels - .filter(l => l.selected) - .map(l => l.id) - .join(' ') - : ''; + .filter((l: any) => l.selected && l.id) + .map((l: any) => l.id) + .join(' '); // Get selected assignees from taskReducer const selectedAssignees = state.taskReducer.taskAssignees - ? state.taskReducer.taskAssignees - .filter(m => m.selected) - .map(m => m.id) - .join(' ') - : ''; + .filter((m: any) => m.selected && m.id) + .map((m: any) => m.id) + .join(' '); - // Get selected priorities from taskReducer (consistent with other slices) - const selectedPriorities = state.taskReducer.priorities - ? state.taskReducer.priorities.join(' ') - : ''; + // Get selected priorities from taskReducer + const selectedPriorities = state.taskReducer.priorities.join(' '); // Get search value from taskReducer const searchValue = state.taskReducer.search || ''; @@ -192,7 +205,7 @@ export const fetchTasksV3 = createAsyncThunk( const config: ITaskListConfigV2 = { id: projectId, archived: false, - group: currentGrouping, + group: currentGrouping || '', field: '', order: '', search: searchValue, @@ -206,10 +219,82 @@ export const fetchTasksV3 = createAsyncThunk( const response = await tasksApiService.getTaskListV3(config); - // Minimal processing - tasks are already processed by backend + // Log raw response for debugging + console.log('Raw API response:', response.body); + + // Ensure tasks are properly normalized + const tasks = response.body.allTasks.map((task: any) => { + const now = new Date().toISOString(); + return { + id: task.id, + title: task.name || '', + description: task.description || '', + status: task.status || 'todo', + priority: task.priority || 'medium', + phase: task.phase || 'Development', + progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0, + assignees: task.assignees?.map((a: { team_member_id: string }) => a.team_member_id) || [], + assignee_names: task.assignee_names || task.names || [], + labels: task.labels?.map((l: { id: string; label_id: string; name: string; color_code: string; end: boolean; names: string[] }) => ({ + id: l.id || l.label_id, + name: l.name, + color: l.color_code || '#1890ff', + end: l.end, + names: l.names, + })) || [], + due_date: task.end_date || '', + timeTracking: { + estimated: convertTimeValue(task.total_time), + logged: convertTimeValue(task.time_spent), + }, + created_at: task.created_at || now, + updated_at: task.updated_at || now, + order: typeof task.sort_order === 'number' ? task.sort_order : 0, + sub_tasks: task.sub_tasks || [], + sub_tasks_count: task.sub_tasks_count || 0, + show_sub_tasks: task.show_sub_tasks || false, + parent_task_id: task.parent_task_id || '', + weight: task.weight || 0, + color: task.color || '', + statusColor: task.status_color || '', + priorityColor: task.priority_color || '', + comments_count: task.comments_count || 0, + attachments_count: task.attachments_count || 0, + has_dependencies: !!task.has_dependencies, + schedule_id: task.schedule_id || null, + } as Task; + }); + + // Map groups to match TaskGroup interface + const mappedGroups = response.body.groups.map((group: any) => ({ + id: group.id, + title: group.title, + taskIds: group.taskIds || [], + type: group.groupType as 'status' | 'priority' | 'phase' | 'members', + color: group.color, + })); + + // Log normalized data for debugging + console.log('Normalized data:', { + tasks, + groups: mappedGroups, + grouping: response.body.grouping, + totalTasks: response.body.totalTasks, + }); + + // Verify task IDs match group taskIds + const taskIds = new Set(tasks.map(t => t.id)); + const groupTaskIds = new Set(mappedGroups.flatMap(g => g.taskIds)); + console.log('Task ID verification:', { + taskIds: Array.from(taskIds), + groupTaskIds: Array.from(groupTaskIds), + allTaskIdsInGroups: Array.from(groupTaskIds).every(id => taskIds.has(id)), + allGroupTaskIdsInTasks: Array.from(taskIds).every(id => groupTaskIds.has(id)), + }); + return { - tasks: response.body.allTasks, - groups: response.body.groups, + tasks: tasks, + groups: mappedGroups, grouping: response.body.grouping, totalTasks: response.body.totalTasks, }; @@ -351,288 +436,148 @@ export const updateTaskWithSubtasks = createAsyncThunk( } ); +// Create the slice const taskManagementSlice = createSlice({ name: 'taskManagement', - initialState: tasksAdapter.getInitialState(initialState), + initialState, reducers: { - // Basic CRUD operations setTasks: (state, action: PayloadAction) => { - tasksAdapter.setAll(state, action.payload); - state.loading = false; - state.error = null; + const tasks = action.payload; + state.ids = tasks.map(task => task.id); + state.entities = tasks.reduce((acc, task) => { + acc[task.id] = task; + return acc; + }, {} as Record); }, - addTask: (state, action: PayloadAction) => { - tasksAdapter.addOne(state, action.payload); + const task = action.payload; + state.ids.push(task.id); + state.entities[task.id] = task; }, - - addTaskToGroup: (state, action: PayloadAction<{ task: Task; groupId?: string }>) => { + addTaskToGroup: (state, action: PayloadAction<{ task: Task; groupId: string }>) => { const { task, groupId } = action.payload; - - // Add to entity adapter - tasksAdapter.addOne(state, task); - - // Add to groups array for V3 API compatibility - if (state.groups && state.groups.length > 0) { - // Find the target group using the provided UUID - const targetGroup = state.groups.find(group => { - // If a specific groupId (UUID) is provided, use it directly - if (groupId && group.id === groupId) { - return true; - } - - return false; - }); - - if (targetGroup) { - // Add task ID to the end of the group's taskIds array (newest last) - targetGroup.taskIds.push(task.id); - - // Also add to the tasks array if it exists (for backward compatibility) - if ((targetGroup as any).tasks) { - (targetGroup as any).tasks.push(task); - } - } + state.ids.push(task.id); + state.entities[task.id] = task; + const group = state.groups.find(g => g.id === groupId); + if (group) { + group.taskIds.push(task.id); } }, - - updateTask: (state, action: PayloadAction<{ id: string; changes: Partial }>) => { - tasksAdapter.updateOne(state, { - id: action.payload.id, - changes: { - ...action.payload.changes, - updatedAt: new Date().toISOString(), - }, + updateTask: (state, action: PayloadAction) => { + const task = action.payload; + state.entities[task.id] = task; + }, + deleteTask: (state, action: PayloadAction) => { + const taskId = action.payload; + delete state.entities[taskId]; + state.ids = state.ids.filter(id => id !== taskId); + state.groups = state.groups.map(group => ({ + ...group, + taskIds: group.taskIds.filter(id => id !== taskId), + })); + }, + bulkUpdateTasks: (state, action: PayloadAction) => { + action.payload.forEach(task => { + state.entities[task.id] = task; }); }, - - deleteTask: (state, action: PayloadAction) => { - tasksAdapter.removeOne(state, action.payload); - }, - - // Bulk operations - bulkUpdateTasks: (state, action: PayloadAction<{ ids: string[]; changes: Partial }>) => { - const { ids, changes } = action.payload; - const updates = ids.map(id => ({ - id, - changes: { - ...changes, - updatedAt: new Date().toISOString(), - }, - })); - tasksAdapter.updateMany(state, updates); - }, - bulkDeleteTasks: (state, action: PayloadAction) => { - tasksAdapter.removeMany(state, action.payload); - }, - - // Optimized drag and drop operations - reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => { - const { taskIds, newOrder } = action.payload; - - // Batch update for better performance - const updates = taskIds.map((id, index) => ({ - id, - changes: { - order: newOrder[index], - updatedAt: new Date().toISOString(), - }, + const taskIds = action.payload; + taskIds.forEach(taskId => { + delete state.entities[taskId]; + }); + state.ids = state.ids.filter(id => !taskIds.includes(id)); + state.groups = state.groups.map(group => ({ + ...group, + taskIds: group.taskIds.filter(id => !taskIds.includes(id)), })); - - tasksAdapter.updateMany(state, updates); }, - - moveTaskToGroup: ( - state, - action: PayloadAction<{ - taskId: string; - groupType: 'status' | 'priority' | 'phase'; - groupValue: string; - }> - ) => { - const { taskId, groupType, groupValue } = action.payload; - const changes: Partial = { - updatedAt: new Date().toISOString(), - }; - - // Update the appropriate field based on group type - if (groupType === 'status') { - changes.status = groupValue as Task['status']; - } else if (groupType === 'priority') { - changes.priority = groupValue as Task['priority']; - } else if (groupType === 'phase') { - changes.phase = groupValue; + reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; groupId: string }>) => { + const { taskIds, groupId } = action.payload; + const group = state.groups.find(g => g.id === groupId); + if (group) { + group.taskIds = taskIds; } - - tasksAdapter.updateOne(state, { id: taskId, changes }); }, - - // New action to move task between groups with proper group management + moveTaskToGroup: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => { + const { taskId, groupId } = action.payload; + state.groups = state.groups.map(group => ({ + ...group, + taskIds: + group.id === groupId + ? [...group.taskIds, taskId] + : group.taskIds.filter(id => id !== taskId), + })); + }, moveTaskBetweenGroups: ( state, action: PayloadAction<{ taskId: string; - fromGroupId: string; - toGroupId: string; - taskUpdate: Partial; + sourceGroupId: string; + targetGroupId: string; }> ) => { - const { taskId, fromGroupId, toGroupId, taskUpdate } = action.payload; - - // Update the task entity with new values - tasksAdapter.updateOne(state, { - id: taskId, - changes: { - ...taskUpdate, - updatedAt: new Date().toISOString(), - }, - }); - - // Update groups if they exist - if (state.groups && state.groups.length > 0) { - // Remove task from old group - const fromGroup = state.groups.find(group => group.id === fromGroupId); - if (fromGroup) { - fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId); - } - - // Add task to new group - const toGroup = state.groups.find(group => group.id === toGroupId); - if (toGroup) { - // Add to the end of the group (newest last) - toGroup.taskIds.push(taskId); - } - } + const { taskId, sourceGroupId, targetGroupId } = action.payload; + state.groups = state.groups.map(group => ({ + ...group, + taskIds: + group.id === targetGroupId + ? [...group.taskIds, taskId] + : group.id === sourceGroupId + ? group.taskIds.filter(id => id !== taskId) + : group.taskIds, + })); }, - - // Optimistic update for drag operations - reduces perceived lag optimisticTaskMove: ( - state, - action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }> - ) => { - const { taskId, newGroupId, newIndex } = action.payload; - const task = state.entities[taskId]; - - if (task) { - // Parse group ID to determine new values - const [groupType, ...groupValueParts] = newGroupId.split('-'); - const groupValue = groupValueParts.join('-'); - - const changes: Partial = { - order: newIndex, - updatedAt: new Date().toISOString(), - }; - - // Update group-specific field - if (groupType === 'status') { - changes.status = groupValue as Task['status']; - } else if (groupType === 'priority') { - changes.priority = groupValue as Task['priority']; - } else if (groupType === 'phase') { - changes.phase = groupValue; - } - - // Update the task entity - tasksAdapter.updateOne(state, { id: taskId, changes }); - - // Update groups if they exist - if (state.groups && state.groups.length > 0) { - // Find the target group - const targetGroup = state.groups.find(group => group.id === newGroupId); - if (targetGroup) { - // Remove task from all groups first - state.groups.forEach(group => { - group.taskIds = group.taskIds.filter(id => id !== taskId); - }); - - // Add task to target group at the specified index - if (newIndex >= targetGroup.taskIds.length) { - targetGroup.taskIds.push(taskId); - } else { - targetGroup.taskIds.splice(newIndex, 0, taskId); - } - } - } - } - }, - - // Proper reorder action that handles both task entities and group arrays - reorderTasksInGroup: ( state, action: PayloadAction<{ taskId: string; - fromGroupId: string; - toGroupId: string; - fromIndex: number; - toIndex: number; - groupType: 'status' | 'priority' | 'phase'; - groupValue: string; + sourceGroupId: string; + targetGroupId: string; }> ) => { - const { taskId, fromGroupId, toGroupId, fromIndex, toIndex, groupType, groupValue } = - action.payload; - - // Update the task entity - const changes: Partial = { - order: toIndex, - updatedAt: new Date().toISOString(), - }; - - // Update group-specific field - if (groupType === 'status') { - changes.status = groupValue as Task['status']; - } else if (groupType === 'priority') { - changes.priority = groupValue as Task['priority']; - } else if (groupType === 'phase') { - changes.phase = groupValue; - } - - tasksAdapter.updateOne(state, { id: taskId, changes }); - - // Update groups if they exist - if (state.groups && state.groups.length > 0) { - // Remove task from source group - const fromGroup = state.groups.find(group => group.id === fromGroupId); - if (fromGroup) { - fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId); - } - - // Add task to target group - const toGroup = state.groups.find(group => group.id === toGroupId); - if (toGroup) { - if (toIndex >= toGroup.taskIds.length) { - toGroup.taskIds.push(taskId); - } else { - toGroup.taskIds.splice(toIndex, 0, taskId); - } - } + const { taskId, sourceGroupId, targetGroupId } = action.payload; + state.groups = state.groups.map(group => ({ + ...group, + taskIds: + group.id === targetGroupId + ? [...group.taskIds, taskId] + : group.id === sourceGroupId + ? group.taskIds.filter(id => id !== taskId) + : group.taskIds, + })); + }, + reorderTasksInGroup: ( + state, + action: PayloadAction<{ taskIds: string[]; groupId: string }> + ) => { + const { taskIds, groupId } = action.payload; + const group = state.groups.find(g => g.id === groupId); + if (group) { + group.taskIds = taskIds; } }, - - // Loading states setLoading: (state, action: PayloadAction) => { state.loading = action.payload; }, - setError: (state, action: PayloadAction) => { state.error = action.payload; - state.loading = false; }, - - // Filter actions setSelectedPriorities: (state, action: PayloadAction) => { state.selectedPriorities = action.payload; }, - - // Search action setSearch: (state, action: PayloadAction) => { state.search = action.payload; }, - - // Reset action resetTaskManagement: state => { - return tasksAdapter.getInitialState(initialState); + state.loading = false; + state.error = null; + state.groups = []; + state.grouping = undefined; + state.selectedPriorities = []; + state.search = ''; + state.ids = []; + state.entities = {}; }, toggleTaskExpansion: (state, action: PayloadAction) => { const task = state.entities[action.payload]; @@ -640,37 +585,25 @@ const taskManagementSlice = createSlice({ task.show_sub_tasks = !task.show_sub_tasks; } }, - addSubtaskToParent: (state, action: PayloadAction<{ subtask: Task; parentTaskId: string }>) => { - const { subtask, parentTaskId } = action.payload; - const parentTask = state.entities[parentTaskId]; - if (parentTask) { - if (!parentTask.sub_tasks) { - parentTask.sub_tasks = []; + addSubtaskToParent: ( + state, + action: PayloadAction<{ parentId: string; subtask: Task }> + ) => { + const { parentId, subtask } = action.payload; + const parent = state.entities[parentId]; + if (parent) { + state.ids.push(subtask.id); + state.entities[subtask.id] = subtask; + if (!parent.sub_tasks) { + parent.sub_tasks = []; } - parentTask.sub_tasks.push(subtask); - parentTask.sub_tasks_count = (parentTask.sub_tasks_count || 0) + 1; - // Ensure the parent task is expanded to show the new subtask - parentTask.show_sub_tasks = true; - // Add the subtask to the main entities as well - tasksAdapter.addOne(state, subtask); + parent.sub_tasks.push(subtask); + parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1; } }, }, extraReducers: builder => { builder - .addCase(fetchTasks.pending, state => { - state.loading = true; - state.error = null; - }) - .addCase(fetchTasks.fulfilled, (state, action) => { - state.loading = false; - state.error = null; - tasksAdapter.setAll(state, action.payload); - }) - .addCase(fetchTasks.rejected, (state, action) => { - state.loading = false; - state.error = (action.payload as string) || 'Failed to fetch tasks'; - }) .addCase(fetchTasksV3.pending, state => { state.loading = true; state.error = null; @@ -678,47 +611,68 @@ const taskManagementSlice = createSlice({ .addCase(fetchTasksV3.fulfilled, (state, action) => { state.loading = false; state.error = null; - // Tasks are already processed by backend, minimal setup needed - tasksAdapter.setAll(state, action.payload.tasks); - state.groups = action.payload.groups; - state.grouping = action.payload.grouping; + + // Ensure we have tasks before updating state + if (action.payload.tasks && action.payload.tasks.length > 0) { + // Update tasks + const tasks = action.payload.tasks; + state.ids = tasks.map(task => task.id); + state.entities = tasks.reduce((acc, task) => { + acc[task.id] = task; + return acc; + }, {} as Record); + + // Update groups + state.groups = action.payload.groups; + state.grouping = action.payload.grouping; + + // Verify task IDs match group taskIds + const taskIds = new Set(Object.keys(state.entities)); + const groupTaskIds = new Set(state.groups.flatMap(g => g.taskIds)); + + // Ensure all tasks have IDs and all group taskIds exist + const validTaskIds = new Set(Object.keys(state.entities)); + state.groups = state.groups.map((group: TaskGroup) => ({ + ...group, + taskIds: group.taskIds.filter((id: string) => validTaskIds.has(id)), + })); + } else { + // Set empty state but don't show error + state.ids = []; + state.entities = {}; + state.groups = []; + } }) .addCase(fetchTasksV3.rejected, (state, action) => { state.loading = false; - state.error = (action.payload as string) || 'Failed to fetch tasks'; + // Provide a more descriptive error message + state.error = action.error.message || action.payload || 'An error occurred while fetching tasks. Please try again.'; + // Clear task data on error to prevent stale state + state.ids = []; + state.entities = {}; + state.groups = []; + }) + .addCase(fetchSubTasks.pending, (state, action) => { + // Don't set global loading state for subtasks + state.error = null; }) .addCase(fetchSubTasks.fulfilled, (state, action) => { const { parentTaskId, subtasks } = action.payload; const parentTask = state.entities[parentTaskId]; if (parentTask) { parentTask.sub_tasks = subtasks; + parentTask.sub_tasks_count = subtasks.length; parentTask.show_sub_tasks = true; - // Add subtasks to the main entities as well - tasksAdapter.addMany(state, subtasks); } }) - .addCase(refreshTaskProgress.pending, state => { - // Don't set loading to true for refresh to avoid UI blocking - state.error = null; - }) - .addCase(refreshTaskProgress.fulfilled, state => { - state.error = null; - // Progress refresh completed successfully - }) - .addCase(refreshTaskProgress.rejected, (state, action) => { - state.error = (action.payload as string) || 'Failed to refresh task progress'; - }) - .addCase(updateTaskWithSubtasks.fulfilled, (state, action) => { - const { taskId, subtasks } = action.payload; - const task = state.entities[taskId]; - if (task) { - task.sub_tasks = subtasks; - task.show_sub_tasks = true; - } + .addCase(fetchSubTasks.rejected, (state, action) => { + // Set error but don't clear task data + state.error = action.error.message || action.payload || 'Failed to fetch subtasks. Please try again.'; }); }, }); +// Export the slice reducer and actions export const { setTasks, addTask, @@ -741,25 +695,30 @@ export const { addSubtaskToParent, } = taskManagementSlice.actions; -export default taskManagementSlice.reducer; +// Export the selectors +export const selectAllTasks = (state: RootState) => state.taskManagement.entities; +export const selectAllTasksArray = (state: RootState) => Object.values(state.taskManagement.entities); +export const selectTaskById = (state: RootState, taskId: string) => state.taskManagement.entities[taskId]; +export const selectTaskIds = (state: RootState) => state.taskManagement.ids; +export const selectGroups = (state: RootState) => state.taskManagement.groups; +export const selectGrouping = (state: RootState) => state.taskManagement.grouping; +export const selectLoading = (state: RootState) => state.taskManagement.loading; +export const selectError = (state: RootState) => state.taskManagement.error; +export const selectSelectedPriorities = (state: RootState) => state.taskManagement.selectedPriorities; +export const selectSearch = (state: RootState) => state.taskManagement.search; -// Selectors -export const taskManagementSelectors = tasksAdapter.getSelectors( - state => state.taskManagement -); - -// Enhanced selectors for better performance +// Memoized selectors export const selectTasksByStatus = (state: RootState, status: string) => - taskManagementSelectors.selectAll(state).filter(task => task.status === status); + Object.values(state.taskManagement.entities).filter(task => task.status === status); export const selectTasksByPriority = (state: RootState, priority: string) => - taskManagementSelectors.selectAll(state).filter(task => task.priority === priority); + Object.values(state.taskManagement.entities).filter(task => task.priority === priority); export const selectTasksByPhase = (state: RootState, phase: string) => - taskManagementSelectors.selectAll(state).filter(task => task.phase === phase); + Object.values(state.taskManagement.entities).filter(task => task.phase === phase); -export const selectTasksLoading = (state: RootState) => state.taskManagement.loading; -export const selectTasksError = (state: RootState) => state.taskManagement.error; +// Export the reducer as default +export default taskManagementSlice.reducer; // V3 API selectors - no processing needed, data is pre-processed by backend export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups; diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index 3cd6fc89..bf6348a3 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -4,6 +4,7 @@ import { InlineSuspenseFallback } from '@/components/suspense-fallback/suspense- // Import core components synchronously to avoid suspense in main tabs import ProjectViewEnhancedTasks from '@/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks'; import ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board'; +import TaskListV2 from '@/components/task-list-v2/TaskListV2'; // Lazy load less critical components const ProjectViewInsights = React.lazy( @@ -35,7 +36,7 @@ export const tabItems: TabItems[] = [ key: 'tasks-list', label: 'Task List', isPinned: true, - element: React.createElement(ProjectViewEnhancedTasks), + element: React.createElement(TaskListV2), }, { index: 1, diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 42dfc71e..cf42bfdb 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -119,7 +119,7 @@ const ProjectView = React.memo(() => { return () => { resetAllProjectData(); }; - }, []); // Empty dependency array - only runs on mount/unmount + }, [resetAllProjectData]); // Effect for handling route changes (when navigating away from project view) useEffect(() => { @@ -358,10 +358,10 @@ const ProjectView = React.memo(() => { minHeight: '36px', }} tabBarGutter={0} - destroyInactiveTabPane={true} // Destroy inactive tabs to save memory + destroyInactiveTabPane={true} animated={{ inkBar: true, - tabPane: false, // Disable content animation for better performance + tabPane: false, }} size="small" type="card" diff --git a/worklenz-frontend/src/types/task-list-field.types.ts b/worklenz-frontend/src/types/task-list-field.types.ts new file mode 100644 index 00000000..35c5b24c --- /dev/null +++ b/worklenz-frontend/src/types/task-list-field.types.ts @@ -0,0 +1,28 @@ +export interface TaskListField { + id: string; + name: string; + type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'checkbox'; + isVisible: boolean; + order: number; + width?: number; + options?: { + id: string; + label: string; + value: string; + color?: string; + }[]; +} + +export interface TaskListFieldGroup { + id: string; + name: string; + fields: TaskListField[]; + order: number; +} + +export interface TaskListFieldState { + fields: TaskListField[]; + groups: TaskListFieldGroup[]; + loading: boolean; + error: string | null; +} \ No newline at end of file diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 064855a8..f8e554c8 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -1,43 +1,44 @@ import { InlineMember } from './teamMembers/inlineMember.types'; +import { EntityState } from '@reduxjs/toolkit'; export interface Task { id: string; - task_key: string; title: string; description?: string; - status: 'todo' | 'doing' | 'done'; - priority: 'critical' | 'high' | 'medium' | 'low'; - phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment' - progress: number; // 0-100 - assignees: string[]; - assignee_names?: InlineMember[]; - labels: Label[]; - startDate?: string; // Start date for the task - dueDate?: string; // Due date for the task - completedAt?: string; // When the task was completed - reporter?: string; // Who reported/created the task - timeTracking: { - estimated?: number; - logged: number; - }; - customFields: Record; - createdAt: string; - updatedAt: string; - order: number; - // Subtask-related properties + status: string; + priority: string; + phase?: string; + assignee?: string; + due_date?: string; + created_at: string; + updated_at: string; + sub_tasks?: Task[]; sub_tasks_count?: number; show_sub_tasks?: boolean; - sub_tasks?: Task[]; + parent_task_id?: string; + progress?: number; + weight?: number; + color?: string; + statusColor?: string; + priorityColor?: string; + labels?: { id: string; name: string; color: string }[]; + comments_count?: number; + attachments_count?: number; + has_dependencies?: boolean; + schedule_id?: string | null; + order?: number; + // Add any other task properties as needed } export interface TaskGroup { id: string; title: string; - groupType: 'status' | 'priority' | 'phase'; - groupValue: string; // The actual value for the group (e.g., 'todo', 'high', 'development') - collapsed: boolean; taskIds: string[]; - color?: string; // For visual distinction + type?: 'status' | 'priority' | 'phase' | 'members'; + color?: string; + collapsed?: boolean; + groupValue?: string; + // Add any other group properties as needed } export interface GroupingConfig { @@ -73,14 +74,14 @@ export interface Label { // Redux State Interfaces export interface TaskManagementState { - entities: Record; ids: string[]; + entities: Record; loading: boolean; error: string | null; - groups: TaskGroup[]; // Pre-processed groups from V3 API - grouping: string | null; // Current grouping from V3 API - selectedPriorities: string[]; // Selected priority filters - search: string; // Search query for filtering tasks + groups: TaskGroup[]; + grouping: string | undefined; + selectedPriorities: string[]; + search: string; } export interface TaskGroupsState { @@ -89,15 +90,20 @@ export interface TaskGroupsState { } export interface GroupingState { - currentGrouping: 'status' | 'priority' | 'phase'; - customPhases: string[]; - groupOrder: Record; - groupStates: Record; // Persist group states + currentGrouping: TaskGrouping | null; + collapsedGroups: Set; } -export interface SelectionState { +export interface TaskGrouping { + id: string; + name: string; + field: string; + collapsed?: boolean; +} + +export interface TaskSelection { selectedTaskIds: string[]; - lastSelectedId: string | null; + lastSelectedTaskId: string | null; } export interface ColumnsState {