Skip to content

Commit

Permalink
feat(orgAdmin): add sortable memberCount and lastMetAt columns in…
Browse files Browse the repository at this point in the history
… OrgTeams view (#10846)
  • Loading branch information
tianrunhe authored Feb 12, 2025
1 parent 183480e commit c04bb94
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 27 deletions.
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import graphql from 'babel-plugin-relay/macro'
import {useState} from 'react'
import {useFragment} from 'react-relay'
import {OrgTeams_organization$key} from '../../../../__generated__/OrgTeams_organization.graphql'
import AddTeamDialogRoot from '../../../../components/AddTeamDialogRoot'
import {Button} from '../../../../ui/Button/Button'
import {useDialogState} from '../../../../ui/Dialog/useDialogState'
import plural from '../../../../utils/plural'
import OrgTeamsRow from './OrgTeamsRow'
import TeaserOrgTeamsRow from './TeaserOrgTeamsRow'

type Props = {
organizationRef: OrgTeams_organization$key
}

type SortField = 'name' | 'memberCount' | 'lastMetAt'
type SortDirection = 'asc' | 'desc'

const OrgTeams = (props: Props) => {
const {organizationRef} = props
const organization = useFragment(
Expand All @@ -22,6 +25,11 @@ const OrgTeams = (props: Props) => {
hasPublicTeamsFlag: featureFlag(featureName: "publicTeams")
allTeams {
id
name
lastMetAt
teamMembers {
id
}
...OrgTeamsRow_team
}
viewerTeams {
Expand All @@ -39,10 +47,39 @@ const OrgTeams = (props: Props) => {
isOpen: isAddTeamDialogOpened
} = useDialogState()

const [sortBy, setSortBy] = useState<SortField>('lastMetAt')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')

const {allTeams, tier, viewerTeams, allTeamsCount, hasPublicTeamsFlag} = organization
const showAllTeams = allTeams.length === allTeamsCount || hasPublicTeamsFlag
const viewerTeamCount = viewerTeams.length

const handleSort = (field: SortField) => {
if (sortBy === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(field)
setSortDirection('desc')
}
}

const sortedTeams = [...allTeams].sort((a, b) => {
const direction = sortDirection === 'asc' ? 1 : -1
if (sortBy === 'name') {
return direction * a.name.localeCompare(b.name)
} else if (sortBy === 'memberCount') {
return direction * (a.teamMembers.length - b.teamMembers.length)
} else {
// lastMetAt
const aLastMet = a.lastMetAt
const bLastMet = b.lastMetAt
if (!aLastMet && !bLastMet) return 0
if (!aLastMet) return -direction
if (!bLastMet) return direction
return direction * (new Date(aLastMet).getTime() - new Date(bLastMet).getTime())
}
})

return (
<div className='max-w-4xl pb-4'>
<div className='flex items-center justify-center py-1'>
Expand All @@ -65,15 +102,44 @@ const OrgTeams = (props: Props) => {
<div className='bg-slate-100 px-4 py-2'>
<div className='flex w-full justify-between'>
<div className='flex items-center font-bold'>
{allTeamsCount} {plural(allTeamsCount, 'Team')}{' '}
{allTeamsCount} {' total '}
{!showAllTeams ? `(${allTeamsCount - viewerTeamCount} hidden)` : null}
</div>
</div>
</div>
<div className='divide-y divide-slate-300 border-y border-slate-300'>
{allTeams.map((team) => (
<OrgTeamsRow key={team.id} teamRef={team} />
))}
<div className='w-full overflow-x-auto px-4'>
<table className='w-full table-fixed border-collapse md:table-auto'>
<thead>
<tr className='border-b border-slate-300'>
<th
className='w-[60%] cursor-pointer p-3 text-left font-semibold'
onClick={() => handleSort('name')}
>
Team
{sortBy === 'name' && (sortDirection === 'asc' ? ' ▲' : ' ▼')}
</th>
<th
className='w-[20%] cursor-pointer p-3 text-left font-semibold'
onClick={() => handleSort('memberCount')}
>
Member Count
{sortBy === 'memberCount' && (sortDirection === 'asc' ? ' ▲' : ' ▼')}
</th>
<th
className='w-[20%] cursor-pointer p-3 text-left font-semibold'
onClick={() => handleSort('lastMetAt')}
>
Last Met At
{sortBy === 'lastMetAt' && (sortDirection === 'asc' ? ' ▲' : ' ▼')}
</th>
</tr>
</thead>
<tbody>
{sortedTeams.map((team) => (
<OrgTeamsRow key={team.id} teamRef={team} />
))}
</tbody>
</table>
</div>

{tier !== 'enterprise' && allTeamsCount > viewerTeamCount && !showAllTeams && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import {ChevronRight} from '@mui/icons-material'
import graphql from 'babel-plugin-relay/macro'
import {format} from 'date-fns'
import {useFragment} from 'react-relay'
import {Link} from 'react-router-dom'

import {OrgTeamsRow_team$key} from '../../../../__generated__/OrgTeamsRow_team.graphql'
import plural from '../../../../utils/plural'

type Props = {
teamRef: OrgTeamsRow_team$key
Expand All @@ -22,24 +21,25 @@ const OrgTeamsRow = (props: Props) => {
isLead
preferredName
}
lastMetAt
}
`,
teamRef
)
const {id: teamId, teamMembers, name} = team
const {id: teamId, teamMembers, name, lastMetAt} = team
const teamMembersCount = teamMembers.length
const viewerTeamMember = teamMembers.find((m) => m.isSelf)
const isLead = viewerTeamMember?.isLead
const isMember = !!viewerTeamMember && !isLead

return (
<Link
className='block hover:bg-slate-100 focus-visible:ring-1 focus-visible:outline-hidden focus-visible:ring-inset'
to={`teams/${teamId}`}
>
<div className='flex items-center p-4'>
<div className='flex flex-1 flex-col py-1'>
<div className='text-gray-700 flex items-center text-lg font-bold'>
<tr className='hover:bg-slate-50 border-b border-slate-300'>
<td className='p-3'>
<Link
to={`teams/${teamId}`}
className='text-gray-700 hover:text-gray-900 flex items-center text-lg font-bold'
>
<div className='flex flex-1 items-center'>
{name}
{isLead && (
<span className='ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-white'>
Expand All @@ -52,17 +52,14 @@ const OrgTeamsRow = (props: Props) => {
</span>
)}
</div>
<div className='flex items-center justify-between'>
<div className='text-gray-600'>
{`${teamMembersCount} ${plural(teamMembersCount, 'member')}`}
</div>
</div>
</div>
<div className='flex items-center justify-center'>
<ChevronRight />
</div>
</div>
</Link>
<ChevronRight className='ml-2 text-slate-600' />
</Link>
</td>
<td className='text-gray-600 p-3'>{teamMembersCount}</td>
<td className='text-gray-600 p-3'>
{lastMetAt ? format(new Date(lastMetAt), 'yyyy-MM-dd') : 'Never'}
</td>
</tr>
)
}

Expand Down
5 changes: 5 additions & 0 deletions packages/server/graphql/public/typeDefs/Team.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type Team implements Node {
"""
lastMeetingType: MeetingTypeEnum!

"""
The datetime of the team's most recent meeting (from either active or completed meetings)
"""
lastMetAt: DateTime

"""
The HTML message to show if isPaid is false
"""
Expand Down
14 changes: 14 additions & 0 deletions packages/server/graphql/public/types/Team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ const Team: TeamResolvers = {
const retroMeetings = meetings.filter((meeting) => meeting.meetingType === 'retrospective')
return retroMeetings.length
},
lastMetAt: async ({id: teamId}, _args, {dataLoader}) => {
const [completedMeetings, activeMeetings] = await Promise.all([
dataLoader.get('completedMeetingsByTeamId').load(teamId),
dataLoader.get('activeMeetingsByTeamId').load(teamId)
])

const dates = [
...completedMeetings.map((meeting) => new Date(meeting.endedAt || meeting.createdAt)),
...activeMeetings.map((meeting) => new Date(meeting.createdAt))
]

if (dates.length === 0) return null
return dates.reduce((latest, current) => (current > latest ? current : latest))
},
insight: async ({id: teamId}, _args, {dataLoader}) => {
const insight = await dataLoader.get('latestInsightByTeamId').load(teamId)
if (!insight) return null
Expand Down

0 comments on commit c04bb94

Please sign in to comment.