diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index 251dbd44..b208dc5a 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -69,11 +69,11 @@ "@types/react-dom": "19.0.0", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", + "autoprefixer": "^10.4.21", "postcss": "^8.5.2", - "prettier-plugin-tailwindcss": "^0.6.8", + "prettier-plugin-tailwindcss": "^0.6.13", "rollup": "^4.40.2", - "tailwindcss": "^3.4.17", + "tailwindcss": "^3.4.15", "terser": "^5.39.0", "typescript": "^5.7.3", "vite": "^6.3.5", @@ -3652,6 +3652,18 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4509,13 +4521,15 @@ } }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-tokens": { @@ -4582,6 +4596,257 @@ "html2canvas": "^1.0.0-rc.5" } }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -5255,9 +5520,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.11", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", - "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "version": "0.6.13", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.13.tgz", + "integrity": "sha512-uQ0asli1+ic8xrrSmIOaElDu0FacR4x69GynTh2oZjFY10JUt6EEumTQl5tB4fMeD6I1naKd+4rXQQ7esT2i1g==", "dev": true, "license": "MIT", "engines": { @@ -7019,6 +7284,16 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss/node_modules/jiti": { + "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" + } + }, "node_modules/terser": { "version": "5.39.2", "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index bb1d7f46..969f7461 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -73,11 +73,11 @@ "@types/react-dom": "19.0.0", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", + "autoprefixer": "^10.4.21", "postcss": "^8.5.2", - "prettier-plugin-tailwindcss": "^0.6.8", + "prettier-plugin-tailwindcss": "^0.6.13", "rollup": "^4.40.2", - "tailwindcss": "^3.4.17", + "tailwindcss": "^3.4.15", "terser": "^5.39.0", "typescript": "^5.7.3", "vite": "^6.3.5", diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 9c780bd7..177e0e73 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -227,7 +227,7 @@ const AssigneeSelector: React.FC = ({
{ - const baseClasses = `inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 ${isDarkMode ? 'focus:ring-blue-400' : 'focus:ring-blue-500'} focus:ring-offset-1 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`; + const baseClasses = `inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 ${isDarkMode ? 'focus:ring-blue-400' : 'focus:ring-blue-500'} focus:ring-offset-2 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`; const variantClasses = { text: isDarkMode @@ -42,7 +42,7 @@ const Button: React.FC = ({ return ( {truncatedName} diff --git a/worklenz-frontend/src/components/LabelsSelector.tsx b/worklenz-frontend/src/components/LabelsSelector.tsx index bb5f88ad..fb6eadb7 100644 --- a/worklenz-frontend/src/components/LabelsSelector.tsx +++ b/worklenz-frontend/src/components/LabelsSelector.tsx @@ -180,7 +180,7 @@ const LabelsSelector: React.FC = ({
= ({ />
diff --git a/worklenz-frontend/src/components/Tag.tsx b/worklenz-frontend/src/components/Tag.tsx index 5cdad835..519091c4 100644 --- a/worklenz-frontend/src/components/Tag.tsx +++ b/worklenz-frontend/src/components/Tag.tsx @@ -24,7 +24,7 @@ const Tag: React.FC = ({ default: 'px-2 py-1 text-xs' }; - const baseClasses = `inline-flex items-center font-medium rounded ${sizeClasses[size]}`; + const baseClasses = `inline-flex items-center font-medium rounded-sm ${sizeClasses[size]}`; if (variant === 'outlined') { return ( diff --git a/worklenz-frontend/src/components/Tooltip.tsx b/worklenz-frontend/src/components/Tooltip.tsx index e61ea1ce..a0074b8a 100644 --- a/worklenz-frontend/src/components/Tooltip.tsx +++ b/worklenz-frontend/src/components/Tooltip.tsx @@ -25,7 +25,7 @@ const Tooltip: React.FC = ({ return (
{children} -
+
{title}
diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx index dc59b83d..1226cbad 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx @@ -72,6 +72,7 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy); const project = useAppSelector((state: RootState) => state.projectReducer.project); const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer); + const themeMode = useAppSelector(state => state.themeReducer.mode); // Load filter data useFilterDataLoader(); @@ -445,18 +446,42 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl {activeTask && ( - +
+ {activeTask.name} +
)} {activeGroup && ( -
-
-

{activeGroup.name}

- ({activeGroup.tasks.length}) -
+
+

{activeGroup.name}

+ ({activeGroup.tasks.length})
)} diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateTaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateTaskCard.tsx index 8c946f9b..dbb17262 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateTaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateTaskCard.tsx @@ -128,7 +128,7 @@ const EnhancedKanbanCreateTaskCard: React.FC marginBottom: 8, marginTop: 8, }} - className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`} + className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline-solid`} > = React.memo(({ e.stopPropagation(); }} > - - {group.tasks.length} - {isLoading && } {isEditable ? ( @@ -403,7 +391,7 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ e.stopPropagation(); }} > - {name} + {name} ({group.tasks.length}) )} @@ -480,28 +468,36 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ {group.tasks.map((task, index) => ( - {/* Show drop indicator before task if this is the target position */} - {shouldShowDropIndicators && overId === task.id && ( -
-
-
+ {/* Drop indicator before the card if this is the drop target */} + {overId === task.id && ( +
)} - - - {/* Show drop indicator after last task if dropping at the end */} - {shouldShowDropIndicators && - index === group.tasks.length - 1 && - overId === group.id && ( -
-
-
- )} + {/* Drop indicator at the end if dropping at the end of the group */} + {index === group.tasks.length - 1 && overId === group.id && ( +
+ )} ))} diff --git a/worklenz-frontend/src/components/schedule/grant-chart/grantt-chart.tsx b/worklenz-frontend/src/components/schedule/grant-chart/grantt-chart.tsx index 934b6e2b..675adf3e 100644 --- a/worklenz-frontend/src/components/schedule/grant-chart/grantt-chart.tsx +++ b/worklenz-frontend/src/components/schedule/grant-chart/grantt-chart.tsx @@ -114,7 +114,7 @@ const GranttChart = React.forwardRef(({ type, date }: { type: string; date: Date style={{ background: themeWiseColor('#fff', '#141414', themeMode), }} - className={`after:content relative z-10 after:absolute after:-right-1 after:top-0 after:-z-10 after:h-full after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent`} + className={`after:content relative z-10 after:absolute after:-right-1 after:top-0 after:-z-10 after:h-full after:w-1.5 after:bg-transparent after:bg-linear-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent`} >
)) diff --git a/worklenz-frontend/src/components/task-list-common/priorityDropdown/priority-dropdown.tsx b/worklenz-frontend/src/components/task-list-common/priorityDropdown/priority-dropdown.tsx index 05e8c82f..bc233968 100644 --- a/worklenz-frontend/src/components/task-list-common/priorityDropdown/priority-dropdown.tsx +++ b/worklenz-frontend/src/components/task-list-common/priorityDropdown/priority-dropdown.tsx @@ -153,7 +153,7 @@ const PriorityDropdown = ({ task, teamId }: PriorityDropdownProps) => { // Fallback rendering for raw priority values or when priority list is not loaded return (
{ // Fallback rendering for raw status values or when status list is not loaded return (
handleToggleExpansion(taskId)} - className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54] transition duration-150" + className="hover flex h-4 w-4 items-center justify-center rounded-sm text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54] transition duration-150" > {expandedTasks.includes(taskId) ? : } diff --git a/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx b/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx index 77cc9483..b1777192 100644 --- a/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx +++ b/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx @@ -15,7 +15,7 @@ const HeavyAssigneeSelector = React.lazy(() => new Promise<{ default: React.ComponentType }>((resolve) => setTimeout(() => resolve({ default: () => ( -
+
🚀 Heavy Assignee Selector Loaded!
This component contains: @@ -36,7 +36,7 @@ const HeavyDatePicker = React.lazy(() => new Promise<{ default: React.ComponentType }>((resolve) => setTimeout(() => resolve({ default: () => ( -
+
📅 Heavy Date Picker Loaded!
This component contains: @@ -57,7 +57,7 @@ const HeavyPrioritySelector = React.lazy(() => new Promise<{ default: React.ComponentType }>((resolve) => setTimeout(() => resolve({ default: () => ( -
+
🔥 Heavy Priority Selector Loaded!
This component contains: @@ -78,7 +78,7 @@ const HeavyLabelsSelector = React.lazy(() => new Promise<{ default: React.ComponentType }>((resolve) => setTimeout(() => resolve({ default: () => ( -
+
🏷️ Heavy Labels Selector Loaded!
This component contains: @@ -163,7 +163,7 @@ const AsanaStyleLazyDemo: React.FC = () => { 🎯 Asana-Style Lazy Loading Demo -
+
Performance Benefits:
  • Faster Initial Load: Only lightweight placeholders load initially
  • @@ -220,25 +220,25 @@ const AsanaStyleLazyDemo: React.FC = () => {
    {showComponents.assignee && ( - Loading assignee selector...
    }> + Loading assignee selector...
}> )} {showComponents.date && ( - Loading date picker...
}> + Loading date picker...
}> )} {showComponents.priority && ( - Loading priority selector...
}> + Loading priority selector...
}> )} {showComponents.labels && ( - Loading labels selector...
}> + Loading labels selector...
}> )} diff --git a/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx b/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx index 0e92a9f1..d07107b7 100644 --- a/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx +++ b/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx @@ -146,7 +146,7 @@ const AssigneeDropdownContent: React.FC = ({
+
{/* Search Input */} {section.searchable && (
@@ -546,7 +546,7 @@ const SearchFilter: React.FC<{ {!isExpanded ? (
@@ -646,7 +646,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ ? (isDarkMode ? 'bg-blue-600 text-white border-blue-500' : 'bg-blue-50 text-blue-800 border-blue-300 font-semibold') : `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}` } - hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 + hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'} `} aria-expanded={open} @@ -666,7 +666,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ {/* Dropdown Panel - matching FilterDropdown style */} {open && ( -
+
{/* Options List */}
{sortedFields.length === 0 ? ( diff --git a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.css b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.css new file mode 100644 index 00000000..57048d3e --- /dev/null +++ b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.css @@ -0,0 +1,253 @@ +/* Optimized Bulk Action Bar Styles */ +.optimized-bulk-action-bar { + /* GPU acceleration for smooth animations */ + will-change: transform, opacity; + transform: translateZ(0); + + /* Smooth backdrop blur with fallback */ + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + + /* Prevent layout shifts */ + contain: layout style paint; + + /* Optimize for animations */ + animation-fill-mode: both; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Entrance animation */ +@keyframes slideUpFadeIn { + from { + transform: translateX(-50%) translateY(20px); + opacity: 0; + visibility: hidden; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + visibility: visible; + } +} + +/* Exit animation */ +@keyframes slideDownFadeOut { + from { + transform: translateX(-50%) translateY(0); + opacity: 1; + visibility: visible; + } + to { + transform: translateX(-50%) translateY(20px); + opacity: 0; + visibility: hidden; + } +} + +.optimized-bulk-action-bar.entering { + animation: slideUpFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.optimized-bulk-action-bar.exiting { + animation: slideDownFadeOut 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Action button optimizations */ +.bulk-action-button { + /* GPU acceleration */ + will-change: transform, background-color; + transform: translateZ(0); + + /* Smooth hover transitions */ + transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); + + /* Prevent text selection */ + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + + /* Optimize for touch */ + touch-action: manipulation; +} + +.bulk-action-button:hover { + transform: translateZ(0) scale(1.05); +} + +.bulk-action-button:active { + transform: translateZ(0) scale(0.95); + transition-duration: 0.1s; +} + +/* Button loading state optimization */ +.bulk-action-button.loading { + pointer-events: none; + opacity: 0.7; +} + +/* Danger button styling */ +.bulk-action-button.danger:hover { + background-color: rgba(239, 68, 68, 0.1) !important; + color: #ef4444 !important; +} + +/* Dark mode optimizations */ +.dark .bulk-action-button:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.dark .bulk-action-button.danger:hover { + background-color: rgba(239, 68, 68, 0.1) !important; +} + +/* Divider styling for better visual separation */ +.bulk-action-divider { + opacity: 0.6; + transition: opacity 0.15s ease; +} + +/* Badge styling optimizations */ +.bulk-action-badge { + /* Smooth scaling animation */ + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); + will-change: transform; +} + +.bulk-action-badge.updating { + transform: scale(1.1); +} + +/* Tooltip optimizations */ +.ant-tooltip { + /* Faster tooltip animations */ + transition: opacity 0.1s ease !important; +} + +/* Dropdown optimizations */ +.bulk-action-dropdown { + /* Smooth dropdown animations */ + animation-duration: 0.2s !important; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important; +} + +/* Mobile responsive optimizations */ +@media (max-width: 768px) { + .optimized-bulk-action-bar { + /* Adjust for mobile */ + bottom: 20px; + left: 50%; + right: auto; + transform: translateX(-50%); + max-width: calc(100vw - 32px); + padding: 10px 16px; + gap: 2px; + } + + .bulk-action-button { + /* Smaller buttons on mobile */ + min-width: 28px; + height: 28px; + padding: 4px; + } + + /* Hide some actions on very small screens */ + .bulk-action-secondary { + display: none; + } +} + +@media (max-width: 480px) { + .optimized-bulk-action-bar { + /* Even more compact on small screens */ + bottom: 16px; + padding: 8px 12px; + gap: 1px; + } + + /* Show only essential actions */ + .bulk-action-tertiary { + display: none; + } +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .optimized-bulk-action-bar { + border: 2px solid currentColor; + background: var(--background-color); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .bulk-action-button { + border: 1px solid currentColor; + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .optimized-bulk-action-bar, + .bulk-action-button, + .bulk-action-badge { + transition: none !important; + animation: none !important; + will-change: auto !important; + } + + .bulk-action-button:hover { + transform: none; + } +} + +/* Focus management for accessibility */ +.bulk-action-button:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.dark .bulk-action-button:focus-visible { + outline-color: #3b82f6; +} + +/* Performance optimization classes */ +.bulk-action-gpu-accelerated { + transform: translateZ(0); + will-change: transform; +} + +.bulk-action-contain-layout { + contain: layout style paint; +} + +/* Loading spinner optimization */ +.bulk-action-loading-spinner { + animation: spin 1s linear infinite; + will-change: transform; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Smooth color transitions for theme switching */ +.bulk-action-theme-transition { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; +} + +/* Optimize for 60fps animations */ +.bulk-action-60fps { + animation-duration: 0.25s; + animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +/* Prevent layout thrashing during animations */ +.bulk-action-stable-layout { + contain: layout; + transform: translateZ(0); +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx new file mode 100644 index 00000000..fa14b4e1 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx @@ -0,0 +1,498 @@ +import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { + Button, + Typography, + Dropdown, + Popconfirm, + Tooltip, + Space, + Badge, + Divider +} from 'antd'; +import { + DeleteOutlined, + CloseOutlined, + MoreOutlined, + RetweetOutlined, + UserAddOutlined, + InboxOutlined, + TagsOutlined, + UsergroupAddOutlined, + CheckOutlined, + EditOutlined, + CopyOutlined, + ExportOutlined, + CalendarOutlined, + FlagOutlined, + BulbOutlined +} from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { RootState } from '@/app/store'; + +const { Text } = Typography; + +interface OptimizedBulkActionBarProps { + selectedTaskIds: string[]; + totalSelected: number; + projectId: string; + onClearSelection?: () => void; + onBulkStatusChange?: (statusId: string) => void; + onBulkPriorityChange?: (priorityId: string) => void; + onBulkPhaseChange?: (phaseId: string) => void; + onBulkAssignToMe?: () => void; + onBulkAssignMembers?: (memberIds: string[]) => void; + onBulkAddLabels?: (labelIds: string[]) => void; + onBulkArchive?: () => void; + onBulkDelete?: () => void; + onBulkDuplicate?: () => void; + onBulkExport?: () => void; + onBulkSetDueDate?: (date: string) => void; +} + +// Performance-optimized memoized action button component +const ActionButton = React.memo<{ + icon: React.ReactNode; + tooltip: string; + onClick?: () => void; + loading?: boolean; + danger?: boolean; + disabled?: boolean; + isDarkMode: boolean; + badge?: number; +}>(({ icon, tooltip, onClick, loading = false, danger = false, disabled = false, isDarkMode, badge }) => { + const buttonStyle = useMemo(() => ({ + background: 'transparent', + color: isDarkMode ? '#e5e7eb' : '#374151', + border: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '6px', + height: '32px', + width: '32px', + fontSize: '14px', + borderRadius: '6px', + transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.5 : 1, + ...(danger && { + color: '#ef4444', + }), + }), [isDarkMode, danger, disabled]); + + const hoverStyle = useMemo(() => ({ + backgroundColor: isDarkMode + ? (danger ? 'rgba(239, 68, 68, 0.1)' : 'rgba(255, 255, 255, 0.1)') + : (danger ? 'rgba(239, 68, 68, 0.1)' : 'rgba(0, 0, 0, 0.05)'), + transform: 'scale(1.05)', + }), [isDarkMode, danger]); + + const [isHovered, setIsHovered] = useState(false); + + const combinedStyle = useMemo(() => ({ + ...buttonStyle, + ...(isHovered && !disabled ? hoverStyle : {}), + }), [buttonStyle, hoverStyle, isHovered, disabled]); + + const ButtonComponent = ( +
+ ); +}); + +OptimizedBulkActionBarContent.displayName = 'OptimizedBulkActionBarContent'; + +// Portal wrapper for performance isolation +const OptimizedBulkActionBar: React.FC = React.memo((props) => { + // Only render portal if tasks are selected for better performance + if (props.totalSelected === 0) { + return null; + } + + return createPortal( + , + document.body, + 'optimized-bulk-action-bar' + ); +}); + +OptimizedBulkActionBar.displayName = 'OptimizedBulkActionBar'; + +export default OptimizedBulkActionBar; \ No newline at end of file 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 0c8baced..edb1b7e9 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -41,9 +41,38 @@ import { Task } from '@/types/task-management.types'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import TaskRow from './task-row'; // import BulkActionBar from './bulk-action-bar'; +import OptimizedBulkActionBar from './optimized-bulk-action-bar'; import VirtualizedTaskList from './virtualized-task-list'; import { AppDispatch } from '@/app/store'; import { shallowEqual } from 'react-redux'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; +import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { + evt_project_task_list_bulk_archive, + evt_project_task_list_bulk_assign_me, + evt_project_task_list_bulk_assign_members, + evt_project_task_list_bulk_change_phase, + evt_project_task_list_bulk_change_priority, + evt_project_task_list_bulk_change_status, + evt_project_task_list_bulk_delete, + evt_project_task_list_bulk_update_labels, +} from '@/shared/worklenz-analytics-events'; +import { + IBulkTasksLabelsRequest, + IBulkTasksPhaseChangeRequest, + IBulkTasksPriorityChangeRequest, + IBulkTasksStatusChangeRequest, +} from '@/types/tasks/bulk-action-bar.types'; +import { ITaskStatus } from '@/types/tasks/taskStatus.types'; +import { ITaskPriority } from '@/types/tasks/taskPriority.types'; +import { ITaskPhase } from '@/types/tasks/taskPhase.types'; +import { ITaskLabel } from '@/types/tasks/taskLabel.types'; +import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types'; +import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; +import alertService from '@/services/alerts/alertService'; +import logger from '@/utils/errorLogger'; +import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; import { performanceMonitor } from '@/utils/performance-monitor'; import debugPerformance from '@/utils/debug-performance'; @@ -53,6 +82,7 @@ import PerformanceAnalysis from './performance-analysis'; // Import drag and drop performance optimizations import './drag-drop-optimized.css'; +import './optimized-bulk-action-bar.css'; interface TaskListBoardProps { projectId: string; @@ -91,6 +121,7 @@ const throttle = void>(func: T, delay: number): T const TaskListBoard: React.FC = ({ projectId, className = '' }) => { const dispatch = useDispatch(); const { t } = useTranslation('task-management'); + const { trackMixpanelEvent } = useMixpanelTracking(); const [dragState, setDragState] = useState({ activeTask: null, activeGroupId: null, @@ -119,6 +150,14 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const selectedTaskIds = useSelector(selectSelectedTaskIds); 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); + const priorityList = useSelector((state: RootState) => state.priorityReducer.priorities); + const phaseList = useSelector((state: RootState) => state.phaseReducer.phaseList); + const labelsList = useSelector((state: RootState) => state.taskLabelsReducer.labels); + const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers); + const archived = useSelector((state: RootState) => state.taskReducer.archived); // Get theme from Redux store const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); @@ -401,6 +440,221 @@ const TaskListBoard: React.FC = ({ projectId, className = '' ); }, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]); + // Bulk action handlers - implementing real functionality from task-list-bulk-actions-bar + const handleClearSelection = useCallback(() => { + dispatch(deselectAll()); + }, [dispatch]); + + const handleBulkStatusChange = useCallback(async (statusId: string) => { + if (!statusId || !projectId) return; + try { + // Find the status object + const status = statusList.find(s => s.id === statusId); + if (!status || !status.id) return; + + const body: IBulkTasksStatusChangeRequest = { + tasks: selectedTaskIds, + status_id: status.id, + }; + + // Check task dependencies first + for (const taskId of selectedTaskIds) { + const canContinue = await checkTaskDependencyStatus(taskId, status.id); + if (!canContinue) { + if (selectedTaskIds.length > 1) { + alertService.warning( + 'Incomplete Dependencies!', + 'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.' + ); + } else { + alertService.error( + 'Task is not completed', + 'Please complete the task dependencies before proceeding' + ); + } + return; + } + } + + const res = await taskListBulkActionsApiService.changeStatus(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_change_status); + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error changing status:', error); + } + }, [selectedTaskIds, statusList, projectId, trackMixpanelEvent, dispatch]); + + const handleBulkPriorityChange = useCallback(async (priorityId: string) => { + if (!priorityId || !projectId) return; + try { + const priority = priorityList.find(p => p.id === priorityId); + if (!priority || !priority.id) return; + + const body: IBulkTasksPriorityChangeRequest = { + tasks: selectedTaskIds, + priority_id: priority.id, + }; + const res = await taskListBulkActionsApiService.changePriority(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_change_priority); + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error changing priority:', error); + } + }, [selectedTaskIds, priorityList, projectId, trackMixpanelEvent, dispatch]); + + const handleBulkPhaseChange = useCallback(async (phaseId: string) => { + if (!phaseId || !projectId) return; + try { + const phase = phaseList.find(p => p.id === phaseId); + if (!phase || !phase.id) return; + + const body: IBulkTasksPhaseChangeRequest = { + tasks: selectedTaskIds, + phase_id: phase.id, + }; + const res = await taskListBulkActionsApiService.changePhase(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_change_phase); + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error changing phase:', error); + } + }, [selectedTaskIds, phaseList, projectId, trackMixpanelEvent, dispatch]); + + const handleBulkAssignToMe = useCallback(async () => { + if (!projectId) return; + try { + const body = { + tasks: selectedTaskIds, + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.assignToMe(body); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_assign_me); + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error assigning to me:', error); + } + }, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch]); + + const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => { + if (!projectId || !members?.data) return; + try { + // Convert memberIds to member objects with proper type checking + const selectedMembers = members.data.filter(member => + member.id && memberIds.includes(member.id) + ); + + const body = { + tasks: selectedTaskIds, + project_id: projectId, + members: selectedMembers.map(member => ({ + id: member.id!, + name: member.name || '', + email: member.email || '', + avatar_url: member.avatar_url || '', + team_member_id: member.id!, + project_member_id: member.id!, + })), + }; + const res = await taskListBulkActionsApiService.assignTasks(body); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_assign_members); + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error assigning tasks:', error); + } + }, [selectedTaskIds, projectId, members, trackMixpanelEvent, dispatch]); + + const handleBulkAddLabels = useCallback(async (labelIds: string[]) => { + if (!projectId) return; + try { + // Convert labelIds to label objects with proper type checking + const selectedLabels = labelsList.filter(label => + label.id && labelIds.includes(label.id) + ); + + const body: IBulkTasksLabelsRequest = { + tasks: selectedTaskIds, + labels: selectedLabels, + text: null, + }; + const res = await taskListBulkActionsApiService.assignLabels(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_update_labels); + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + dispatch(fetchLabels()); + } + } catch (error) { + logger.error('Error updating labels:', error); + } + }, [selectedTaskIds, projectId, labelsList, trackMixpanelEvent, dispatch]); + + const handleBulkArchive = useCallback(async () => { + if (!projectId) return; + try { + const body = { + tasks: selectedTaskIds, + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.archiveTasks(body, archived); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_archive); + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error archiving tasks:', error); + } + }, [selectedTaskIds, projectId, archived, trackMixpanelEvent, dispatch]); + + const handleBulkDelete = useCallback(async () => { + if (!projectId) return; + try { + const body = { + tasks: selectedTaskIds, + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.deleteTasks(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_delete); + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error deleting tasks:', error); + } + }, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch]); + + // Additional handlers for new actions + const handleBulkDuplicate = useCallback(async () => { + // This would need to be implemented in the API service + console.log('Bulk duplicate not yet implemented in API:', selectedTaskIds); + }, [selectedTaskIds]); + + const handleBulkExport = useCallback(async () => { + // This would need to be implemented in the API service + console.log('Bulk export not yet implemented in API:', selectedTaskIds); + }, [selectedTaskIds]); + + const handleBulkSetDueDate = useCallback(async (date: string) => { + // This would need to be implemented in the API service + console.log('Bulk set due date not yet implemented in API:', date, selectedTaskIds); + }, [selectedTaskIds]); + // Cleanup effect useEffect(() => { return () => { @@ -525,6 +779,25 @@ const TaskListBoard: React.FC = ({ projectId, className = '' + {/* Optimized Bulk Action Bar */} + +