diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 00000000000..331f2e048ce --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,31 @@ +# 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. +There are up to 3 parties which can provide integration authentication for a task: + +- **viewer** - the user who is performing the action +- **assignee** - the user assigned to the task +- **access user** - the user who's credentials were used to add the integration to the task + +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 or the access user'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, assignee's auth or access user'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) +- [ ] **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 **access user** has an integration set up for the target team and move the task if present, otherwise we report an error diff --git a/packages/client/hooks/useMenu.ts b/packages/client/hooks/useMenu.ts index bde02b8b6f4..9102c6c724e 100644 --- a/packages/client/hooks/useMenu.ts +++ b/packages/client/hooks/useMenu.ts @@ -18,6 +18,9 @@ export interface MenuProps { isDropdown: boolean } +/** + * Wrapper around {@link usePortal} to display menus + */ const useMenu = ( preferredMenuPosition: MenuPosition, options: Options = {} diff --git a/packages/client/hooks/useMenuPortal.tsx b/packages/client/hooks/useMenuPortal.tsx index 14ee4e5a297..24bcf21335b 100644 --- a/packages/client/hooks/useMenuPortal.tsx +++ b/packages/client/hooks/useMenuPortal.tsx @@ -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, diff --git a/packages/client/hooks/useModal.ts b/packages/client/hooks/useModal.ts index 25c751e7111..f1508beba26 100644 --- a/packages/client/hooks/useModal.ts +++ b/packages/client/hooks/useModal.ts @@ -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(null) diff --git a/packages/client/hooks/usePortal.tsx b/packages/client/hooks/usePortal.tsx index dd3ab6ae979..a1872883682 100644 --- a/packages/client/hooks/usePortal.tsx +++ b/packages/client/hooks/usePortal.tsx @@ -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 @@ -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() const originRef = useRef() @@ -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) } }) diff --git a/packages/client/modules/outcomeCard/components/OutcomeCardAssignMenu/TaskFooterTeamAssigneeAddIntegrationDialog.tsx b/packages/client/modules/outcomeCard/components/OutcomeCardAssignMenu/TaskFooterTeamAssigneeAddIntegrationDialog.tsx new file mode 100644 index 00000000000..677181ad566 --- /dev/null +++ b/packages/client/modules/outcomeCard/components/OutcomeCardAssignMenu/TaskFooterTeamAssigneeAddIntegrationDialog.tsx @@ -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 ( + + + {serviceName} integration for {teamName} + + +
+ + You don't have {serviceName} configured for {teamName}. Do you want to add it now? + + + + Cancel + + + Add it now + + +
+
+
+ ) +} + +export default TaskFooterTeamAssigneeAddIntegrationDialog diff --git a/packages/client/modules/outcomeCard/components/OutcomeCardAssignMenu/TaskFooterTeamAssigneeMenu.tsx b/packages/client/modules/outcomeCard/components/OutcomeCardAssignMenu/TaskFooterTeamAssigneeMenu.tsx index 2661f3f65a6..4f1ecd69725 100644 --- a/packages/client/modules/outcomeCard/components/OutcomeCardAssignMenu/TaskFooterTeamAssigneeMenu.tsx +++ b/packages/client/modules/outcomeCard/components/OutcomeCardAssignMenu/TaskFooterTeamAssigneeMenu.tsx @@ -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' @@ -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 @@ -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(() => { @@ -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< + 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() } } @@ -54,8 +131,25 @@ const TaskFooterTeamAssigneeMenu = (props: Props) => { > Move to: {assignableTeams.map((team) => { - return + return ( + + ) })} + {addIntegrationModalPortal( + (isGitHubTask || isJiraTask) && ( + + ) + )} ) } @@ -80,6 +174,9 @@ export default createFragmentContainer(TaskFooterTeamAssigneeMenu, { team { id } + integration { + __typename + } } ` }) diff --git a/packages/client/modules/outcomeCard/components/OutcomeCardFooter/TaskFooter.tsx b/packages/client/modules/outcomeCard/components/OutcomeCardFooter/TaskFooter.tsx index 378a096cb86..43b1a75ec1d 100644 --- a/packages/client/modules/outcomeCard/components/OutcomeCardFooter/TaskFooter.tsx +++ b/packages/client/modules/outcomeCard/components/OutcomeCardFooter/TaskFooter.tsx @@ -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 (