Skip to content

Commit

Permalink
feat: managing teams (#9285)
Browse files Browse the repository at this point in the history
Co-authored-by: Marcus Wermuth <[email protected]>
Co-authored-by: Jordan Husney <[email protected]>
  • Loading branch information
3 people authored Mar 8, 2024
1 parent af47966 commit f351cf9
Show file tree
Hide file tree
Showing 34 changed files with 1,058 additions and 165 deletions.
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@
{
"name": "Launch Chrome",
"request": "launch",
"type": "pwa-chrome",
"type": "chrome",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
},
{
"type": "node",
"request": "launch",
"name": "Debug Executor",
"program": "scripts/runExecutor.js",
"program": "scripts/runExecutor.js"
},
{
"type": "node",
Expand Down
74 changes: 74 additions & 0 deletions packages/client/components/DeleteTeamDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, {useState} from 'react'
import FlatPrimaryButton from './FlatPrimaryButton'
import {Input} from '../ui/Input/Input'
import {Dialog} from '../ui/Dialog/Dialog'
import {DialogContent} from '../ui/Dialog/DialogContent'
import {DialogTitle} from '../ui/Dialog/DialogTitle'
import {DialogActions} from '../ui/Dialog/DialogActions'
import useMutationProps from '../hooks/useMutationProps'
import SecondaryButton from './SecondaryButton'
import ArchiveTeamMutation from '../mutations/ArchiveTeamMutation'
import useAtmosphere from '../hooks/useAtmosphere'
import useRouter from '../hooks/useRouter'

interface Props {
isOpen: boolean
onClose: () => void
onDeleteTeam: (teamId: string) => void
teamId: string
teamName: string
teamOrgId: string
}

const DeleteTeamDialog = (props: Props) => {
const atmosphere = useAtmosphere()
const {history} = useRouter()
const {isOpen, onClose, teamId, teamName, teamOrgId, onDeleteTeam} = props

const {submitting, onCompleted, onError, error, submitMutation} = useMutationProps()

const [typedTeamName, setTypedTeamName] = useState(false)

const handleDeleteTeam = () => {
if (submitting) return
submitMutation()
ArchiveTeamMutation(atmosphere, {teamId}, {history, onError, onCompleted})
onDeleteTeam(teamId)
history.push(`/me/organizations/${teamOrgId}/teams`)
}

return (
<Dialog isOpen={isOpen} onClose={onClose}>
<DialogContent className='z-10'>
<DialogTitle className='mb-4'>Delete Team</DialogTitle>

<fieldset className='mx-0 mb-6 flex w-full flex-col p-0'>
<label className='mb-3 text-left text-sm font-semibold text-slate-600'>
Please type your team name to confirm. <b>This action can't be undone.</b>
</label>
<Input
autoFocus
onChange={(e) => {
e.preventDefault()
if (e.target.value === teamName) setTypedTeamName(true)
else setTypedTeamName(false)
}}
placeholder={teamName}
/>
{error && (
<div className='mt-2 text-sm font-semibold text-tomato-500'>{error.message}</div>
)}
</fieldset>

<DialogActions>
<FlatPrimaryButton size='medium' onClick={handleDeleteTeam} disabled={!typedTeamName}>
I understand the consequences, delete this team
</FlatPrimaryButton>
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
</DialogActions>
</DialogContent>
</Dialog>
)
}

export default DeleteTeamDialog
93 changes: 93 additions & 0 deletions packages/client/components/OrgAdminActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import graphql from 'babel-plugin-relay/macro'
import React from 'react'
import {useFragment} from 'react-relay'
import useAtmosphere from '~/hooks/useAtmosphere'
import {MenuProps} from '../hooks/useMenu'
import SetOrgUserRoleMutation from '../mutations/SetOrgUserRoleMutation'
import useMutationProps from '../hooks/useMutationProps'
import {OrgAdminActionMenu_organization$key} from '../__generated__/OrgAdminActionMenu_organization.graphql'
import {OrgAdminActionMenu_organizationUser$key} from '../__generated__/OrgAdminActionMenu_organizationUser.graphql'
import Menu from './Menu'
import MenuItem from './MenuItem'

interface Props {
menuProps: MenuProps
isViewerLastOrgAdmin: boolean
organizationUser: OrgAdminActionMenu_organizationUser$key
organization: OrgAdminActionMenu_organization$key
toggleLeave: () => void
toggleRemove: () => void
}

const OrgAdminActionMenu = (props: Props) => {
const {
menuProps,
isViewerLastOrgAdmin,
organizationUser: organizationUserRef,
organization: organizationRef,
toggleLeave,
toggleRemove
} = props
const organization = useFragment(
graphql`
fragment OrgAdminActionMenu_organization on Organization {
id
}
`,
organizationRef
)
const organizationUser = useFragment(
graphql`
fragment OrgAdminActionMenu_organizationUser on OrganizationUser {
role
user {
id
}
}
`,
organizationUserRef
)
const atmosphere = useAtmosphere()
const {onError, onCompleted, submitting, submitMutation} = useMutationProps()
const {id: orgId} = organization
const {viewerId} = atmosphere
const {role, user} = organizationUser
const {id: userId} = user

const setRole =
(role: 'ORG_ADMIN' | 'BILLING_LEADER' | null = null) =>
() => {
if (submitting) return
submitMutation()
const variables = {orgId, userId, role}
SetOrgUserRoleMutation(atmosphere, variables, {onError, onCompleted})
}

const isOrgAdmin = role === 'ORG_ADMIN'
const isBillingLeader = role === 'BILLING_LEADER'
const isSelf = viewerId === userId
const canRemoveSelf = isSelf && !isViewerLastOrgAdmin
const roleName = role === 'ORG_ADMIN' ? 'Org Admin' : 'Billing Leader'

return (
<>
<Menu ariaLabel={'Select your action'} {...menuProps}>
{!isOrgAdmin && <MenuItem label='Promote to Org Admin' onClick={setRole('ORG_ADMIN')} />}
{!isOrgAdmin && !isBillingLeader && (
<MenuItem label='Promote to Billing Leader' onClick={setRole('BILLING_LEADER')} />
)}
{isOrgAdmin && !isSelf && (
<MenuItem label='Change to Billing Leader' onClick={setRole('BILLING_LEADER')} />
)}
{((role && !isSelf) || canRemoveSelf) && (
<MenuItem label={`Remove ${roleName} role`} onClick={setRole(null)} />
)}
{canRemoveSelf && <MenuItem label='Leave Organization' onClick={toggleLeave} />}
{!isSelf && <MenuItem label='Remove from Organization' onClick={toggleRemove} />}
{isSelf && !canRemoveSelf && <MenuItem label='Contact [email protected] to be removed' />}
</Menu>
</>
)
}

export default OrgAdminActionMenu
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const OrgMembers = lazy(
import(/* webpackChunkName: 'OrgMembersRoot' */ '../../containers/OrgMembers/OrgMembersRoot')
)

const OrgTeamMembers = lazy(
() => import(/* webpackChunkName: 'OrgTeamMembers' */ '../OrgTeamMembers/OrgTeamMembersRoot')
)

const OrgDetails = lazy(() => import(/* webpackChunkName: 'OrgDetails' */ './OrgDetails'))
const Authentication = lazy(
() =>
Expand Down Expand Up @@ -70,13 +74,7 @@ const Organization = (props: Props) => {
path={`${match.url}/${BILLING_PAGE}`}
render={() => <OrgPlansAndBillingRoot organizationRef={organization} />}
/>
{isBillingLeader && (
<Route
exact
path={`${match.url}/${TEAMS_PAGE}`}
render={() => <OrgTeams organizationRef={organization} />}
/>
)}

<Route
exact
path={`${match.url}/${MEMBERS_PAGE}`}
Expand All @@ -92,6 +90,16 @@ const Organization = (props: Props) => {
path={`${match.url}/${AUTHENTICATION_PAGE}`}
render={(p) => <Authentication {...p} orgId={orgId} />}
/>
{isBillingLeader && (
<>
<Route
exact
path={`${match.url}/${TEAMS_PAGE}`}
render={() => <OrgTeams organizationRef={organization} />}
/>
<Route exact path={`${match.url}/${TEAMS_PAGE}/:teamId`} component={OrgTeamMembers} />
</>
)}
</Switch>
</section>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ const OrgMembers = (props: Props) => {
['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 exportToCSV = async () => {
const rows = organizationUsers.edges.map((orgUser, idx) => {
Expand Down Expand Up @@ -117,6 +121,7 @@ const OrgMembers = (props: Props) => {
<OrgMemberRow
key={organizationUser.id}
billingLeaderCount={billingLeaderCount}
orgAdminCount={orgAdminCount}
organizationUser={organizationUser}
organization={organization}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import styled from '@emotion/styled'
import React from 'react'
import graphql from 'babel-plugin-relay/macro'
import useAtmosphere from '~/hooks/useAtmosphere'
import {useFragment} from 'react-relay'
import {MenuProps} from '../../../../hooks/useMenu'
import Menu from '../../../../components/Menu'
import MenuItem from '../../../../components/MenuItem'
import MenuItemLabel from '../../../../components/MenuItemLabel'
import {OrgTeamMemberMenu_teamMember$key} from '../../../../__generated__/OrgTeamMemberMenu_teamMember.graphql'

interface OrgTeamMemberMenuProps {
isLead: boolean
menuProps: MenuProps
isViewerLead: boolean
isViewerOrgAdmin: boolean
manageTeamMemberId?: string | null
teamMember: OrgTeamMemberMenu_teamMember$key
handleNavigate?: () => void
togglePromote: () => void
toggleRemove: () => void
}

const StyledLabel = styled(MenuItemLabel)({
padding: '4px 16px'
})

export const OrgTeamMemberMenu = (props: OrgTeamMemberMenuProps) => {
const {
isViewerLead,
isViewerOrgAdmin,
teamMember: teamMemberRef,
menuProps,
togglePromote,
toggleRemove
} = props
const teamMember = useFragment(
graphql`
fragment OrgTeamMemberMenu_teamMember on TeamMember {
isSelf
preferredName
userId
isLead
}
`,
teamMemberRef
)
const atmosphere = useAtmosphere()
const {preferredName, userId} = teamMember
const {viewerId} = atmosphere
const isSelf = userId === viewerId
const isViewerTeamAdmin = isViewerLead || isViewerOrgAdmin

return (
<Menu ariaLabel={'Select your action'} {...menuProps}>
{isViewerTeamAdmin && (!isSelf || !isViewerLead) && (
<MenuItem
label={<StyledLabel>Promote {preferredName} to Team Lead</StyledLabel>}
key='promote'
onClick={togglePromote}
/>
)}
{isViewerTeamAdmin && !isSelf && (
<MenuItem
label={<StyledLabel>Remove {preferredName} from Team</StyledLabel>}
onClick={toggleRemove}
/>
)}
</Menu>
)
}
Loading

0 comments on commit f351cf9

Please sign in to comment.