Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(orgAdmins): Make org members view sortable #10146

Merged
merged 17 commits into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/client/components/OrgAdminActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {MoreVert} from '@mui/icons-material'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import graphql from 'babel-plugin-relay/macro'
import React from 'react'
import {useFragment} from 'react-relay'
Expand Down Expand Up @@ -123,9 +122,14 @@ export const OrgAdminActionMenu = (props: Props) => {
)}
{isSelf &&
((isOrgAdmin && isViewerLastOrgAdmin) || (isBillingLeader && isViewerLastRole)) && (
<DropdownMenu.Label className='select-none p-2'>
<MenuItem
onClick={() => {
window.location.href =
'mailto:[email protected]?subject=Request to be removed from organization'
}}
>
{'Contact [email protected] to be removed'}
</DropdownMenu.Label>
</MenuItem>
)}
</MenuContent>
</Menu>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import styled from '@emotion/styled'
import graphql from 'babel-plugin-relay/macro'
import type {Parser as JSON2CSVParser} from 'json2csv'
import Parser from 'json2csv/lib/JSON2CSVParser' // only grab the sync parser
import React from 'react'
import React, {useMemo, useState} from 'react'
import {PreloadedQuery, usePaginationFragment, usePreloadedQuery} from 'react-relay'
import {OrgMembersPaginationQuery} from '~/__generated__/OrgMembersPaginationQuery.graphql'
import {OrgMembersQuery} from '~/__generated__/OrgMembersQuery.graphql'
import {OrgMembers_viewer$key} from '~/__generated__/OrgMembers_viewer.graphql'
import User from '../../../../../server/database/types/User'
import ExportToCSVButton from '../../../../components/ExportToCSVButton'
import Panel from '../../../../components/Panel/Panel'
import {ElementWidth} from '../../../../types/constEnums'
import {APP_CORS_OPTIONS} from '../../../../types/cors'
import OrgMemberRow from '../OrgUserRow/OrgMemberRow'

const StyledPanel = styled(Panel)({
maxWidth: ElementWidth.PANEL_WIDTH
})

interface Props {
queryRef: PreloadedQuery<OrgMembersQuery>
}
Expand Down Expand Up @@ -50,6 +44,7 @@ const OrgMembers = (props: Props) => {
user {
preferredName
email
lastSeenAt
}
...OrgMemberRow_organizationUser
}
Expand All @@ -70,6 +65,43 @@ const OrgMembers = (props: Props) => {
const {organization} = viewer
if (!organization) return null
const {organizationUsers, name: orgName, isBillingLeader} = organization
const billingLeaderCount = organizationUsers.edges.reduce(
(count, {node}) =>
['BILLING_LEADER', 'ORG_ADMIN'].includes(node.role ?? '') ? count + 1 : count,
0
)
const orgAdminCount = organizationUsers.edges.reduce(
(count, {node}) => (['ORG_ADMIN'].includes(node.role ?? '') ? count + 1 : count),
0
)
const [sortBy, setSortBy] = useState<keyof User>('lastSeenAt')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')

const sortedOrganizationUsers = useMemo(() => {
return [...organizationUsers.edges].sort((a, b) => {
if (sortBy === 'lastSeenAt') {
const aDate = a.node.user.lastSeenAt ? new Date(a.node.user.lastSeenAt) : new Date(0)
const bDate = b.node.user.lastSeenAt ? new Date(b.node.user.lastSeenAt) : new Date(0)
return sortDirection === 'asc'
? aDate.getTime() - bDate.getTime()
: bDate.getTime() - aDate.getTime()
} else if (sortBy === 'preferredName') {
return sortDirection === 'asc'
? a.node.user.preferredName.localeCompare(b.node.user.preferredName)
: b.node.user.preferredName.localeCompare(a.node.user.preferredName)
}
return 0
})
}, [organizationUsers.edges, sortBy, sortDirection])

const handleSort = (column: keyof User) => {
if (sortBy === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(column)
setSortDirection('asc')
}
}

const exportToCSV = async () => {
const rows = organizationUsers.edges.map((orgUser, idx) => {
Expand Down Expand Up @@ -99,24 +131,62 @@ const OrgMembers = (props: Props) => {
}

return (
<StyledPanel
label='Organization Members'
controls={
isBillingLeader && (
<ExportToCSVButton handleClick={exportToCSV} corsOptions={APP_CORS_OPTIONS} />
)
}
>
{organizationUsers.edges.map(({node: organizationUser}) => {
return (
<OrgMemberRow
key={organizationUser.id}
organizationUser={organizationUser}
organization={organization}
/>
)
})}
</StyledPanel>
<div className='max-w-4xl pb-4'>
<div className='flex items-center justify-start py-1'>
<div>
<h1 className='text-2xl font-semibold leading-7'>Members</h1>
</div>
tianrunhe marked this conversation as resolved.
Show resolved Hide resolved
<div className='ml-auto'>
{isBillingLeader && (
<ExportToCSVButton handleClick={exportToCSV} corsOptions={APP_CORS_OPTIONS} />
)}
</div>
</div>

<div className='divide-y divide-slate-300 overflow-hidden rounded-md border border-slate-300 bg-white shadow-sm'>
<div className='bg-slate-100 px-4 py-2'>
<div className='flex w-full justify-between'>
<div className='flex items-center font-bold'>
{organizationUsers.edges.length} total
</div>
</div>
</div>
<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-[70%] cursor-pointer p-3 text-left font-semibold'
onClick={() => handleSort('preferredName')}
>
User
{sortBy === 'preferredName' && (sortDirection === 'asc' ? ' ▲' : ' ▼')}
</th>
<th
className='w-[20%] cursor-pointer p-3 text-left font-semibold'
onClick={() => handleSort('lastSeenAt')}
>
Last Seen
{sortBy === 'lastSeenAt' && (sortDirection === 'asc' ? ' ▲' : ' ▼')}
</th>
<th className='w-[20%] p-3 text-left font-semibold'></th>
</tr>
</thead>
<tbody>
{sortedOrganizationUsers.map(({node: organizationUser}) => (
<OrgMemberRow
key={organizationUser.id}
billingLeaderCount={billingLeaderCount}
orgAdminCount={orgAdminCount}
organizationUser={organizationUser}
organization={organization}
/>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import styled from '@emotion/styled'
import graphql from 'babel-plugin-relay/macro'
import {format} from 'date-fns'
import React from 'react'
import {useFragment} from 'react-relay'
import {
Expand All @@ -11,7 +12,6 @@ import {
OrgMemberRow_organizationUser$key
} from '../../../../__generated__/OrgMemberRow_organizationUser.graphql'
import Avatar from '../../../../components/Avatar/Avatar'
import Row from '../../../../components/Row/Row'
import RowActions from '../../../../components/Row/RowActions'
import RowInfo from '../../../../components/Row/RowInfo'
import RowInfoHeader from '../../../../components/Row/RowInfoHeader'
Expand All @@ -22,35 +22,17 @@ import InactiveTag from '../../../../components/Tag/InactiveTag'
import RoleTag from '../../../../components/Tag/RoleTag'
import useModal from '../../../../hooks/useModal'
import defaultUserAvatar from '../../../../styles/theme/images/avatar-user.svg'
import {Breakpoint} from '../../../../types/constEnums'
import lazyPreload from '../../../../utils/lazyPreload'

const AvatarBlock = styled('div')({
display: 'none',
[`@media screen and (min-width: ${Breakpoint.SIDEBAR_LEFT}px)`]: {
display: 'block',
marginRight: 16
}
})

const StyledRow = styled(Row)({
padding: '12px 8px 12px 16px',
[`@media screen and (min-width: ${Breakpoint.SIDEBAR_LEFT}px)`]: {
padding: '16px 8px 16px 16px'
}
})

const StyledRowInfo = styled(RowInfo)({
paddingLeft: 0
})

const ActionsBlock = styled('div')({
alignItems: 'center',
display: 'flex',
justifyContent: 'flex-end'
})

interface Props {
billingLeaderCount: number
orgAdminCount: number
organizationUser: OrgMemberRow_organizationUser$key
organization: OrgMemberRow_organization$key
}
Expand All @@ -74,13 +56,13 @@ interface UserAvatarProps {
}

const UserAvatar: React.FC<UserAvatarProps> = ({picture}) => (
<AvatarBlock>
<div className='mr-4 hidden md:block'>
{picture ? (
<Avatar picture={picture} className='h-11 w-11' />
) : (
<img alt='default avatar' src={defaultUserAvatar} />
)}
</AvatarBlock>
</div>
)

interface UserInfoProps {
Expand All @@ -98,7 +80,7 @@ const UserInfo: React.FC<UserInfoProps> = ({
isOrgAdmin,
inactive
}) => (
<StyledRowInfo>
<RowInfo className='pl-0'>
<RowInfoHeader>
<RowInfoHeading>{preferredName}</RowInfoHeading>
{isBillingLeader && <RoleTag>Billing Leader</RoleTag>}
Expand All @@ -108,7 +90,7 @@ const UserInfo: React.FC<UserInfoProps> = ({
<RowInfoLink href={`mailto:${email}`} title='Send an email'>
{email}
</RowInfoLink>
</StyledRowInfo>
</RowInfo>
)

interface UserActionsProps {
Expand Down Expand Up @@ -168,6 +150,7 @@ const OrgMemberRow = (props: Props) => {
inactive
picture
preferredName
lastSeenAt
}
role
...OrgAdminActionMenu_organizationUser
Expand All @@ -177,28 +160,41 @@ const OrgMemberRow = (props: Props) => {
)

const {
user: {email, inactive, picture, preferredName},
user: {email, inactive, picture, preferredName, lastSeenAt},
role
} = organizationUser

const isBillingLeader = role === 'BILLING_LEADER'
const isOrgAdmin = role === 'ORG_ADMIN'
const formattedLastSeenAt = lastSeenAt ? format(new Date(lastSeenAt), 'yyyy-MM-dd') : 'Never'

return (
<StyledRow>
<UserAvatar picture={picture} />
<UserInfo
preferredName={preferredName}
email={email}
isBillingLeader={isBillingLeader}
isOrgAdmin={isOrgAdmin}
inactive={inactive}
/>
<UserActions
organizationUser={organizationUser}
organization={organization}
preferredName={preferredName}
/>
</StyledRow>
<tr className='border-b border-slate-300 last:border-b-0'>
<td className='w-1/2 py-3 px-2 align-middle'>
<div className='flex w-full items-center overflow-hidden'>
<UserAvatar picture={picture} />
<div className='min-w-0 flex-grow'>
<UserInfo
preferredName={preferredName}
email={email}
isBillingLeader={isBillingLeader}
isOrgAdmin={isOrgAdmin}
inactive={inactive}
/>
</div>
</div>
</td>
<td className='w-3/10 py-3 px-2 align-middle'>
<RowInfo className='pl-0'>{formattedLastSeenAt}</RowInfo>
</td>
<td className='w-1/5 py-3 px-2 align-middle'>
<UserActions
organizationUser={organizationUser}
organization={organization}
preferredName={preferredName}
/>
</td>
</tr>
)
}

Expand Down
Loading