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

Allow moving integration tasks between teams #5513

Merged
merged 15 commits into from Nov 11, 2021
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/integrations.md
@@ -0,0 +1,26 @@
# Integrations

## Jira and GitHub

Some guidelines how we handle the user's integration of GitHub or Jira, especially in which cases the user's authentication might be used by someone else than the user.

A user's integration might only be reused by someone else if the user previously connected the task with their credentials in some form.
If a user has an integration set up, their authentication is used to perform actions with tasks.
In some cases if the user has no integration set up, we will fall back to the assignee's authentication.

This is how it should be in the future and does not necessarily reflect current state:

- [ ] **reading** a task uses **any team member's auth**
in the preferred order of:
- `task.integration?.accessUserId`
- fallback to viewer
- fallback to the team lead (reason being is a team lead is less likely to leave a team. maybe this is too optimistic?)
- fallback to any other member
- [x] **pushing a task** requires **viewer auth or assignee's auth**, but in both cases a comment will be added if viewer !== assignee
- [x] adding tasks in **scoping** requires **viewer's auth**
- [ ] adding task **estimates** uses **viewer's auth or assignee's auth** (Most likely just added in scoping by the assignee, but even when added to the board before it's little risk as estimating is a team activity)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably try to use task.integration?.accessUserId too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true

- [ ] **adding fields** to project requires **viewer's auth**
- [ ] when **moving a task between teams** we check who's auth is used for this task
- if the viewer has an integration for the target team, use that
- else if `accessUserId === userId` (**viewer's auth** since switching teams is only allowed for viewer's own tasks), then we ask them to add the integration to the new team in UI and move it over automatically in server
- else if `accessUserId !== userId`, then we check if the user pushing the task initially has an integration set up for the target team and move the task if present, otherwise we report an error
3 changes: 3 additions & 0 deletions packages/client/hooks/useMenu.ts
Expand Up @@ -18,6 +18,9 @@ export interface MenuProps {
isDropdown: boolean
}

/**
* Wrapper around {@link usePortal} to display menus
*/
const useMenu = <T extends HTMLElement = HTMLButtonElement>(
preferredMenuPosition: MenuPosition,
options: Options = {}
Expand Down
3 changes: 3 additions & 0 deletions packages/client/hooks/useMenuPortal.tsx
Expand Up @@ -17,6 +17,9 @@ const MenuBlock = styled('div')({
zIndex: ZIndex.MENU
})

/**
* Use a portal to display a menu, you usually want to use {@link useMenu} instead
*/
const useMenuPortal = (
portal: (el: ReactElement) => ReactPortal | null,
targetRef: RefObject<HTMLDivElement>,
Expand Down
3 changes: 3 additions & 0 deletions packages/client/hooks/useModal.ts
Expand Up @@ -9,6 +9,9 @@ interface Options extends UsePortalOptions {
noClose?: boolean
}

/**
* Wrapper around {@link usePortal} for displaying dialogs
*/
const useModal = (options: Options = {}) => {
const {background, onOpen, onClose, noClose, id, parentId} = options
const targetRef = useRef<HTMLDivElement>(null)
Expand Down
11 changes: 9 additions & 2 deletions packages/client/hooks/usePortal.tsx
Expand Up @@ -27,11 +27,14 @@ export type PortalId =
| 'StageTimerEndTimePicker'
| 'StageTimerStartTimePicker'
| 'StageTimerMinutePicker'
| 'taskFooterTeamAssigneeAddIntegration'
| 'taskFooterTeamAssigneeMenu'

export interface UsePortalOptions {
onOpen?: (el: HTMLElement) => void
onClose?: () => void
id?: PortalId
// if you nest portals, this should be the id of the parent portal
parentId?: PortalId
// allow body to scroll while modal is open
allowScroll?: boolean
Expand All @@ -45,6 +48,10 @@ const getParent = (parentId: string | undefined) => {
return parent
}

/**
* Create and manage a React.Portal to display nodes outside the DOM. Manages Keyboard presses etc.
* To nest multiple portals, the outer one should have id set and the inner one parentId
*/
const usePortal = (options: UsePortalOptions = {}) => {
const portalRef = useRef<HTMLDivElement>()
const originRef = useRef<HTMLElement>()
Expand Down Expand Up @@ -149,10 +156,10 @@ const usePortal = (options: UsePortalOptions = {}) => {
portalRef.current = document.createElement('div')
portalRef.current.id = options.id || 'portal'
getParent(options.parentId).appendChild(portalRef.current)
if (e && e.currentTarget) {
if (e?.currentTarget) {
originRef.current = e.currentTarget as HTMLElement
}
options.onOpen && options.onOpen(portalRef.current)
options.onOpen?.(portalRef.current)
}
})

Expand Down
@@ -0,0 +1,64 @@
import React from 'react'
import styled from '@emotion/styled'
import DialogContainer from '~/components/DialogContainer'
import DialogTitle from '~/components/DialogTitle'
import SecondaryButton from '~/components/SecondaryButton'
import PrimaryButton from '~/components/PrimaryButton'
import DialogContent from '~/components/DialogContent'

interface Props {
onClose: () => void
onConfirm: () => void
serviceName: string
teamName: string
}

const StyledDialogContainer = styled(DialogContainer)({
width: 480
})

const ButtonGroup = styled('div')({
marginTop: '24px',
display: 'flex',
justifyContent: 'flex-end'
})

const StyledTip = styled('p')({
fontSize: 14,
lineHeight: '20px',
margin: 0,
padding: '0 0 16px'
})

const StyledPrimaryButton = styled(PrimaryButton)({
marginLeft: 16
})

const TaskFooterTeamAssigneeAddIntegrationDialog = (props: Props) => {
const {onClose, onConfirm, serviceName, teamName} = props

return (
<StyledDialogContainer>
<DialogTitle>
{serviceName} integration for {teamName}
</DialogTitle>
<DialogContent>
<div>
<StyledTip>
You don't have {serviceName} configured for {teamName}. Do you want to add it now?
</StyledTip>
<ButtonGroup>
<SecondaryButton onClick={onClose} size='medium'>
Cancel
</SecondaryButton>
<StyledPrimaryButton onClick={onConfirm} size='medium'>
Add it now
</StyledPrimaryButton>
</ButtonGroup>
</div>
</DialogContent>
</StyledDialogContainer>
)
}

export default TaskFooterTeamAssigneeAddIntegrationDialog
@@ -1,6 +1,6 @@
import {TaskFooterTeamAssigneeMenu_task} from '../../../../__generated__/TaskFooterTeamAssigneeMenu_task.graphql'
import {TaskFooterTeamAssigneeMenu_viewer} from '../../../../__generated__/TaskFooterTeamAssigneeMenu_viewer.graphql'
import React, {useMemo} from 'react'
import React, {useMemo, useState} from 'react'
import {createFragmentContainer} from 'react-relay'
import graphql from 'babel-plugin-relay/macro'
import DropdownMenuLabel from '../../../../components/DropdownMenuLabel'
Expand All @@ -11,6 +11,30 @@ import {MenuProps} from '../../../../hooks/useMenu'
import ChangeTaskTeamMutation from '../../../../mutations/ChangeTaskTeamMutation'
import useMutationProps from '../../../../hooks/useMutationProps'
import {useUserTaskFilters} from '~/utils/useUserTaskFilters'
import {TaskFooterTeamAssigneeMenu_viewerIntegrationsQuery} from '~/__generated__/TaskFooterTeamAssigneeMenu_viewerIntegrationsQuery.graphql'
import useModal from '~/hooks/useModal'
import TaskFooterTeamAssigneeAddIntegrationDialog from './TaskFooterTeamAssigneeAddIntegrationDialog'
import useEventCallback from '~/hooks/useEventCallback'

const query = graphql`
query TaskFooterTeamAssigneeMenu_viewerIntegrationsQuery($teamId: ID!) {
viewer {
id
teamMember(teamId: $teamId) {
id
integrations {
id
atlassian {
isActive
}
github {
isActive
}
}
}
}
}
`

interface Props {
menuProps: MenuProps
Expand All @@ -20,8 +44,12 @@ interface Props {

const TaskFooterTeamAssigneeMenu = (props: Props) => {
const {menuProps, task, viewer} = props
const {closePortal: closeTeamAssigneeMenu} = menuProps
const {userIds, teamIds} = useUserTaskFilters(viewer.id)
const {team, id: taskId} = task
const {team, id: taskId, integration} = task
const isGitHubTask = integration?.__typename === '_xGitHubIssue'
const isJiraTask = integration?.__typename === 'JiraIssue'

const {id: teamId} = team
const {teams} = viewer
const assignableTeams = useMemo(() => {
Expand All @@ -32,17 +60,66 @@ const TaskFooterTeamAssigneeMenu = (props: Props) => {
: teams
return filteredTeams
}, [teamIds, userIds])
const taskTeamIdx = useMemo(() => assignableTeams.map(({id}) => id).indexOf(teamId) + 1, [
const taskTeamIdx = useMemo(() => assignableTeams.findIndex(({id}) => id === teamId) + 1, [
teamId,
assignableTeams
])

const atmosphere = useAtmosphere()
const {submitting, submitMutation, onError, onCompleted} = useMutationProps()
const handleTaskUpdate = (newTeam) => () => {
if (!submitting && teamId !== newTeam.id) {

const onDialogClose = useEventCallback(() => {
closeTeamAssigneeMenu()
})
const {
modalPortal: addIntegrationModalPortal,
openPortal: openAddIntegrationPortal,
closePortal: closeAddIntegrationPortal
} = useModal({
onClose: onDialogClose,
id: 'taskFooterTeamAssigneeAddIntegration',
parentId: 'taskFooterTeamAssigneeMenu'
})
const [newTeam, setNewTeam] = useState({id: '', name: ''})

const handleAddIntegrationConfirmed = () => {
closeAddIntegrationPortal()
if (!newTeam.id) return

submitMutation()
ChangeTaskTeamMutation(atmosphere, {taskId, teamId: newTeam.id}, {onError, onCompleted})
setNewTeam({id: '', name: ''})
closeTeamAssigneeMenu()
}
const handleClose = () => {
closeAddIntegrationPortal()
closeTeamAssigneeMenu()
}

const handleTaskUpdate = (nextTeam: typeof newTeam) => async () => {
if (!submitting && teamId !== nextTeam.id) {
if (isGitHubTask || isJiraTask) {
const result = await atmosphere.fetchQuery<
mattkrick marked this conversation as resolved.
Show resolved Hide resolved
TaskFooterTeamAssigneeMenu_viewerIntegrationsQuery
>(query, {
teamId: nextTeam.id
})
const {github, atlassian} = result?.viewer?.teamMember?.integrations ?? {}

if ((isGitHubTask && !github?.isActive) || (isJiraTask && !atlassian?.isActive)) {
// viewer is not integrated, now we have these options:
// 1) if user has integration in source team, then we will use that, but still ask
// 2) if accessUser is someone else and they have integration for target team, then we will use that without asking
// 3) else we need to ask the user to integrate with complete oauth flow
// For now ignore 2) and 3) and just assume it's 1) as that's the most common case.
setNewTeam(nextTeam)
openAddIntegrationPortal()
return
}
}
submitMutation()
ChangeTaskTeamMutation(atmosphere, {taskId, teamId: newTeam.id}, {onError, onCompleted})
ChangeTaskTeamMutation(atmosphere, {taskId, teamId: nextTeam.id}, {onError, onCompleted})
closeTeamAssigneeMenu()
}
}

Expand All @@ -54,8 +131,25 @@ const TaskFooterTeamAssigneeMenu = (props: Props) => {
>
<DropdownMenuLabel>Move to:</DropdownMenuLabel>
{assignableTeams.map((team) => {
return <MenuItem key={team.id} label={team.name} onClick={handleTaskUpdate(team)} />
return (
<MenuItem
key={team.id}
label={team.name}
onClick={handleTaskUpdate(team)}
noCloseOnClick
/>
)
})}
{addIntegrationModalPortal(
(isGitHubTask || isJiraTask) && (
<TaskFooterTeamAssigneeAddIntegrationDialog
onClose={handleClose}
onConfirm={handleAddIntegrationConfirmed}
serviceName={isGitHubTask ? 'GitHub' : 'Jira'}
teamName={newTeam.name}
/>
)
)}
</Menu>
)
}
Expand All @@ -80,6 +174,9 @@ export default createFragmentContainer(TaskFooterTeamAssigneeMenu, {
team {
id
}
integration {
Dschoordsch marked this conversation as resolved.
Show resolved Hide resolved
__typename
}
}
`
})
Expand Up @@ -84,17 +84,22 @@ const TaskFooter = (props: Props) => {
const showTeam = area === USER_DASH
const {content, id: taskId, error, integration, tags, userId} = task
const isArchived = isTaskArchived(tags)
const canAssign = !integration && !isArchived
const canAssignUser = !integration && !isArchived
const canAssignTeam = !isArchived
return (
<React.Fragment>
<Footer>
<AvatarBlock>
{showTeam ? (
<TaskFooterTeamAssignee canAssign={canAssign} task={task} useTaskChild={useTaskChild} />
<TaskFooterTeamAssignee
canAssign={canAssignTeam}
task={task}
useTaskChild={useTaskChild}
/>
) : (
<TaskFooterUserAssignee
area={area}
canAssign={canAssign}
canAssign={canAssignUser}
cardIsActive={cardIsActive}
task={task}
useTaskChild={useTaskChild}
Expand Down