From e3dcb49efa489ee6d98f35db870f54890c11a939 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 8 Dec 2025 16:14:10 +0200 Subject: [PATCH] Fix bug 661 (#682) * Limit project card text length * Fix projects issues * fixup! Fix projects issues --- backend/routes/auth.js | 1 + backend/routes/shares.js | 12 +- .../tests/integration/project-sharing.test.js | 59 ++ frontend/components/Project/ProjectItem.tsx | 636 ++++++++++++------ frontend/components/Shared/Tooltip.tsx | 75 +++ frontend/styles/tailwind.css | 21 + frontend/utils/sharesService.ts | 6 +- 7 files changed, 596 insertions(+), 214 deletions(-) create mode 100644 frontend/components/Shared/Tooltip.tsx diff --git a/backend/routes/auth.js b/backend/routes/auth.js index c39b133..fb2b298 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -210,6 +210,7 @@ router.post('/login', authLimiter, async (req, res) => { language: user.language, appearance: user.appearance, timezone: user.timezone, + avatar_image: user.avatar_image, is_admin: admin, }, }); diff --git a/backend/routes/shares.js b/backend/routes/shares.js index a077453..a6fb9b8 100644 --- a/backend/routes/shares.js +++ b/backend/routes/shares.js @@ -227,7 +227,7 @@ router.get('/shares', async (req, res) => { if (resource) { const owner = await User.findByPk(resource.user_id, { - attributes: ['id', 'email'], + attributes: ['id', 'email', 'avatar_image'], }); if (owner) { ownerInfo = { @@ -235,6 +235,7 @@ router.get('/shares', async (req, res) => { access_level: 'owner', created_at: null, email: owner.email, + avatar_image: owner.avatar_image, is_owner: true, }; } @@ -245,7 +246,7 @@ router.get('/shares', async (req, res) => { attributes: ['user_id', 'access_level', 'created_at'], raw: true, }); - // Attach emails for display + // Attach emails and avatar images for display const userIds = Array.from(new Set(rows.map((r) => r.user_id))).filter( Boolean ); @@ -253,17 +254,18 @@ router.get('/shares', async (req, res) => { if (userIds.length) { const users = await User.findAll({ where: { id: userIds }, - attributes: ['id', 'email'], + attributes: ['id', 'email', 'avatar_image'], raw: true, }); usersById = users.reduce((acc, u) => { - acc[u.id] = u.email; + acc[u.id] = { email: u.email, avatar_image: u.avatar_image }; return acc; }, {}); } const withEmails = rows.map((r) => ({ ...r, - email: usersById[r.user_id] || null, + email: usersById[r.user_id]?.email || null, + avatar_image: usersById[r.user_id]?.avatar_image || null, is_owner: false, })); diff --git a/backend/tests/integration/project-sharing.test.js b/backend/tests/integration/project-sharing.test.js index 71f4838..558af58 100644 --- a/backend/tests/integration/project-sharing.test.js +++ b/backend/tests/integration/project-sharing.test.js @@ -61,6 +61,65 @@ describe('Project Sharing Integration Tests', () => { await sequelize.close(); }); + describe('Project visibility', () => { + test('shared user should see shared project in /api/projects', async () => { + const response = await sharedUserAgent.get('/api/projects'); + + expect(response.status).toBe(200); + expect(response.body.projects).toBeDefined(); + + const sharedProjectUids = response.body.projects.map((p) => p.uid); + + expect(sharedProjectUids).toContain(project.uid); + }); + + test('shared project should have is_shared flag and share_count in /api/projects', async () => { + const response = await ownerAgent.get('/api/projects'); + + expect(response.status).toBe(200); + expect(response.body.projects).toBeDefined(); + + const sharedProject = response.body.projects.find( + (p) => p.uid === project.uid + ); + + expect(sharedProject).toBeDefined(); + expect(sharedProject.is_shared).toBe(true); + expect(sharedProject.share_count).toBeGreaterThan(0); + }); + + test('owner can fetch share list with emails via /api/shares', async () => { + const response = await ownerAgent.get( + `/api/shares?resource_type=project&resource_uid=${project.uid}` + ); + + expect(response.status).toBe(200); + expect(response.body.shares).toBeDefined(); + expect(Array.isArray(response.body.shares)).toBe(true); + + // Should include owner + const ownerShare = response.body.shares.find( + (s) => s.is_owner === true + ); + expect(ownerShare).toBeDefined(); + expect(ownerShare.email).toBe(ownerUser.email); + expect(ownerShare.access_level).toBe('owner'); + // Avatar field should exist (may be null if no avatar set) + expect(ownerShare).toHaveProperty('avatar_image'); + + // Should include shared user + const sharedUserShare = response.body.shares.find( + (s) => s.email === sharedUser.email + ); + expect(sharedUserShare).toBeDefined(); + expect(sharedUserShare.is_owner).toBe(false); + expect(sharedUserShare.email).toBe(sharedUser.email); + expect(['ro', 'rw']).toContain(sharedUserShare.access_level); + // Avatar field should exist (may be null if no avatar set) + expect(sharedUserShare).toHaveProperty('avatar_image'); + }); + }); + describe('Issue 1: Task and Note Visibility in Shared Projects', () => { test('shared user should see tasks in shared project', async () => { // Owner creates a task in the shared project diff --git a/frontend/components/Project/ProjectItem.tsx b/frontend/components/Project/ProjectItem.tsx index 2203533..36f1b3b 100644 --- a/frontend/components/Project/ProjectItem.tsx +++ b/frontend/components/Project/ProjectItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { @@ -10,11 +10,16 @@ import { StopIcon, CheckCircleIcon, ShareIcon, + ExclamationTriangleIcon, } from '@heroicons/react/24/outline'; import { Project, ProjectState } from '../../entities/Project'; import { useTranslation } from 'react-i18next'; import { useToast } from '../Shared/ToastContext'; import { getCurrentUser } from '../../utils/userUtils'; +import Tooltip from '../Shared/Tooltip'; +import { differenceInCalendarDays } from 'date-fns'; +import { listShares, ListSharesResponseRow } from '../../utils/sharesService'; +import { getApiPath } from '../../config/paths'; interface ProjectItemProps { project: Project; @@ -78,6 +83,22 @@ const getStateLabel = (state: ProjectState | undefined, t: any): string => { } }; +const projectShareCache = new Map(); +const failedShareCache = new Set(); +const MAX_SHARE_AVATARS = 4; + +const getShareInitials = (value?: string | null) => { + if (!value) return '?'; + const cleaned = value + .replace(/@.*/, '') + .split(/[\s._-]+/) + .filter((part) => part.length > 0) + .map((part) => part[0].toUpperCase()) + .join(''); + return cleaned.substring(0, 2) || '?'; +}; + + const ProjectItem: React.FC = ({ project, viewMode, @@ -94,6 +115,151 @@ const ProjectItem: React.FC = ({ const currentUser = getCurrentUser(); const isOwner = currentUser && (project as any).user_uid === currentUser.uid; + const descriptionText = project.description?.trim(); + const listTitleClasses = + 'block w-full text-md font-semibold text-gray-900 dark:text-gray-100 hover:text-gray-700 dark:hover:text-gray-200 transition-colors truncate'; + const listTitleLink = ( + + {project.name} + + ); + const [sharedUsers, setSharedUsers] = useState< + ListSharesResponseRow[] | null + >(() => { + if (project.uid && projectShareCache.has(project.uid)) { + return projectShareCache.get(project.uid) || null; + } + return null; + }); + + useEffect(() => { + if (project.uid && projectShareCache.has(project.uid)) { + setSharedUsers(projectShareCache.get(project.uid) || null); + } else if (!project.is_shared) { + setSharedUsers(null); + } + }, [project.uid, project.is_shared]); + + useEffect(() => { + if ( + !project.is_shared || + !project.uid || + projectShareCache.has(project.uid) || + failedShareCache.has(project.uid) + ) { + return; + } + + let isMounted = true; + listShares('project', project.uid) + .then((rows) => { + if (!isMounted) return; + const filtered = rows.filter((row) => !row.is_owner); + projectShareCache.set(project.uid as string, filtered); + setSharedUsers(filtered); + }) + .catch((error) => { + if (!isMounted) return; + failedShareCache.add(project.uid as string); + console.error( + 'Failed to fetch shares for project', + project.uid, + error + ); + }); + + return () => { + isMounted = false; + }; + }, [project.uid, project.is_shared]); + + const dueInfo = useMemo(() => { + if (!project.due_date_at) { + return { + text: t('projectItem.noDueDate', 'No due date'), + isOverdue: false, + }; + } + const dueDate = new Date(project.due_date_at); + if (Number.isNaN(dueDate.getTime())) { + return { + text: t('projectItem.noDueDate', 'No due date'), + isOverdue: false, + }; + } + const diff = differenceInCalendarDays(dueDate, new Date()); + if (diff === 0) { + return { + text: t('projectItem.dueToday', 'Due today'), + isOverdue: false, + }; + } + + const unit = + Math.abs(diff) === 1 + ? t('projectItem.day', 'day') + : t('projectItem.days', 'days'); + + if (diff > 0) { + return { + text: t('projectItem.dueIn', 'Due in {{count}} {{unit}}', { + count: diff, + unit, + }), + isOverdue: false, + }; + } + + return { + text: t( + 'projectItem.overdue', + 'Overdue {{count}} {{unit}} ago', + { + count: Math.abs(diff), + unit, + } + ), + isOverdue: true, + }; + }, [project.due_date_at, t]); + + const shareAvatars = useMemo(() => { + if (!project.is_shared) { + return { + avatars: [] as ListSharesResponseRow[], + remaining: 0, + }; + } + + const knownShares = sharedUsers ?? []; + const avatars = knownShares.slice(0, MAX_SHARE_AVATARS); + const totalCount = + (sharedUsers?.length ?? + project.share_count ?? + avatars.length) || 0; + const remaining = Math.max(0, totalCount - avatars.length); + + return { avatars, remaining }; + }, [project.is_shared, project.share_count, sharedUsers]); + + const getShareDisplayName = (email?: string | null) => { + if (!email) { + return t('projectItem.sharedUser', 'Shared user'); + } + const [namePart] = email.split('@'); + if (!namePart) return email; + return namePart.charAt(0).toUpperCase() + namePart.slice(1); + }; return (
= ({ : 'bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-row items-center p-4 group' }`} style={{ - minHeight: viewMode === 'cards' ? '250px' : 'auto', - maxHeight: viewMode === 'cards' ? '250px' : 'auto', + minHeight: viewMode === 'cards' ? '260px' : 'auto', + maxHeight: viewMode === 'cards' ? '260px' : 'auto', }} > {viewMode === 'cards' && ( - -
+ - {project.image_url ? ( - {project.name} - ) : ( - - {getProjectInitials(project.name)} - - )} - - {/* Icons in top right corner of image area */} -
- {/* Shared project icon */} - {project.is_shared && ( - + {project.image_url ? ( + {project.name} + ) : ( +
)} - - {/* State icon */} - {(() => { - const { icon: StateIcon } = getStateIcon( - project.state - ); - return ( -
+
+ {project.is_shared && ( + - ); - })()} + )} + {(() => { + const { icon: StateIcon } = getStateIcon( + project.state + ); + return ( + + ); + })()} +
+ + {project.id !== undefined && + activeDropdown === project.id && ( +
+ + {isOwner && ( + + )} + +
+ )} +
+
+
+ +
+ )} + + {viewMode === 'cards' && ( +
+
+ +

+ {project.name} +

+ {descriptionText && ( +

+ {descriptionText} +

+ )} +
+ } + className="w-full" + > + + {project.name} + + +
+
+
+
+
+
+ + {(project as any).task_status + ? `${(project as any).task_status.done}/${(project as any).task_status.total}` + : '0/0'} + +
+
+
+ {dueInfo.isOverdue ? ( + + + {dueInfo.text} + + ) : ( + {dueInfo.text} + )} +
+
+ {project.is_shared && ( +
+ <> + {shareAvatars.avatars.map((share) => ( + + {share.avatar_image ? ( + {getShareDisplayName(share.email)} + ) : ( + + {getShareInitials(share.email)} + + )} + + ))} + {shareAvatars.remaining > 0 && ( + + + +{shareAvatars.remaining} + + + )} + +
+ )} +
- + )} {viewMode === 'list' && ( @@ -194,130 +563,12 @@ const ProjectItem: React.FC = ({ )} -
-
- - {project.name} - - {viewMode === 'cards' && project.description && ( -

- {project.description} -

- )} -
-
- {viewMode === 'cards' ? ( - <> - - - {project.id !== undefined && - activeDropdown === project.id && ( -
- - {isOwner && ( - - )} - -
- )} - - ) : ( + {viewMode === 'list' && ( +
+
+ {listTitleLink} +
+
- )} -
-
- - {viewMode === 'cards' && ( -
-
-
-
-
- - {(project as any).task_status - ? `${(project as any).task_status.done}/${(project as any).task_status.total}` - : '0/0'} -
)} diff --git a/frontend/components/Shared/Tooltip.tsx b/frontend/components/Shared/Tooltip.tsx new file mode 100644 index 0000000..125628f --- /dev/null +++ b/frontend/components/Shared/Tooltip.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useRef, useState } from 'react'; + +interface TooltipProps { + content: React.ReactNode; + children: React.ReactNode; + className?: string; + position?: 'top' | 'bottom'; +} + +const Tooltip: React.FC = ({ + content, + children, + className = '', + position = 'top', +}) => { + const [isVisible, setIsVisible] = useState(false); + const timerRef = useRef | null>(null); + const delay = 300; + + if (!content) { + return {children}; + } + + const positionClasses = + position === 'top' + ? 'bottom-full mb-2 origin-bottom' + : 'top-full mt-2 origin-top'; + + const visibilityClasses = isVisible + ? 'opacity-100 scale-100' + : 'pointer-events-none opacity-0 scale-95'; + + const showWithDelay = () => { + timerRef.current = setTimeout(() => { + setIsVisible(true); + }, delay); + }; + + const hideTooltip = () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setIsVisible(false); + }; + + useEffect( + () => () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }, + [] + ); + + return ( + + {children} + + {content} + + + ); +}; + +export default Tooltip; diff --git a/frontend/styles/tailwind.css b/frontend/styles/tailwind.css index 15297ed..a3e0e2c 100644 --- a/frontend/styles/tailwind.css +++ b/frontend/styles/tailwind.css @@ -170,3 +170,24 @@ select:focus { display: none; /* Chrome/Safari */ } +@layer utilities { + .line-clamp-1, + .line-clamp-2, + .line-clamp-3 { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .line-clamp-1 { + -webkit-line-clamp: 1; + } + + .line-clamp-2 { + -webkit-line-clamp: 2; + } + + .line-clamp-3 { + -webkit-line-clamp: 3; + } +} diff --git a/frontend/utils/sharesService.ts b/frontend/utils/sharesService.ts index 9c06ad1..9f36bf9 100644 --- a/frontend/utils/sharesService.ts +++ b/frontend/utils/sharesService.ts @@ -33,9 +33,11 @@ export async function grantShare(req: ShareGrantRequest): Promise { export interface ListSharesResponseRow { user_id: number; - access_level: AccessLevel; - created_at: string; + access_level: AccessLevel | 'owner'; + created_at: string | null; email?: string | null; + avatar_image?: string | null; + is_owner?: boolean; } export async function listShares(