feat: implement team activity stream (#5565)
* wip * feat: implement team activity page * feat: add pagination * fix: add max height * Team activity updates * Remove invalid activityes * Team activity page * fix: team roadmap versions not working * Add team activity items --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>pull/5578/head
parent
8ceedadd22
commit
f2a2ac9ec8
22 changed files with 2891 additions and 2270 deletions
@ -0,0 +1,195 @@ |
||||
import { useState } from 'react'; |
||||
import { getRelativeTimeString } from '../../lib/date'; |
||||
import type { TeamStreamActivity } from './TeamActivityPage'; |
||||
import { ChevronsDown, ChevronsUp } from 'lucide-react'; |
||||
|
||||
type TeamActivityItemProps = { |
||||
onTopicClick?: (activity: TeamStreamActivity) => void; |
||||
user: { |
||||
activities: TeamStreamActivity[]; |
||||
_id: string; |
||||
name: string; |
||||
avatar?: string | undefined; |
||||
username?: string | undefined; |
||||
}; |
||||
}; |
||||
|
||||
export function TeamActivityItem(props: TeamActivityItemProps) { |
||||
const { user, onTopicClick } = props; |
||||
const { activities } = user; |
||||
|
||||
const [showAll, setShowAll] = useState(false); |
||||
|
||||
const resourceLink = (activity: TeamStreamActivity) => { |
||||
const { |
||||
resourceId, |
||||
resourceTitle, |
||||
resourceType, |
||||
isCustomResource, |
||||
resourceSlug, |
||||
} = activity; |
||||
|
||||
const resourceUrl = |
||||
resourceType === 'question' |
||||
? `/questions/${resourceId}` |
||||
: resourceType === 'best-practice' |
||||
? `/best-practices/${resourceId}` |
||||
: isCustomResource && resourceType === 'roadmap' |
||||
? `/r/${resourceSlug}` |
||||
: `/${resourceId}`; |
||||
|
||||
return ( |
||||
<a |
||||
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black" |
||||
target="_blank" |
||||
href={resourceUrl} |
||||
> |
||||
{resourceTitle} |
||||
</a> |
||||
); |
||||
}; |
||||
|
||||
const timeAgo = (date: string | Date) => ( |
||||
<span className="ml-1 text-xs text-gray-400"> |
||||
{getRelativeTimeString(new Date(date).toISOString())} |
||||
</span> |
||||
); |
||||
const userAvatar = user.avatar |
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}` |
||||
: '/images/default-avatar.png'; |
||||
|
||||
const username = ( |
||||
<> |
||||
<img |
||||
className="mr-1 inline-block h-5 w-5 rounded-full" |
||||
src={userAvatar} |
||||
alt={user.name} |
||||
/> |
||||
<span className="font-medium">{user?.name || 'Unknown'}</span>{' '} |
||||
</> |
||||
); |
||||
|
||||
if (activities.length === 1) { |
||||
const activity = activities[0]; |
||||
const { actionType, topicIds } = activity; |
||||
const topicCount = topicIds?.length || 0; |
||||
|
||||
return ( |
||||
<li |
||||
key={user._id} |
||||
className="flex items-center flex-wrap gap-1 rounded-md border px-2 py-2.5 text-sm" |
||||
> |
||||
{actionType === 'in_progress' && ( |
||||
<> |
||||
{username} started{' '} |
||||
<button |
||||
className="font-medium underline underline-offset-2 hover:text-black" |
||||
onClick={() => onTopicClick?.(activity)} |
||||
> |
||||
{topicCount} topic{topicCount > 1 ? 's' : ''} |
||||
</button>{' '} |
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)} |
||||
</> |
||||
)} |
||||
|
||||
{actionType === 'done' && ( |
||||
<> |
||||
{username} completed{' '} |
||||
<button |
||||
className="font-medium underline underline-offset-2 hover:text-black" |
||||
onClick={() => onTopicClick?.(activity)} |
||||
> |
||||
{topicCount} topic{topicCount > 1 ? 's' : ''} |
||||
</button>{' '} |
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)} |
||||
</> |
||||
)} |
||||
{actionType === 'answered' && ( |
||||
<> |
||||
{username} answered {topicCount} question |
||||
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '} |
||||
{timeAgo(activity.updatedAt)} |
||||
</> |
||||
)} |
||||
</li> |
||||
); |
||||
} |
||||
|
||||
const uniqueResourcesCount = new Set( |
||||
activities.map((activity) => activity.resourceId), |
||||
).size; |
||||
|
||||
const activityLimit = showAll ? activities.length : 5; |
||||
|
||||
return ( |
||||
<li key={user._id} className="rounded-md border overflow-hidden"> |
||||
<h3 className="flex flex-wrap items-center gap-1 bg-gray-100 px-2 py-2.5 text-sm"> |
||||
{username} has {activities.length} updates in {uniqueResourcesCount}{' '} |
||||
resources |
||||
</h3> |
||||
<div className="py-3"> |
||||
<ul className="flex flex-col gap-2 ml-2 sm:ml-[36px]"> |
||||
{activities.slice(0, activityLimit).map((activity) => { |
||||
const { actionType, topicIds } = activity; |
||||
const topicCount = topicIds?.length || 0; |
||||
|
||||
return ( |
||||
<li key={activity._id} className="text-sm text-gray-600"> |
||||
{actionType === 'in_progress' && ( |
||||
<> |
||||
Started{' '} |
||||
<button |
||||
className="font-medium underline underline-offset-2 hover:text-black" |
||||
onClick={() => onTopicClick?.(activity)} |
||||
> |
||||
{topicCount} topic{topicCount > 1 ? 's' : ''} |
||||
</button>{' '} |
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)} |
||||
</> |
||||
)} |
||||
{actionType === 'done' && ( |
||||
<> |
||||
Completed{' '} |
||||
<button |
||||
className="font-medium underline underline-offset-2 hover:text-black" |
||||
onClick={() => onTopicClick?.(activity)} |
||||
> |
||||
{topicCount} topic{topicCount > 1 ? 's' : ''} |
||||
</button>{' '} |
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)} |
||||
</> |
||||
)} |
||||
{actionType === 'answered' && ( |
||||
<> |
||||
Answered {topicCount} question |
||||
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '} |
||||
{timeAgo(activity.updatedAt)} |
||||
</> |
||||
)} |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
|
||||
{activities.length > 5 && ( |
||||
<button |
||||
className="mt-3 flex items-center gap-2 rounded-md border border-gray-300 p-1 text-xs uppercase tracking-wide text-gray-600 transition-colors hover:border-black hover:bg-black hover:text-white" |
||||
onClick={() => setShowAll(!showAll)} |
||||
> |
||||
{showAll ? ( |
||||
<> |
||||
<ChevronsUp size={14} /> |
||||
Show less |
||||
</> |
||||
) : ( |
||||
<> |
||||
<ChevronsDown size={14} /> |
||||
Show more |
||||
</> |
||||
)} |
||||
</button> |
||||
)} |
||||
</div> |
||||
</li> |
||||
); |
||||
} |
@ -0,0 +1,189 @@ |
||||
import { useEffect, useState, useMemo } from 'react'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { getUrlParams } from '../../lib/browser'; |
||||
import { httpGet } from '../../lib/http'; |
||||
import type { ResourceType } from '../../lib/resource-progress'; |
||||
import type { AllowedActivityActionType } from '../Activity/ActivityStream'; |
||||
import { pageProgressMessage } from '../../stores/page'; |
||||
import { TeamActivityItem } from './TeamActivityItem'; |
||||
import { TeamActivityTopicsModal } from './TeamActivityTopicsModal'; |
||||
import { TeamEmptyStream } from './TeamEmptyStream'; |
||||
import { Pagination } from '../Pagination/Pagination'; |
||||
|
||||
export type TeamStreamActivity = { |
||||
_id?: string; |
||||
resourceType: ResourceType | 'question'; |
||||
resourceId: string; |
||||
resourceTitle: string; |
||||
resourceSlug?: string; |
||||
isCustomResource?: boolean; |
||||
actionType: AllowedActivityActionType; |
||||
topicIds?: string[]; |
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
}; |
||||
|
||||
export interface TeamActivityStreamDocument { |
||||
_id?: string; |
||||
teamId: string; |
||||
userId: string; |
||||
activity: TeamStreamActivity[]; |
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
} |
||||
|
||||
type GetTeamActivityResponse = { |
||||
data: { |
||||
users: { |
||||
_id: string; |
||||
name: string; |
||||
avatar?: string; |
||||
username?: string; |
||||
}[]; |
||||
activities: TeamActivityStreamDocument[]; |
||||
}; |
||||
totalCount: number; |
||||
totalPages: number; |
||||
currPage: number; |
||||
perPage: number; |
||||
}; |
||||
|
||||
export function TeamActivityPage() { |
||||
const { t: teamId } = getUrlParams(); |
||||
|
||||
const toast = useToast(); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [selectedActivity, setSelectedActivity] = |
||||
useState<TeamStreamActivity | null>(null); |
||||
const [teamActivities, setTeamActivities] = useState<GetTeamActivityResponse>( |
||||
{ |
||||
data: { |
||||
users: [], |
||||
activities: [], |
||||
}, |
||||
totalCount: 0, |
||||
totalPages: 0, |
||||
currPage: 1, |
||||
perPage: 21, |
||||
}, |
||||
); |
||||
const [currPage, setCurrPage] = useState(1); |
||||
|
||||
const getTeamProgress = async (currPage: number = 1) => { |
||||
const { response, error } = await httpGet<GetTeamActivityResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-activity/${teamId}`, |
||||
{ |
||||
currPage, |
||||
}, |
||||
); |
||||
if (error || !response) { |
||||
toast.error(error?.message || 'Failed to get team activity'); |
||||
return; |
||||
} |
||||
|
||||
setTeamActivities(response); |
||||
setCurrPage(response.currPage); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
if (!teamId) { |
||||
return; |
||||
} |
||||
|
||||
getTeamProgress().then(() => { |
||||
pageProgressMessage.set(''); |
||||
setIsLoading(false); |
||||
}); |
||||
}, [teamId]); |
||||
|
||||
const { users, activities } = teamActivities?.data; |
||||
const usersWithActivities = useMemo(() => { |
||||
const validActivities = activities.filter((activity) => { |
||||
return ( |
||||
activity.activity.length > 0 && |
||||
activity.activity.some((t) => (t?.topicIds?.length || 0) > 0) |
||||
); |
||||
}); |
||||
|
||||
return users |
||||
.map((user) => { |
||||
const userActivities = validActivities |
||||
.filter((activity) => activity.userId === user._id) |
||||
.flatMap((activity) => activity.activity) |
||||
.filter((activity) => (activity?.topicIds?.length || 0) > 0) |
||||
.sort((a, b) => { |
||||
return ( |
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() |
||||
); |
||||
}); |
||||
|
||||
return { |
||||
...user, |
||||
activities: userActivities, |
||||
}; |
||||
}) |
||||
.filter((user) => user.activities.length > 0) |
||||
.sort((a, b) => { |
||||
return ( |
||||
new Date(b.activities[0].updatedAt).getTime() - |
||||
new Date(a.activities[0].updatedAt).getTime() |
||||
); |
||||
}); |
||||
}, [users, activities]); |
||||
|
||||
if (!teamId) { |
||||
window.location.href = '/'; |
||||
return; |
||||
} |
||||
|
||||
if (isLoading) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
{selectedActivity && ( |
||||
<TeamActivityTopicsModal |
||||
activity={selectedActivity} |
||||
onClose={() => setSelectedActivity(null)} |
||||
/> |
||||
)} |
||||
|
||||
{usersWithActivities.length > 0 ? ( |
||||
<> |
||||
<h3 className="mb-4 flex w-full items-center justify-between text-xs uppercase text-gray-400"> |
||||
Team Activity |
||||
</h3> |
||||
<ul className="mb-4 mt-2 flex flex-col gap-3"> |
||||
{usersWithActivities.map((user) => { |
||||
return ( |
||||
<TeamActivityItem |
||||
key={user._id} |
||||
user={user} |
||||
onTopicClick={setSelectedActivity} |
||||
/> |
||||
); |
||||
})} |
||||
</ul> |
||||
|
||||
<Pagination |
||||
currPage={currPage} |
||||
totalPages={teamActivities.totalPages} |
||||
totalCount={teamActivities.totalCount} |
||||
perPage={teamActivities.perPage} |
||||
onPageChange={(page) => { |
||||
setCurrPage(page); |
||||
pageProgressMessage.set('Loading...'); |
||||
getTeamProgress(page).finally(() => { |
||||
pageProgressMessage.set(''); |
||||
}); |
||||
}} |
||||
/> |
||||
</> |
||||
) : ( |
||||
<TeamEmptyStream teamId={teamId} /> |
||||
)} |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,128 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { httpPost } from '../../lib/http'; |
||||
import { Modal } from '../Modal.tsx'; |
||||
import { ModalLoader } from '../UserProgress/ModalLoader.tsx'; |
||||
import { ArrowUpRight, BookOpen, Check } from 'lucide-react'; |
||||
import type { TeamStreamActivity } from './TeamActivityPage.tsx'; |
||||
|
||||
type TeamActivityTopicsModalProps = { |
||||
activity: TeamStreamActivity; |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) { |
||||
const { activity, onClose } = props; |
||||
const { |
||||
resourceId, |
||||
resourceType, |
||||
isCustomResource, |
||||
topicIds = [], |
||||
actionType, |
||||
} = activity; |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({}); |
||||
const [error, setError] = useState<string | null>(null); |
||||
|
||||
const loadTopicTitles = async () => { |
||||
setIsLoading(true); |
||||
setError(null); |
||||
|
||||
const { response, error } = await httpPost( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`, |
||||
{ |
||||
resourceId, |
||||
resourceType, |
||||
isCustomResource, |
||||
topicIds, |
||||
}, |
||||
); |
||||
|
||||
if (error || !response) { |
||||
setError(error?.message || 'Failed to load topic titles'); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
setTopicTitles(response); |
||||
setIsLoading(false); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
loadTopicTitles().finally(() => { |
||||
setIsLoading(false); |
||||
}); |
||||
}, []); |
||||
|
||||
if (isLoading || error) { |
||||
return ( |
||||
<ModalLoader |
||||
error={error!} |
||||
text={'Loading topics..'} |
||||
isLoading={isLoading} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
let pageUrl = ''; |
||||
if (resourceType === 'roadmap') { |
||||
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`; |
||||
} else if (resourceType === 'best-practice') { |
||||
pageUrl = `/best-practices/${resourceId}`; |
||||
} else { |
||||
pageUrl = `/questions/${resourceId}`; |
||||
} |
||||
|
||||
return ( |
||||
<Modal |
||||
onClose={() => { |
||||
onClose(); |
||||
setError(null); |
||||
setIsLoading(false); |
||||
}} |
||||
> |
||||
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}> |
||||
<span className="mb-2 flex items-center justify-between text-lg font-semibold capitalize"> |
||||
<span className="flex items-center gap-2"> |
||||
{actionType.replace('_', ' ')} |
||||
</span> |
||||
<a |
||||
href={pageUrl} |
||||
target="_blank" |
||||
className="flex items-center gap-1 rounded-md border border-transparent py-0.5 pl-2 pr-1 text-sm font-normal text-gray-400 transition-colors hover:border-black hover:bg-black hover:text-white" |
||||
> |
||||
Visit Page{' '} |
||||
<ArrowUpRight |
||||
size={16} |
||||
strokeWidth={2} |
||||
className="relative top-px" |
||||
/> |
||||
</a> |
||||
</span> |
||||
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto max-md:max-h-full"> |
||||
{topicIds.map((topicId) => { |
||||
const topicTitle = topicTitles[topicId] || 'Unknown Topic'; |
||||
|
||||
const ActivityIcon = |
||||
actionType === 'done' |
||||
? Check |
||||
: actionType === 'in_progress' |
||||
? BookOpen |
||||
: Check; |
||||
|
||||
return ( |
||||
<li key={topicId} className="flex items-start gap-2"> |
||||
<ActivityIcon |
||||
strokeWidth={3} |
||||
className="relative top-[4px] text-green-500" |
||||
size={16} |
||||
/> |
||||
{topicTitle} |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
</div> |
||||
</Modal> |
||||
); |
||||
} |
@ -0,0 +1,23 @@ |
||||
import { Activity, List, ListTodo } from 'lucide-react'; |
||||
|
||||
type TeamActivityItemProps = { |
||||
teamId: string; |
||||
}; |
||||
|
||||
export function TeamEmptyStream(props: TeamActivityItemProps) { |
||||
const { teamId } = props; |
||||
|
||||
return ( |
||||
<div className="rounded-md"> |
||||
<div className="flex flex-col items-center p-7 text-center sm:p-14"> |
||||
<ListTodo className="mb-4 h-[60px] w-[60px] opacity-10 sm:h-[60px] sm:w-[60px]" /> |
||||
|
||||
<h2 className="text-lg font-semibold sm:text-lg">No Activity</h2> |
||||
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base"> |
||||
Team activity will appear here once members start tracking their |
||||
progress. |
||||
</p> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,15 @@ |
||||
--- |
||||
import { TeamSidebar } from '../../components/TeamSidebar'; |
||||
import { TeamActivityPage } from '../../components/TeamActivity/TeamActivityPage'; |
||||
import AccountLayout from '../../layouts/AccountLayout.astro'; |
||||
--- |
||||
|
||||
<AccountLayout |
||||
title='Team Activities' |
||||
noIndex={true} |
||||
initialLoadingMessage='Loading activity' |
||||
> |
||||
<TeamSidebar activePageId='activity' client:load> |
||||
<TeamActivityPage client:only='react' /> |
||||
</TeamSidebar> |
||||
</AccountLayout> |
Loading…
Reference in new issue