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: <JiraServerSVG />, + service: 'jiraServer', + label: 'Jira Server', + Component: JiraServerIntegrationPanel + } + ] + : []), {icon: <GitHubSVG />, service: 'github', label: 'GitHub', Component: GitHubIntegrationPanel}, {icon: <JiraSVG />, 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 ? ( + <JiraServerIntegrationResultsRoot teamId={teamMember.teamId} /> + ) : ( + <div className='-mt-14 flex h-full flex-col items-center justify-center gap-2'> + <div className='h-10 w-10'> + <img className='h-10 w-10' src={jiraServerSVG} /> + </div> + <b>Connect to Jira Server</b> + <div className='w-1/2 text-center text-sm'> + Connect to Jira Server to view your issues. + </div> + <button + className='mt-4 cursor-pointer rounded-full bg-sky-500 px-8 py-2 font-semibold text-white hover:bg-sky-600' + onClick={authJiraServer} + > + Connect + </button> + {error && <div className='text-tomato-500'>Error: {error.message}</div>} + </div> + )} + </> + ) +} + +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<JiraServerIntegrationResultsQuery> + 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 ( + <> + <div className='flex flex h-full flex-col gap-y-2 overflow-auto p-4'> + {jiraResults && jiraResults.length > 0 ? ( + jiraResults?.map((result, idx) => { + if (!result) { + return null + } + return <JiraServerObjectCard key={idx} resultRef={result} /> + }) + ) : ( + <div className='-mt-14 flex h-full flex-col items-center justify-center'> + <img className='w-20' src={halloweenRetrospectiveTemplate} /> + <div className='mt-7 w-2/3 text-center'> + {error?.message ? error.message : `Looks like you don’t have any issues to display.`} + </div> + <Link + to={`/team/${teamId}/integrations`} + className='mt-4 font-semibold text-sky-500 hover:text-sky-400' + > + Review your Jira Server configuration + </Link> + </div> + )} + {lastItem} + {hasNext && ( + <div className='mx-auto mb-4 -mt-4 h-8 text-2xl' key={'loadingNext'}> + <Ellipsis /> + </div> + )} + </div> + </> + ) +} + +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<JiraServerIntegrationResultsQuery>( + jiraIntegrationResultsQuery, + { + teamId: teamId + } + ) + return ( + <ErrorBoundary> + <Suspense fallback={<Loader />}> + {queryRef && <JiraServerIntegrationResults queryRef={queryRef} teamId={teamId} />} + </Suspense> + </ErrorBoundary> + ) +} + +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<HTMLDivElement>( + MenuPosition.UPPER_CENTER + ) + + const { + tooltipPortal: copiedTooltipPortal, + openTooltip: openCopiedTooltip, + closeTooltip: closeCopiedTooltip, + originRef: copiedTooltipRef + } = useTooltip<HTMLDivElement>(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 ( + <div className='rounded border border-solid border-slate-300 p-4 hover:border-slate-600'> + <div className='flex gap-2 text-xs text-slate-600'> + <a + href={url} + target='_blank' + className='font-semibold text-slate-600 hover:underline' + rel='noreferrer' + onClick={trackLinkClick} + > + {issueKey} + </a> + <div>Updated {relativeDate(updatedAt)}</div> + </div> + <div className='my-2 text-sm'> + <a + href={url} + target='_blank' + className='hover:underline' + rel='noreferrer' + onClick={trackLinkClick} + > + {summary} + </a> + </div> + <div className='flex items-center justify-between'> + <div className='flex items-center gap-2'> + <div className='h-4 w-4'> + <img src={jiraSVG} /> + </div> + <div className='text-xs text-slate-600'>{projectName}</div> + </div> + <CopyToClipboard text={url} onCopy={handleCopy}> + <div + className='h-6 rounded-full bg-transparent p-0 text-slate-500 hover:bg-slate-200' + onMouseEnter={openTooltip} + onMouseLeave={closeTooltip} + ref={mergeRefs(originRef, copiedTooltipRef)} + > + <Link className='h-6 w-6 cursor-pointer p-0.5' /> + </div> + </CopyToClipboard> + {tooltipPortal('Copy link')} + {copiedTooltipPortal('Copied!')} + </div> + </div> + ) +} + +export default JiraServerObjectCard diff --git a/packages/server/graphql/private/typeDefs/_legacy.graphql b/packages/server/graphql/private/typeDefs/_legacy.graphql index 0aeca980ef5..4bea1166dcc 100644 --- a/packages/server/graphql/private/typeDefs/_legacy.graphql +++ b/packages/server/graphql/private/typeDefs/_legacy.graphql @@ -1096,6 +1096,7 @@ type JiraServerIssue implements TaskIntegration { id: ID! issueKey: ID! projectKey: ID! + projectName: String! """ The parabol teamId this issue was fetched for @@ -1122,6 +1123,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<JiraServerIssueSource, GQLContext> 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<JiraServerIssueSource, GQLContext> .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