Skip to content

Commit

Permalink
feat(ad-hoc): Add create team dialog (#8846)
Browse files Browse the repository at this point in the history
* chore(one-on-one): add oneOnOne org feature flag

* chore(one-on-one): add one-on-one meeting template

* Change color

* Put one on one in standup

* feat(one-on-one): allow oneOnOne input in startCheckIn mutation

* fix checkin

* revert duplicate meeting check

* Remove userId option

* Publish team payload

* chore(one-on-one): Add user picker

* Submit mutation

* remove ui/input

* Code cleanup

* chore(one-on-one): add user picker styles

* args validation

* prettier

* generateOneOnOneTeamName

* isOneOnOneTeam

* chore(one-on-one): show organiation picker if can't determine org automatically

* Enable multiple false

* chore(one-on-one): Add team exists warning

* use isOneOnOne team to find teams

* Add analytics

* Show only mutual orgs

* Show name in warning

* Exclude self

* Add a validation

* Avatar

* fix meeting name

* Separate files

* simplify

* Remove useEffect

* autoselect

* Hide schedule button

* Move team exists check to organization

* feat(ad-hoc): allow to create team from teams dropdown

* Rename component

* use organization(id)

* handleAddTeamClick

* add Teammate Selected event

* feat(ad-hoc): Add create team dialog

* Don't redirect after team creation

* Fix new team select

* automatically select team

* validation

* remove comment

* Auto-generate team name

* Add email expires warning

* Fix unnecessary spacing

* tweak padding

* tweak select height

* Add events

* Show name length error from backend

* Move styles outside

* Remove React prefix

* Bigger button

* Update styles

* Don't show org picker if user has a single org
  • Loading branch information
igorlesnenko authored Oct 5, 2023
1 parent 2b9a5c9 commit 14a32aa
Show file tree
Hide file tree
Showing 9 changed files with 303 additions and 13 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {LockOpen} from '@mui/icons-material'
import graphql from 'babel-plugin-relay/macro'
import clsx from 'clsx'
import React, {useState} from 'react'
import React, {useState, useEffect, useRef} from 'react'
import {useFragment} from 'react-relay'
import StartSprintPokerMutation from '~/mutations/StartSprintPokerMutation'
import {useHistory} from 'react-router'
Expand Down Expand Up @@ -132,12 +132,24 @@ const ActivityDetailsSidebar = (props: Props) => {
: // it is a team-scoped template, templateTeam must exist
[templateTeam!]

const availableTeamsRef = useRef(availableTeams)

useEffect(() => {
availableTeamsRef.current = availableTeams
}, [availableTeams])

const [selectedTeam, setSelectedTeam] = useState(
() =>
availableTeams.find((team) => team.id === preferredTeamId) ??
templateTeam ??
sortByTier(availableTeams)[0]!
)

const onSelectTeam = (teamId: string) => {
const currentAvailableTeams = availableTeamsRef.current
const newTeam = currentAvailableTeams.find((team) => team.id === teamId)
newTeam && setSelectedTeam(newTeam)
}
const mutationProps = useMutationProps()
const {onError, onCompleted, submitting, submitMutation, error} = mutationProps
const history = useHistory()
Expand Down Expand Up @@ -322,10 +334,7 @@ const ActivityDetailsSidebar = (props: Props) => {
) : (
<NewMeetingTeamPicker
positionOverride={MenuPosition.UPPER_LEFT}
onSelectTeam={(teamId) => {
const newTeam = availableTeams.find((team) => team.id === teamId)
newTeam && setSelectedTeam(newTeam)
}}
onSelectTeam={onSelectTeam}
selectedTeamRef={selectedTeam}
teamsRef={availableTeams}
customPortal={teamScopePopover}
Expand Down
213 changes: 213 additions & 0 deletions packages/client/components/AddTeamDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import React, {useState} from 'react'
import {PreloadedQuery, usePreloadedQuery, useFragment} from 'react-relay'
import FlatPrimaryButton from './FlatPrimaryButton'
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 {Select} from '../ui/Select/Select'
import {SelectItem} from '../ui/Select/SelectItem'
import {SelectTrigger} from '../ui/Select/SelectTrigger'
import {SelectGroup} from '../ui/Select/SelectGroup'
import {SelectValue} from '../ui/Select/SelectValue'
import {SelectContent} from '../ui/Select/SelectContent'
import graphql from 'babel-plugin-relay/macro'
import {AddTeamDialogQuery} from '../__generated__/AddTeamDialogQuery.graphql'
import {AddTeamDialog_viewer$key} from '../__generated__/AddTeamDialog_viewer.graphql'
import useMutationProps from '../hooks/useMutationProps'
import useAtmosphere from '../hooks/useAtmosphere'
import {AdhocTeamMultiSelect, Option} from '../components/AdhocTeamMultiSelect/AdhocTeamMultiSelect'
import {Input} from '../ui/Input/Input'
import AddTeamMutation from '~/mutations/AddTeamMutation'
import useRouter from '~/hooks/useRouter'
import getGraphQLError from '~/utils/relay/getGraphQLError'
import SendClientSegmentEventMutation from '~/mutations/SendClientSegmentEventMutation'

interface Props {
isOpen: boolean
onClose: () => void
onAddTeam: (teamId: string) => void
queryRef: PreloadedQuery<AddTeamDialogQuery>
}

const AddTeamDialogViewerFragment = graphql`
fragment AddTeamDialog_viewer on User {
...AdhocTeamMultiSelect_viewer
organizations {
id
name
}
}
`

const query = graphql`
query AddTeamDialogQuery {
viewer {
...AddTeamDialog_viewer
}
}
`

const AddTeamDialog = (props: Props) => {
const {isOpen, onClose, queryRef, onAddTeam} = props
const atmosphere = useAtmosphere()

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

const data = usePreloadedQuery<AddTeamDialogQuery>(query, queryRef)
const viewer = useFragment<AddTeamDialog_viewer$key>(AddTeamDialogViewerFragment, data.viewer)
const {organizations: viewerOrganizations} = viewer

const [selectedUsers, setSelectedUsers] = useState<Option[]>([])
const [mutualOrgsIds, setMutualOrgsIds] = useState<string[]>([])

const showOrgPicker = !!(
selectedUsers.length &&
(mutualOrgsIds.length > 1 || !mutualOrgsIds.length) &&
viewerOrganizations.length > 1
)

const defaultOrgId = mutualOrgsIds[0]
const [selectedOrgId, setSelectedOrgId] = useState(defaultOrgId)
const [teamName, setTeamName] = useState('')
const [teamNameManuallyEdited, setTeamNameManuallyEdited] = useState(false)

const MAX_TEAM_NAME_LENGTH = 50
const generateTeamName = (newUsers: Option[]) => {
return newUsers
.map((user) => (user.id ? user.label : user.email.split('@')[0]))
.join(', ')
.substring(0, MAX_TEAM_NAME_LENGTH)
}

const onSelectedUsersChange = (newUsers: Option[]) => {
setSelectedUsers(newUsers)
const selectedUsersOrganizationIds = new Set()
newUsers.forEach((user) => {
//add user.organizationIds to selectedUserOrganizationIds
user.organizationIds.forEach((organizationId) => {
selectedUsersOrganizationIds.add(organizationId)
})
})

const mutualOrgs = viewerOrganizations.filter((org) => selectedUsersOrganizationIds.has(org.id))

const mutualOrgsIds = mutualOrgs.map((org) => org.id)
setMutualOrgsIds(mutualOrgsIds)
setSelectedOrgId(mutualOrgsIds[0] ?? viewerOrganizations[0]?.id)

if (!teamNameManuallyEdited) {
setTeamName(generateTeamName(newUsers))
}

if (newUsers.length && newUsers.length > selectedUsers.length) {
SendClientSegmentEventMutation(atmosphere, 'Teammate Selected', {
selectionLocation: 'addTeamUserPicker'
})
}
}

const handleAddTeam = () => {
const newTeam = {
name: teamName,
orgId: selectedOrgId
}
submitMutation()

AddTeamMutation(
atmosphere,
{newTeam, invitees: selectedUsers.map((user) => user.email)},
{
onError,
onCompleted: (res, errors) => {
onCompleted(res, errors)
const error = getGraphQLError(res, errors)
if (!error) {
onAddTeam(res.addTeam.team.id)
}
},
history,
showTeamCreatedToast: false
}
)
}

const isValid = selectedUsers.length && teamName.trim()

const labelStyles = `text-left text-sm font-semibold mb-3`
const fieldsetStyles = `mx-0 mb-6 flex flex-col w-full p-0`

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

<fieldset className={fieldsetStyles}>
<label className={labelStyles}>Add teammates</label>

<AdhocTeamMultiSelect
viewerRef={viewer}
value={selectedUsers}
onChange={onSelectedUsersChange}
/>

{selectedUsers.some((user: Option) => !user.id) && (
<div className='mt-3 text-xs font-semibold text-slate-700'>
Email invitations expire in 30 days.
</div>
)}
</fieldset>

{showOrgPicker && (
<fieldset className={fieldsetStyles}>
<label className={labelStyles}>Organization</label>
<Select onValueChange={(orgId) => setSelectedOrgId(orgId)} value={selectedOrgId}>
<SelectTrigger className='bg-white'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{viewerOrganizations
.filter((org) => (mutualOrgsIds.length ? mutualOrgsIds.includes(org.id) : true))
.map((org) => (
<SelectItem value={org.id} key={org.id}>
{org.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</fieldset>
)}

<fieldset className={fieldsetStyles}>
<label className={labelStyles}>Team name</label>
<Input
onChange={(e) => {
if (!teamNameManuallyEdited) {
setTeamNameManuallyEdited(true)
}
setTeamName(e.target.value)
}}
value={teamName}
/>
{error && (
<div className='mt-2 text-sm font-semibold text-tomato-500'>{error.message}</div>
)}
</fieldset>
<DialogActions>
<FlatPrimaryButton
size='medium'
onClick={handleAddTeam}
disabled={submitting || !isValid}
>
Add team
</FlatPrimaryButton>
</DialogActions>
</DialogContent>
</Dialog>
)
}

export default AddTeamDialog
26 changes: 26 additions & 0 deletions packages/client/components/AddTeamDialogRoot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, {Suspense} from 'react'
import AddTeamDialog from './AddTeamDialog'
import {renderLoader} from '../utils/relay/renderLoader'
import useQueryLoaderNow from '../hooks/useQueryLoaderNow'
import addTeamDialogQuery, {AddTeamDialogQuery} from '../__generated__/AddTeamDialogQuery.graphql'

interface Props {
onClose: () => void
onAddTeam: (teamId: string) => void
}

const AddTeamDialogRoot = (props: Props) => {
const {onClose, onAddTeam} = props

const queryRef = useQueryLoaderNow<AddTeamDialogQuery>(addTeamDialogQuery, {}, 'network-only')

return (
<Suspense fallback={renderLoader()}>
{queryRef && (
<AddTeamDialog onAddTeam={onAddTeam} isOpen={true} onClose={onClose} queryRef={queryRef} />
)}
</Suspense>
)
}

export default AddTeamDialogRoot
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export const AdhocTeamMultiSelect = (props: Props) => {
<div {...getRootProps()}>
<div
ref={setAnchorEl}
className='align-center flex min-h-[44px] w-full flex-wrap rounded border-2 border-slate-300 bg-white px-1 py-0.5 text-sm font-semibold'
className='align-center flex min-h-[44px] w-full flex-wrap rounded border border-slate-500 bg-white px-1 py-0.5 text-sm'
>
{value.map((option, index: number) => (
<Chip
Expand All @@ -184,7 +184,7 @@ export const AdhocTeamMultiSelect = (props: Props) => {
className='m-0 box-border min-h-[36px] w-0 min-w-[30px] flex-grow border-0 bg-white pl-1 text-black outline-none'
/>
</div>
<div className='mt-2 text-sm font-semibold text-tomato-500'>{error}</div>
{error && <div className='mt-2 text-sm font-semibold text-tomato-500'>{error}</div>}
</div>
{groupedOptions.length > 0 ? (
<ul
Expand Down
18 changes: 17 additions & 1 deletion packages/client/components/NewMeetingTeamPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import NewMeetingDropdown from './NewMeetingDropdown'
import NewMeetingTeamPickerAvatars from './NewMeetingTeamPickerAvatars'
import useAtmosphere from '../hooks/useAtmosphere'
import setPreferredTeamId from '../utils/relay/setPreferredTeamId'
import AddTeamDialogRoot from '~/components/AddTeamDialogRoot'
import SendClientSegmentEventMutation from '~/mutations/SendClientSegmentEventMutation'

const SelectTeamDropdown = lazyPreload(
() =>
Expand Down Expand Up @@ -39,6 +41,8 @@ const NewMeetingTeamPicker = (props: Props) => {
}
)

const [addTeamDialogOpen, setAddTeamDialogOpen] = React.useState(false)

const atmosphere = useAtmosphere()

const handleSelectTeam = (teamId: string) => {
Expand All @@ -47,7 +51,8 @@ const NewMeetingTeamPicker = (props: Props) => {
}

const handleAddTeamClick = () => {
window.open(`/newteam/1`, '_blank', 'noreferrer')
SendClientSegmentEventMutation(atmosphere, 'Add Team Clicked')
setAddTeamDialogOpen(true)
}

const selectedTeam = useFragment(
Expand Down Expand Up @@ -96,6 +101,17 @@ const NewMeetingTeamPicker = (props: Props) => {
/>
)
)}
{addTeamDialogOpen && (
<AddTeamDialogRoot
onAddTeam={(teamId) => {
setAddTeamDialogOpen(false)
handleSelectTeam(teamId)
}}
onClose={() => {
setAddTeamDialogOpen(false)
}}
/>
)}
</>
)
}
Expand Down
12 changes: 9 additions & 3 deletions packages/client/mutations/AddTeamMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ graphql`
...NewTeamForm_teams
...MeetingsDashActiveMeetings
...Team_team
...ActivityDetailsSidebar_teams
}
}
`
Expand Down Expand Up @@ -71,10 +72,13 @@ export const addTeamMutationNotificationUpdater: SharedUpdater<
handleRemoveSuggestedActions(removedSuggestedActionId, store)
}

const AddTeamMutation: StandardMutation<TAddTeamMutation, HistoryLocalHandler> = (
type ExtendedHistoryLocalHandler = HistoryLocalHandler & {
showTeamCreatedToast?: boolean
}
const AddTeamMutation: StandardMutation<TAddTeamMutation, ExtendedHistoryLocalHandler> = (
atmosphere,
variables,
{history, onError, onCompleted}
{history, onError, onCompleted, showTeamCreatedToast = true}
) => {
return commitMutation<TAddTeamMutation>(atmosphere, {
mutation,
Expand All @@ -91,7 +95,9 @@ const AddTeamMutation: StandardMutation<TAddTeamMutation, HistoryLocalHandler> =
if (!error) {
const {authToken} = addTeam
atmosphere.setAuthToken(authToken)
popTeamCreatedToast(addTeam, {atmosphere, history})
if (showTeamCreatedToast) {
popTeamCreatedToast(addTeam, {atmosphere, history})
}
}
},
onError
Expand Down
Loading

0 comments on commit 14a32aa

Please sign in to comment.