From eec025e3e22202c0c4c5630d2e6a75db76e3008f Mon Sep 17 00:00:00 2001 From: Georg Bremer Date: Thu, 30 May 2024 16:45:41 +0200 Subject: [PATCH] feat: Add Jira Server to Your Work (#9794) --- .../TeamPrompt/TeamPromptWorkDrawer.tsx | 27 ++++ .../WorkDrawer/JiraServerIntegrationPanel.tsx | 95 ++++++++++++++ .../JiraServerIntegrationResults.tsx | 115 +++++++++++++++++ .../JiraServerIntegrationResultsRoot.tsx | 31 +++++ .../WorkDrawer/JiraServerObjectCard.tsx | 121 ++++++++++++++++++ .../graphql/private/typeDefs/_legacy.graphql | 6 + .../graphql/types/JiraServerIntegration.ts | 9 +- .../server/graphql/types/JiraServerIssue.ts | 8 ++ .../jiraServer/JiraServerRestManager.ts | 2 + 9 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationPanel.tsx create mode 100644 packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationResults.tsx create mode 100644 packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationResultsRoot.tsx create mode 100644 packages/client/components/TeamPrompt/WorkDrawer/JiraServerObjectCard.tsx diff --git a/packages/client/components/TeamPrompt/TeamPromptWorkDrawer.tsx b/packages/client/components/TeamPrompt/TeamPromptWorkDrawer.tsx index 9f51d4e28cd..836d3aea0e2 100644 --- a/packages/client/components/TeamPrompt/TeamPromptWorkDrawer.tsx +++ b/packages/client/components/TeamPrompt/TeamPromptWorkDrawer.tsx @@ -8,12 +8,14 @@ import gcalLogo from '../../styles/theme/images/graphics/google-calendar.svg' import SendClientSideEvent from '../../utils/SendClientSideEvent' import GitHubSVG from '../GitHubSVG' import JiraSVG from '../JiraSVG' +import JiraServerSVG from '../JiraServerSVG' import ParabolLogoSVG from '../ParabolLogoSVG' import Tab from '../Tab/Tab' import Tabs from '../Tabs/Tabs' import GCalIntegrationPanel from './WorkDrawer/GCalIntegrationPanel' import GitHubIntegrationPanel from './WorkDrawer/GitHubIntegrationPanel' import JiraIntegrationPanel from './WorkDrawer/JiraIntegrationPanel' +import JiraServerIntegrationPanel from './WorkDrawer/JiraServerIntegrationPanel' import ParabolTasksPanel from './WorkDrawer/ParabolTasksPanel' interface Props { @@ -32,11 +34,26 @@ const TeamPromptWorkDrawer = (props: Props) => { ...GitHubIntegrationPanel_meeting ...JiraIntegrationPanel_meeting ...GCalIntegrationPanel_meeting + ...JiraServerIntegrationPanel_meeting + viewerMeetingMember { + teamMember { + teamId + integrations { + jiraServer { + sharedProviders { + id + } + } + } + } + } } `, meetingRef ) const atmosphere = useAtmosphere() + const hasJiraServer = + !!meeting.viewerMeetingMember?.teamMember?.integrations.jiraServer?.sharedProviders?.length useEffect(() => { SendClientSideEvent(atmosphere, 'Your Work Drawer Impression', { @@ -54,6 +71,16 @@ const TeamPromptWorkDrawer = (props: Props) => { label: 'Parabol', Component: ParabolTasksPanel }, + ...(hasJiraServer + ? [ + { + icon: , + service: 'jiraServer', + label: 'Jira Server', + Component: JiraServerIntegrationPanel + } + ] + : []), {icon: , service: 'github', label: 'GitHub', Component: GitHubIntegrationPanel}, {icon: , service: 'jira', label: 'Jira', Component: JiraIntegrationPanel}, { diff --git a/packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationPanel.tsx b/packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationPanel.tsx new file mode 100644 index 00000000000..da6eedf1a05 --- /dev/null +++ b/packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationPanel.tsx @@ -0,0 +1,95 @@ +import graphql from 'babel-plugin-relay/macro' +import React from 'react' +import {useFragment} from 'react-relay' +import {JiraServerIntegrationPanel_meeting$key} from '../../../__generated__/JiraServerIntegrationPanel_meeting.graphql' +import useAtmosphere from '../../../hooks/useAtmosphere' +import useMutationProps from '../../../hooks/useMutationProps' +import jiraServerSVG from '../../../styles/theme/images/graphics/jira-software-blue.svg' +import JiraServerClientManager from '../../../utils/JiraServerClientManager' +import SendClientSideEvent from '../../../utils/SendClientSideEvent' +import JiraServerIntegrationResultsRoot from './JiraServerIntegrationResultsRoot' + +interface Props { + meetingRef: JiraServerIntegrationPanel_meeting$key +} + +const JiraServerIntegrationPanel = (props: Props) => { + const {meetingRef} = props + const meeting = useFragment( + graphql` + fragment JiraServerIntegrationPanel_meeting on TeamPromptMeeting { + id + teamId + viewerMeetingMember { + teamMember { + teamId + integrations { + jiraServer { + auth { + id + isActive + } + sharedProviders { + id + } + } + } + } + } + } + `, + meetingRef + ) + + const teamMember = meeting.viewerMeetingMember?.teamMember + const integration = teamMember?.integrations.jiraServer + const providerId = integration?.sharedProviders?.[0]?.id + const isActive = !!integration?.auth?.isActive + + const atmosphere = useAtmosphere() + const mutationProps = useMutationProps() + const {error, onError} = mutationProps + + const authJiraServer = () => { + if (!teamMember || !providerId) { + return onError(new Error('Could not find integration provider')) + } + JiraServerClientManager.openOAuth(atmosphere, providerId, teamMember.teamId, mutationProps) + + SendClientSideEvent(atmosphere, 'Your Work Drawer Integration Connected', { + teamId: meeting.teamId, + meetingId: meeting.id, + service: 'jira server' + }) + } + if (!teamMember || !teamMember) { + return null + } + + return ( + <> + {isActive ? ( + + ) : ( +
+
+ +
+ Connect to Jira Server +
+ Connect to Jira Server to view your issues. +
+ + {error &&
Error: {error.message}
} +
+ )} + + ) +} + +export default JiraServerIntegrationPanel diff --git a/packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationResults.tsx b/packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationResults.tsx new file mode 100644 index 00000000000..3beed1d4c9c --- /dev/null +++ b/packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationResults.tsx @@ -0,0 +1,115 @@ +import graphql from 'babel-plugin-relay/macro' +import React from 'react' +import {PreloadedQuery, usePaginationFragment, usePreloadedQuery} from 'react-relay' +import {Link} from 'react-router-dom' +import halloweenRetrospectiveTemplate from '../../../../../static/images/illustrations/halloweenRetrospectiveTemplate.png' +import {JiraServerIntegrationResultsQuery} from '../../../__generated__/JiraServerIntegrationResultsQuery.graphql' +import {JiraServerIntegrationResultsSearchPaginationQuery} from '../../../__generated__/JiraServerIntegrationResultsSearchPaginationQuery.graphql' +import {JiraServerIntegrationResults_search$key} from '../../../__generated__/JiraServerIntegrationResults_search.graphql' +import useLoadNextOnScrollBottom from '../../../hooks/useLoadNextOnScrollBottom' +import Ellipsis from '../../Ellipsis/Ellipsis' +import JiraServerObjectCard from './JiraServerObjectCard' + +interface Props { + queryRef: PreloadedQuery + teamId: string +} + +const JiraServerIntegrationResults = (props: Props) => { + const {queryRef, teamId} = props + const query = usePreloadedQuery( + graphql` + query JiraServerIntegrationResultsQuery($teamId: ID!) { + ...JiraServerIntegrationResults_search @arguments(teamId: $teamId) + } + `, + queryRef + ) + + const paginationRes = usePaginationFragment< + JiraServerIntegrationResultsSearchPaginationQuery, + JiraServerIntegrationResults_search$key + >( + graphql` + fragment JiraServerIntegrationResults_search on Query + @argumentDefinitions( + cursor: {type: "String"} + count: {type: "Int", defaultValue: 20} + teamId: {type: "ID!"} + ) + @refetchable(queryName: "JiraServerIntegrationResultsSearchPaginationQuery") { + viewer { + teamMember(teamId: $teamId) { + integrations { + jiraServer { + issues( + first: $count + after: $cursor + isJQL: true + queryString: "assignee = currentUser() order by updated DESC" + ) @connection(key: "JiraServerScopingSearchResults_issues") { + error { + message + } + edges { + node { + ...JiraServerObjectCard_result + id + summary + url + issueKey + } + } + } + } + } + } + } + } + `, + query + ) + + const lastItem = useLoadNextOnScrollBottom(paginationRes, {}, 20) + const {data, hasNext} = paginationRes + + const jira = data.viewer.teamMember?.integrations.jiraServer + const jiraResults = jira?.issues.edges.map((edge) => edge.node) + const error = jira?.issues.error ?? null + + return ( + <> +
+ {jiraResults && jiraResults.length > 0 ? ( + jiraResults?.map((result, idx) => { + if (!result) { + return null + } + return + }) + ) : ( +
+ +
+ {error?.message ? error.message : `Looks like you don’t have any issues to display.`} +
+ + Review your Jira Server configuration + +
+ )} + {lastItem} + {hasNext && ( +
+ +
+ )} +
+ + ) +} + +export default JiraServerIntegrationResults diff --git a/packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationResultsRoot.tsx b/packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationResultsRoot.tsx new file mode 100644 index 00000000000..c58e4170e3d --- /dev/null +++ b/packages/client/components/TeamPrompt/WorkDrawer/JiraServerIntegrationResultsRoot.tsx @@ -0,0 +1,31 @@ +import React, {Suspense} from 'react' +import {Loader} from '~/utils/relay/renderLoader' +import jiraIntegrationResultsQuery, { + JiraServerIntegrationResultsQuery +} from '../../../__generated__/JiraServerIntegrationResultsQuery.graphql' +import useQueryLoaderNow from '../../../hooks/useQueryLoaderNow' +import ErrorBoundary from '../../ErrorBoundary' +import JiraServerIntegrationResults from './JiraServerIntegrationResults' + +interface Props { + teamId: string +} + +const JiraServerIntegrationResultsRoot = (props: Props) => { + const {teamId} = props + const queryRef = useQueryLoaderNow( + jiraIntegrationResultsQuery, + { + teamId: teamId + } + ) + return ( + + }> + {queryRef && } + + + ) +} + +export default JiraServerIntegrationResultsRoot diff --git a/packages/client/components/TeamPrompt/WorkDrawer/JiraServerObjectCard.tsx b/packages/client/components/TeamPrompt/WorkDrawer/JiraServerObjectCard.tsx new file mode 100644 index 00000000000..dd6da759a71 --- /dev/null +++ b/packages/client/components/TeamPrompt/WorkDrawer/JiraServerObjectCard.tsx @@ -0,0 +1,121 @@ +import {Link} from '@mui/icons-material' +import graphql from 'babel-plugin-relay/macro' +import React from 'react' +import CopyToClipboard from 'react-copy-to-clipboard' +import {useFragment} from 'react-relay' +import {JiraServerObjectCard_result$key} from '../../../__generated__/JiraServerObjectCard_result.graphql' +import useAtmosphere from '../../../hooks/useAtmosphere' +import {MenuPosition} from '../../../hooks/useCoords' +import useTooltip from '../../../hooks/useTooltip' +import jiraSVG from '../../../styles/theme/images/graphics/jira.svg' +import SendClientSideEvent from '../../../utils/SendClientSideEvent' +import relativeDate from '../../../utils/date/relativeDate' +import {mergeRefs} from '../../../utils/react/mergeRefs' + +interface Props { + resultRef: JiraServerObjectCard_result$key +} + +const JiraServerObjectCard = (props: Props) => { + const {resultRef} = props + + const result = useFragment( + graphql` + fragment JiraServerObjectCard_result on JiraServerIssue { + id + summary + url + issueKey + projectKey + projectName + updatedAt + } + `, + resultRef + ) + + const atmosphere = useAtmosphere() + + const {tooltipPortal, openTooltip, closeTooltip, originRef} = useTooltip( + MenuPosition.UPPER_CENTER + ) + + const { + tooltipPortal: copiedTooltipPortal, + openTooltip: openCopiedTooltip, + closeTooltip: closeCopiedTooltip, + originRef: copiedTooltipRef + } = useTooltip(MenuPosition.LOWER_CENTER) + + const trackLinkClick = () => { + SendClientSideEvent(atmosphere, 'Your Work Drawer Card Link Clicked', { + service: 'jira' + }) + } + + const trackCopy = () => { + SendClientSideEvent(atmosphere, 'Your Work Drawer Card Copied', { + service: 'jira' + }) + } + + const handleCopy = () => { + openCopiedTooltip() + trackCopy() + setTimeout(() => { + closeCopiedTooltip() + }, 2000) + } + + const {summary, url, issueKey, projectName, updatedAt} = result + + return ( +
+
+ + {issueKey} + +
Updated {relativeDate(updatedAt)}
+
+ +
+
+
+ +
+
{projectName}
+
+ +
+ +
+
+ {tooltipPortal('Copy link')} + {copiedTooltipPortal('Copied!')} +
+
+ ) +} + +export default JiraServerObjectCard diff --git a/packages/server/graphql/private/typeDefs/_legacy.graphql b/packages/server/graphql/private/typeDefs/_legacy.graphql index a5aa1e4ff48..406fd1d39c0 100644 --- a/packages/server/graphql/private/typeDefs/_legacy.graphql +++ b/packages/server/graphql/private/typeDefs/_legacy.graphql @@ -1095,6 +1095,7 @@ type JiraServerIssue implements TaskIntegration { id: ID! issueKey: ID! projectKey: ID! + projectName: String! """ The parabol teamId this issue was fetched for @@ -1121,6 +1122,11 @@ type JiraServerIssue implements TaskIntegration { The description converted into raw HTML """ descriptionHTML: String! + + """ + The timestamp the issue was last updated + """ + updatedAt: DateTime! } """ diff --git a/packages/server/graphql/types/JiraServerIntegration.ts b/packages/server/graphql/types/JiraServerIntegration.ts index 2f4f3968b74..ec349fa98b1 100644 --- a/packages/server/graphql/types/JiraServerIntegration.ts +++ b/packages/server/graphql/types/JiraServerIntegration.ts @@ -92,7 +92,7 @@ const JiraServerIntegration = new GraphQLObjectType<{teamId: string; userId: str }, after: { type: GraphQLString, - defaultValue: '0' + defaultValue: '-1' }, queryString: { type: GraphQLString, @@ -162,21 +162,22 @@ const JiraServerIntegration = new GraphQLObjectType<{teamId: string; userId: str const {issues} = issueRes const mappedIssues = issues.map((issue) => { - const {project, issuetype, summary, description} = issue.fields + const {project, issuetype, summary, description, updated} = issue.fields return { ...issue, userId, teamId, providerId: provider.id, issueKey: issue.key, + description: description ?? '', descriptionHTML: issue.renderedFields.description, projectId: project.id, projectKey: project.key, + projectName: project.name, issueType: issuetype.id, summary, - description, service: 'jiraServer' as const, - updatedAt: new Date() + updatedAt: new Date(updated) } }) diff --git a/packages/server/graphql/types/JiraServerIssue.ts b/packages/server/graphql/types/JiraServerIssue.ts index 6d4a583c68a..3047f751c28 100644 --- a/packages/server/graphql/types/JiraServerIssue.ts +++ b/packages/server/graphql/types/JiraServerIssue.ts @@ -3,6 +3,7 @@ import JiraServerIssueId from '~/shared/gqlIds/JiraServerIssueId' import {JiraServerIssue as JiraServerRestIssue} from '../../dataloader/jiraServerLoaders' import connectionDefinitions from '../connectionDefinitions' import {GQLContext} from '../graphql' +import GraphQLISO8601Type from './GraphQLISO8601Type' import StandardMutationError from './StandardMutationError' import TaskIntegration from './TaskIntegration' @@ -40,6 +41,9 @@ const JiraServerIssue = new GraphQLObjectType projectKey: { type: new GraphQLNonNull(GraphQLID) }, + projectName: { + type: new GraphQLNonNull(GraphQLString) + }, teamId: { type: new GraphQLNonNull(GraphQLID), description: 'The parabol teamId this issue was fetched for' @@ -84,6 +88,10 @@ const JiraServerIssue = new GraphQLObjectType .map(({name}) => name) return fieldNames } + }, + updatedAt: { + type: new GraphQLNonNull(GraphQLISO8601Type), + description: 'The timestamp the issue was last updated' } }) }) diff --git a/packages/server/integrations/jiraServer/JiraServerRestManager.ts b/packages/server/integrations/jiraServer/JiraServerRestManager.ts index 52416b07746..36b024379a1 100644 --- a/packages/server/integrations/jiraServer/JiraServerRestManager.ts +++ b/packages/server/integrations/jiraServer/JiraServerRestManager.ts @@ -57,7 +57,9 @@ export interface JiraServerIssue { id: string key: string name: string + self: string } + updated: string } renderedFields: { description: string