From acd959eda94a568a28d36e906e796fe0ac14ef47 Mon Sep 17 00:00:00 2001 From: Raghav Aggarwal Date: Tue, 5 Dec 2023 17:04:49 +0530 Subject: [PATCH] [GH-774] Add tooltip to Jira ticket links (#887) * [GH-774] Add Jira Ticket Tolltip * [GH-774] Add Jira Ticket Tooltip * [GH-774] Add Jira Ticket Tooltip * [GH-774] Add Jira Ticket Tolltip * [GH-774] Draft Update Styling With Fake Data Link PopOver * tooltip for jira link comment * tooltip for jira link comment * fix lint issue * fix ci issue * fix ci issue * fix ci issue * fix ci issue * fix ci issue * fix ci issue * fix ci issue * fix ci issue * fix ci issue * fix lint isssue * lint issue fix * fix lint issue * fix lint issue * fix lint issue * fix lint issue * fix lint issue * fix lint issue * fix lint issue * fix lint issue * added some debug statement * added some debug statement * fix overlap of state * fix overlap of state * fix overlap of state * fix lint issue * lint fix * fixed lint issue * resolved comments * resolved comments * resloved lint * temp push * temp push * fix logic * solved lint issue * solved lint issue * resolved comments * removed reducer flow to fetch jira ticket details * removed reducer flow to fetch jira ticket details * temp push * added props redux logic to handle state * fixed lint issue * [MI-2260] Add tooltip on jira ticket links * [MI-2260] Self review fixes * [MI-2260] Review fixes 1. Removed any types whereever possible. 2. Added condition to prevent unnecessary API call. * [MI-2260] Review fixes * [MI-2260] Review fixes * [MI-2260] Review fixes * [MI-2260] Updated babel config to use optional chaining * [MI-2260] Review fixes 1. Removed optional chaining 2. Updated className according to BEM * [MI-2260] Review fixes * [MI-2385] Fix review fixes for PR GH-774 * [MI-2385] Fix declaration of variables * [MI-2385] Fix lint error * [GH-774] Revert package-lock.json file changes. * [MI-2471] Review fixes on PR #887 by javaguirre * [MI-2471] Review fix on jira PR #887 by mickmister * [MI-2471] Review fix on PR #887 by mickmister 1. Wrapped the CSS into a parent ID so that it does not override with any other css * [MI-2471] Fix lint errors * [MI-2471] Replaced id with class * [MI-2471] Review fixes * [MI-2471] Review fixes * [MI-2543] Review fixes on Jira PR #887 (Jira ticket link tooltip) * [MI-2543] Changed reducer name * [GH-774] Review fixes * [MI-2612] Review fixes on Jira PR #887 * [MI-2612] Review fix * Done the review fixes of PR #887 (#37) * [MI-2700] Review fixes * [MI-2700]: Done the review fixes of PR #887 * [MI-2700]: Added EOF * [MI-2700]: Review fixes done 1. Improved code readability * [MI-2700]: Review fixes done 1. Added support for multiple themes. 2. Improved code quality * [MI-2700]: Review fixes done 1. Improved code quality * [MI-2700]: Removed white space from around the image loader * [MI-2700]: Review fixes done 1. Improved code quality * [MI-2700]: Review fixes done 1. Improved code readability 2. Improved comments --------- Co-authored-by: raghavaggarwal2308 * [MI-2944] Review fixes on Jira PR #887 (Add link tooltip) * [MI-2944] Fixed few testcases related comments * [MI-2944] Updated logic to store the actual ticket data in redux * [MI-2944] Review fixes * [MI-2988] Review fixes on Jira PR #887(Add link tooltip) 1. Created a separate file to get mock data for tickt details. 2. Separated out the logic to render skeleton loader. * [MI-2988] Review fix * [MI-3053] Review fixes on Jira PR #887 (Add link tooltip) 1. Replaced skeleton loader with spinner loader * [MI-3053] Removed unused css variable * [MI-3053] Fix spinner opacity and size * [MI-3064] Review fixes on jira PR #887(Add link tooltip) * [MI-3077] Review fixes on Jira PR #887 (Add link tooltip) * [MI-3077] Review fixes * [MI-3103] Review fixes on Jira PR #887 (Add link tooltip) --------- Co-authored-by: JuprianoAbelioGinting Co-authored-by: Kitty Co-authored-by: sibasankarnayak Co-authored-by: ayusht2810 Co-authored-by: Ayush Thakur <100013900+ayusht2810@users.noreply.github.com> Co-authored-by: Nityanand Rai <107465508+Nityanand13@users.noreply.github.com> --- server/constants.go | 8 + server/http.go | 3 + server/issue.go | 38 +++ webapp/src/action_types/index.js | 2 + webapp/src/actions/index.ts | 30 +- .../default_avatar/defaultAvatar.scss | 12 + .../default_avatar/default_avatar.tsx | 31 ++ .../components/jira_ticket_tooltip/index.ts | 24 ++ .../jira_ticket_tooltip.test.tsx | 94 ++++++ .../jira_ticket_tooltip.tsx | 295 ++++++++++++++++++ .../jira_ticket_tooltip/ticketStyle.scss | 243 +++++++++++++++ webapp/src/plugin.tsx | 2 + webapp/src/reducers/index.js | 13 + webapp/src/selectors/index.ts | 2 + .../get-ticket-metadata-for-tooltip.ts | 34 ++ webapp/src/types/tooltip.ts | 39 +++ webapp/src/utils/jira_issue_metadata.test.tsx | 48 ++- webapp/src/utils/jira_issue_metadata.tsx | 22 ++ 18 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 server/constants.go create mode 100644 webapp/src/components/default_avatar/defaultAvatar.scss create mode 100644 webapp/src/components/default_avatar/default_avatar.tsx create mode 100644 webapp/src/components/jira_ticket_tooltip/index.ts create mode 100644 webapp/src/components/jira_ticket_tooltip/jira_ticket_tooltip.test.tsx create mode 100644 webapp/src/components/jira_ticket_tooltip/jira_ticket_tooltip.tsx create mode 100644 webapp/src/components/jira_ticket_tooltip/ticketStyle.scss create mode 100644 webapp/src/testdata/get-ticket-metadata-for-tooltip.ts create mode 100644 webapp/src/types/tooltip.ts diff --git a/server/constants.go b/server/constants.go new file mode 100644 index 000000000..6261f9185 --- /dev/null +++ b/server/constants.go @@ -0,0 +1,8 @@ +package main + +const ( + HeaderMattermostUserID = "Mattermost-User-Id" + + ParamInstanceID = "instance_id" + ParamIssueKey = "issue_key" +) diff --git a/server/http.go b/server/http.go index 06cf5e1f9..a7f6177e3 100644 --- a/server/http.go +++ b/server/http.go @@ -59,6 +59,7 @@ const ( routeUserStart = "/user/start" routeUserConnect = "/user/connect" routeUserDisconnect = "/user/disconnect" + routeGetIssueByKey = "/get-issue-by-key" routeSharePublicly = "/share-issue-publicly" routeOAuth2Complete = "/oauth2/complete.html" ) @@ -98,6 +99,7 @@ func (p *Plugin) initializeRouter() { apiRouter := p.router.PathPrefix(routeAPI).Subrouter() + // Issue APIs apiRouter.HandleFunc(routeAPIGetAutoCompleteFields, p.checkAuth(p.handleResponse(p.httpGetAutoCompleteFields))).Methods(http.MethodGet) apiRouter.HandleFunc(routeAPICreateIssue, p.checkAuth(p.handleResponse(p.httpCreateIssue))).Methods(http.MethodPost) apiRouter.HandleFunc(routeAPIGetCreateIssueMetadata, p.checkAuth(p.handleResponse(p.httpGetCreateIssueMetadataForProjects))).Methods(http.MethodGet) @@ -107,6 +109,7 @@ func (p *Plugin) initializeRouter() { apiRouter.HandleFunc(routeAPIAttachCommentToIssue, p.checkAuth(p.handleResponse(p.httpAttachCommentToIssue))).Methods(http.MethodPost) apiRouter.HandleFunc(routeIssueTransition, p.handleResponse(p.httpTransitionIssuePostAction)).Methods(http.MethodPost) apiRouter.HandleFunc(routeSharePublicly, p.handleResponse(p.httpShareIssuePublicly)).Methods(http.MethodPost) + apiRouter.HandleFunc(routeGetIssueByKey, p.handleResponse(p.httpGetIssueByKey)).Methods(http.MethodGet) // User APIs apiRouter.HandleFunc(routeAPIUserInfo, p.checkAuth(p.handleResponse(p.httpGetUserInfo))).Methods(http.MethodGet) diff --git a/server/issue.go b/server/issue.go index d7bc62618..d56da1ff7 100644 --- a/server/issue.go +++ b/server/issue.go @@ -1033,3 +1033,41 @@ func (p *Plugin) getClient(instanceID, mattermostUserID types.ID) (Client, Insta } return client, instance, connection, nil } + +func (p *Plugin) httpGetIssueByKey(w http.ResponseWriter, r *http.Request) (int, error) { + if r.Method != http.MethodGet { + return respondErr(w, http.StatusMethodNotAllowed, fmt.Errorf("request: %s is not allowed, must be GET", r.Method)) + } + + mattermostUserID := r.Header.Get(HeaderMattermostUserID) + if mattermostUserID == "" { + return respondErr(w, http.StatusUnauthorized, errors.New("not authorized")) + } + + instanceID := r.FormValue(ParamInstanceID) + issueKey := r.FormValue(ParamIssueKey) + issue, err := p.GetIssueByKey(types.ID(instanceID), types.ID(mattermostUserID), issueKey) + if err != nil { + return respondErr(w, http.StatusInternalServerError, err) + } + + return respondJSON(w, issue) +} + +func (p *Plugin) GetIssueByKey(instanceID, mattermostUserID types.ID, issueKey string) (*jira.Issue, error) { + client, _, _, err := p.getClient(instanceID, mattermostUserID) + if err != nil { + return nil, err + } + + issue, err := client.GetIssue(issueKey, nil) + if err != nil { + switch StatusCode(err) { + case http.StatusNotFound: + return nil, errors.New("we couldn't find the issue key, or you do not have the appropriate permissions to view the issue. Please try again or contact your Jira administrator") + default: + return nil, errors.WithMessage(err, "request to Jira failed") + } + } + return issue, nil +} diff --git a/webapp/src/action_types/index.js b/webapp/src/action_types/index.js index 75e0806ee..c8ad0aca5 100644 --- a/webapp/src/action_types/index.js +++ b/webapp/src/action_types/index.js @@ -32,4 +32,6 @@ export default { RECEIVED_CHANNEL_SUBSCRIPTIONS: `${PluginId}_recevied_channel_subscriptions`, DELETED_CHANNEL_SUBSCRIPTION: `${PluginId}_deleted_channel_subscription`, + + RECEIVED_JIRA_TICKET: `${PluginId}_received_jira_ticket`, }; diff --git a/webapp/src/actions/index.ts b/webapp/src/actions/index.ts index c15759734..3022be061 100644 --- a/webapp/src/actions/index.ts +++ b/webapp/src/actions/index.ts @@ -9,7 +9,14 @@ import ActionTypes from 'action_types'; import {doFetch, doFetchWithResponse, buildQueryString} from 'client'; import {getPluginServerRoute, getInstalledInstances, getUserConnectedInstances} from 'selectors'; import {isDesktopApp, isMinimumDesktopAppVersion} from 'utils/user_agent'; -import {ChannelSubscription, CreateIssueRequest, SearchIssueParams, InstanceType, ProjectMetadata, APIResponse} from 'types/model'; +import { + APIResponse, + ChannelSubscription, + CreateIssueRequest, + InstanceType, + ProjectMetadata, + SearchIssueParams, +} from 'types/model'; export const openConnectModal = () => { return { @@ -509,3 +516,24 @@ export function sendEphemeralPost(message: string, channelId?: string) { }); }; } + +export const fetchIssueByKey = (issueKey: string, instanceID: string) => { + return async (dispatch, getState) => { + const baseUrl = getPluginServerRoute(getState()); + let data = null; + const params = `issue_key=${issueKey}&instance_id=${instanceID}`; + try { + data = await doFetch(`${baseUrl}/api/v2/get-issue-by-key?${params}`, { + method: 'get', + }); + + dispatch({ + type: ActionTypes.RECEIVED_JIRA_TICKET, + data, + }); + return {data}; + } catch (error) { + return {error}; + } + }; +}; diff --git a/webapp/src/components/default_avatar/defaultAvatar.scss b/webapp/src/components/default_avatar/defaultAvatar.scss new file mode 100644 index 000000000..79dae38ec --- /dev/null +++ b/webapp/src/components/default_avatar/defaultAvatar.scss @@ -0,0 +1,12 @@ +.jira-issue-tooltip { + .default-avatar { + background-color: #708090; + border-radius: 50%; + margin-right: 5px; + display: flex; + justify-content: center; + align-items: center; + width: 22px; + height: 22px; + } +} diff --git a/webapp/src/components/default_avatar/default_avatar.tsx b/webapp/src/components/default_avatar/default_avatar.tsx new file mode 100644 index 000000000..0a69b5748 --- /dev/null +++ b/webapp/src/components/default_avatar/default_avatar.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import './defaultAvatar.scss'; + +function DefaultAvatar() { + return ( + + + + + + + + + ); +} + +export default DefaultAvatar; diff --git a/webapp/src/components/jira_ticket_tooltip/index.ts b/webapp/src/components/jira_ticket_tooltip/index.ts new file mode 100644 index 000000000..5df7ab161 --- /dev/null +++ b/webapp/src/components/jira_ticket_tooltip/index.ts @@ -0,0 +1,24 @@ +import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch} from 'redux'; +import {GlobalState} from 'mattermost-redux/types/store'; + +import {jiraIssueToReducer} from 'utils/jira_issue_metadata'; + +import {isUserConnected, getStoredLinkTooltipIssue, getUserConnectedInstances, getDefaultUserInstanceID} from 'selectors'; +import {fetchIssueByKey} from 'actions'; + +import TicketPopover from './jira_ticket_tooltip'; + +const mapStateToProps = (state: GlobalState) => { + return { + connected: isUserConnected(state), + ticketDetails: jiraIssueToReducer(getStoredLinkTooltipIssue(state).ticket), + connectedInstances: getUserConnectedInstances(state), + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators({ + fetchIssueByKey, +}, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(TicketPopover); diff --git a/webapp/src/components/jira_ticket_tooltip/jira_ticket_tooltip.test.tsx b/webapp/src/components/jira_ticket_tooltip/jira_ticket_tooltip.test.tsx new file mode 100644 index 000000000..a8e35d841 --- /dev/null +++ b/webapp/src/components/jira_ticket_tooltip/jira_ticket_tooltip.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import {Instance, InstanceType} from 'types/model'; + +import TicketPopover, {Props} from './jira_ticket_tooltip'; + +describe('components/jira_ticket_tooltip', () => { + describe('getIssueKey', () => { + const mockConnectedInstances: Instance[] = [ + { + instance_id: 'https://something-1.atlassian.net', + type: InstanceType.CLOUD, + }, + { + instance_id: 'https://something-2.atlassian.net', + type: InstanceType.SERVER, + }, + ]; + + const mockProps1: Props = { + href: '', + show: false, + connected: false, + connectedInstances: mockConnectedInstances, + fetchIssueByKey: jest.fn(), + }; + + const mockProps2: Props = { + href: '', + show: false, + connected: false, + connectedInstances: [], + fetchIssueByKey: jest.fn(), + }; + + test('should return the expected output when URL matches the first regex pattern', () => { + const wrapper = shallow( + + ); + const instance = wrapper.instance() as TicketPopover; + const expectedOutput = {ticketID: 'TICKET-1234', instanceID: 'https://something-1.atlassian.net'}; + expect(instance.getIssueKey()).toEqual(expectedOutput); + }); + + test('should return the expected output when URL matches the second regex pattern', () => { + const wrapper = shallow( + + ); + const instance = wrapper.instance() as TicketPopover; + const expectedOutput = {ticketID: 'TICKET-1234', instanceID: 'https://something-2.atlassian.net'}; + expect(instance.getIssueKey()).toEqual(expectedOutput); + }); + + test('should return null when URL does not match any pattern', () => { + const wrapper = shallow( + + ); + const instance = wrapper.instance() as TicketPopover; + expect(instance.getIssueKey()).toEqual(null); + }); + + test('should return null when the URL does not contain the ticket ID', () => { + const wrapper = shallow( + + ); + const instance = wrapper.instance() as TicketPopover; + expect(instance.getIssueKey()).toEqual(null); + }); + + test('should return null when no instance is connected', () => { + const wrapper = shallow( + + ); + const instance = wrapper.instance() as TicketPopover; + expect(instance.getIssueKey()).toEqual(null); + }); + }); +}); diff --git a/webapp/src/components/jira_ticket_tooltip/jira_ticket_tooltip.tsx b/webapp/src/components/jira_ticket_tooltip/jira_ticket_tooltip.tsx new file mode 100644 index 000000000..872a999ec --- /dev/null +++ b/webapp/src/components/jira_ticket_tooltip/jira_ticket_tooltip.tsx @@ -0,0 +1,295 @@ +import React, {ReactNode} from 'react'; +import {Dispatch} from 'redux'; + +import {Instance} from 'types/model'; +import {TicketData, TicketDetails} from 'types/tooltip'; +import DefaultAvatar from 'components/default_avatar/default_avatar'; + +import './ticketStyle.scss'; + +export type Props = { + href: string; + show: boolean; + connected: boolean; + ticketDetails?: TicketDetails | null; + connectedInstances: Instance[]; + fetchIssueByKey: (issueKey: string, instanceID: string) => Promise; +} + +export type State = { + ticketId: string; + ticketDetails?: TicketDetails | null; +}; + +const isAssignedLabel = ' is assigned'; +const unAssignedLabel = 'Unassigned'; +const jiraTicketSummaryMaxLength = 80; +const maxTicketDescriptionLength = 160; + +enum myStatus { + INDETERMINATE = 'indeterminate', + DONE = 'done', +} + +const myStatusClasses: Record = { + [myStatus.INDETERMINATE]: 'ticket-status--indeterminate', + [myStatus.DONE]: 'ticket-status--done', +}; + +export default class TicketPopover extends React.PureComponent { + constructor(props: Props) { + super(props); + const issueKey = this.getIssueKey(); + let ticketID = ''; + if (issueKey) { + ticketID = issueKey.ticketID; + } + + this.state = { + ticketId: ticketID, + }; + } + + getIssueKey = () => { + let ticketID = ''; + let instanceID = ''; + + for (const instance of this.props.connectedInstances) { + instanceID = instance.instance_id; + + if (!this.props.href.includes(instanceID)) { + continue; + } + + // We already check href.includes above in the if statement before this try block + try { + const regex = /(https|http):\/\/.*\/.*\?.*selectedIssue=([\w-]+)&?.*|(https|http):\/\/.*\/browse\/([\w-]+)?.*/; + const result = regex.exec(this.props.href); + if (result) { + ticketID = result[2] || result[4]; + return {ticketID, instanceID}; + } + break; + } catch (e) { + break; + } + } + + return null; + } + + isUserConnectedAndStateNotLoaded() { + const {connected} = this.props; + const {ticketDetails} = this.state; + + return Boolean(connected && ticketDetails); + } + + componentDidMount() { + if (!this.state.ticketId) { + return; + } + + const {ticketDetails} = this.props; + const {ticketId} = this.state; + if (this.isUserConnectedAndStateNotLoaded() && ticketDetails && ticketDetails.ticketId === ticketId) { + this.setTicket(this.props); + } + } + + componentDidUpdate() { + const issueKey = this.getIssueKey(); + if (!issueKey) { + return; + } + + const {instanceID} = issueKey; + const {ticketDetails} = this.props; + const {ticketId, ticketDetails: localTicketDetails} = this.state; + + if (!localTicketDetails && ticketDetails && ticketDetails.ticketId === ticketId) { + this.setTicket(this.props); + } else if (!localTicketDetails && this.props.show && ticketId) { + this.props.fetchIssueByKey(ticketId, instanceID); + } + } + + setTicket(data: Props) { + this.setState({ + ticketDetails: data.ticketDetails, + }); + } + + fixVersionLabel(fixVersion: string) { + if (fixVersion) { + const fixVersionString = 'Fix Version :'; + return ( +
+ {fixVersionString} + + {fixVersion} + +
+ ); + } + + return null; + } + + tagTicketStatus(ticketStatus: string) { + let ticketStatusClass = 'default-style ticket-status--default'; + + const myStatusClass = myStatusClasses[ticketStatus.toLowerCase()]; + if (myStatusClass) { + ticketStatusClass = 'default-style ' + myStatusClass; + } + + return {ticketStatus}; + } + + renderLabelList(labels: string[]) { + if (!labels.length) { + return null; + } + + return ( +
+ { + labels.map((label: string, key: number): ReactNode => { + // Return an element for the first three labels and if there are more than three labels, then return a combined label for the remaining labels + if (key < 3) { + return ( + + {label} + ); + } + + if (key === labels.length - 1 && labels.length > 3) { + return ( + + {`+${labels.length - 3} more`} + ); + } + + return null; + }) + } +
+ ); + } + + render() { + if (!this.state.ticketId || (!this.state.ticketDetails && !this.props.show)) { + return null; + } + + const {ticketDetails} = this.state; + if (!ticketDetails) { + // Display the spinner loader while ticket details are being fetched + return ( +
+ +
+ ); + } + + return ( +
+ +
+
+ +
{ticketDetails.summary.substring(0, jiraTicketSummaryMaxLength)}
+
+ {this.tagTicketStatus(ticketDetails.statusKey)} +
+
+ {ticketDetails.description && `${ticketDetails.description.substring(0, maxTicketDescriptionLength).trim()}${ticketDetails.description.length > maxTicketDescriptionLength ? '...' : ''}`} +
+ +
+ {this.fixVersionLabel(ticketDetails.versions)} + {this.renderLabelList(ticketDetails.labels)} +
+
+
+ {ticketDetails.assigneeAvatar ? ( + jira assignee profile + ) : + } + {ticketDetails.assigneeName ? ( + + + {ticketDetails.assigneeName} + + + {isAssignedLabel} + + + ) : ( + + {unAssignedLabel} + + ) + } +
+
+ ); + } +} diff --git a/webapp/src/components/jira_ticket_tooltip/ticketStyle.scss b/webapp/src/components/jira_ticket_tooltip/ticketStyle.scss new file mode 100644 index 000000000..d50f6443d --- /dev/null +++ b/webapp/src/components/jira_ticket_tooltip/ticketStyle.scss @@ -0,0 +1,243 @@ +.jira-issue-tooltip { + display: flex; + max-width: 380px; + width: 380px; + flex-direction: column; + align-items: center; + left: 0; + right: 0; + color: var(--center-channel-color); + top: calc(50% - 311px / 2); + background: var(--center-channel-bg); + border: 1px solid rgba(63, 67, 80, 0.16); + border-radius: 4px; + box-shadow: 0 12px 32px 0 rgba(0, 0, 0, 0.12); + + .popover-header__keyword:hover { + color: var(--link-color); + text-decoration-line: underline; + } + + .popover-header { + height: 56px; + display: flex; + flex-direction: row; + align-self: stretch; + } + + .popover-header__container { + display: flex; + margin: 24px; + height: 20px; + align-items: center; + } + + .popover-header__avatar { + background-color: #282c34; + width: 20px; + position: absolute; + margin-left: 300px; + right: 24px; + top: 24px; + height: 20px; + border-radius: 50%; + } + + .popover-header__keyword { + display: flex; + flex-direction: row; + align-items: center; + font-weight: 600; + text-align: left; + font-size: 16px; + height: 16px; + line-height: 24px; + text-decoration: none; + } + + .popover-header__keyword>span { + position: static; + font-style: normal; + font-weight: normal; + line-height: 12px; + display: flex; + order: 1; + flex: none; + flex-grow: 0; + margin: 0 6px; + + } + + .popover-body { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + } + + .popover-body__title { + display: flex; + margin: 0 24px; + flex-direction: column; + align-items: flex-start; + } + + .popover-body__title>a:hover>h5 { + text-decoration-line: underline; + } + + .popover-body__title>a>h5 { + font-family: Metropolis, system-ui; + text-align: left; + flex-grow: 1; + text-decoration: none; + font-weight: 600; + font-size: 16px; + } + + .popover-body__title>a>h5 { + margin-block-start: 0; + margin-block-end: 0; + font-family: Metropolis, system-ui; + } + + .popover-body__description { + text-align: left; + font-family: 'Open Sans', system-ui; + font-style: normal; + font-weight: normal; + margin: 0 24px 8px 24px; + font-size: 12px; + line-height: 1.5; + width: 300px; + text-overflow: ellipsis; + } + + .popover-body__description>p { + margin-block-start: 0; + } + + .popover-body__see-more-link { + margin: 0 24px 8px 24px; + font-size: 12px; + font-weight: bold; + } + + .popover__labels__label { + display: flex; + flex-direction: row; + flex-wrap: wrap; + } + + .popover-body__labels { + margin: 0 24px; + } + + .popover-footer { + display: flex; + height: 60px; + flex-direction: row; + align-items: center; + position: static; + flex: none; + order: 3; + flex-grow: 0; + margin-top: 16px; + align-self: stretch; + border-top: 1px solid rgba(63, 67, 80, 0.16); + font-family: Open Sans; + padding: 0 24px; + font-size: 12px; + line-height: 16px; + } + + .popover-footer>span:nth-child(3) { + margin-left: 4px; + } + + .popover-footer__assignee-profile { + width: 20px; + height: 20px; + border-radius: 50px; + flex: none; + margin-right: 6px; + } + + .popover-footer__assignee-name { + font-weight: 600; + } + + .popover-labels__label-list { + font-size: 10px; + font-weight: 600; + margin: 5px 5px 5px 0; + padding: 0 4px; + background: rgba(var(--center-channel-color-rgb), 0.08); + border-radius: 2px; + line-height: 16px; + } + + .fix-version-label { + margin: 16px 0; + text-align: left; + font-family: open sans; + font-size: 10px; + padding: 0 0 2px 0; + } + + .fix-version-label-value { + padding: 1px 8px; + font-weight: 600; + border-radius: 2px; + } + + .default-style { + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 600; + font-size: 12px; + margin: 10px 0 8px 0; + padding: 4px 8px; + text-align: center; + border-radius: 4px; + } + + .ticket-status--indeterminate { + color: #FFFFFF; + background-color: #1C58D9; + border-radius: 2px; + } + + .ticket-status--done { + color: #FFFFFF; + background-color: var(--online-indicator); + } + + .ticket-status--default { + color: var(--center-channel-color-rgb); + background-color: rgba(var(--center-channel-color-rgb), 0.08); + } + + .jira-ticket-key-icon-loader { + min-width: 15px; + margin-right: 5px; + } + + .jira-ticket-key-loader { + min-width: 50px; + } + + .jira-ticket-key { + font-size: 12px; + } +} + +.jira-issue-tooltip-loading { + height: 210px; + font-size: 28px; + justify-content: center; + + .jira-issue-spinner { + opacity: 0.72; + } +} diff --git a/webapp/src/plugin.tsx b/webapp/src/plugin.tsx index fe098aa8b..893eea967 100644 --- a/webapp/src/plugin.tsx +++ b/webapp/src/plugin.tsx @@ -15,6 +15,7 @@ import ChannelSubscriptionsModal from 'components/modals/channel_subscriptions'; import AttachCommentToIssuePostMenuAction from 'components/post_menu_actions/attach_comment_to_issue'; import AttachCommentToIssueModal from 'components/modals/attach_comment_modal'; import SetupUI from 'components/setup_ui'; +import LinkTooltip from 'components/jira_ticket_tooltip'; import {id as PluginId} from './manifest'; @@ -37,6 +38,7 @@ const setupUILater = (registry: any, store: Store>): () = registry.registerPostDropdownMenuComponent(CreateIssuePostMenuAction); registry.registerRootComponent(AttachCommentToIssueModal); registry.registerPostDropdownMenuComponent(AttachCommentToIssuePostMenuAction); + registry.registerLinkTooltipComponent(LinkTooltip); } registry.registerRootComponent(ChannelSubscriptionsModal); diff --git a/webapp/src/reducers/index.js b/webapp/src/reducers/index.js index efed52332..d68df9726 100644 --- a/webapp/src/reducers/index.js +++ b/webapp/src/reducers/index.js @@ -193,6 +193,18 @@ const channelSubscriptions = (state = {}, action) => { } }; +const storedLinkTooltipIssue = (state = {}, action) => { + switch (action.type) { + case ActionTypes.RECEIVED_JIRA_TICKET : { + return { + ticket: action.data, + }; + } + default: + return state; + } +}; + export default combineReducers({ userConnected, userCanConnect, @@ -208,4 +220,5 @@ export default combineReducers({ attachCommentToIssueModalForPostId, channelIdWithSettingsOpen, channelSubscriptions, + storedLinkTooltipIssue, }); diff --git a/webapp/src/selectors/index.ts b/webapp/src/selectors/index.ts index 801c056e1..ab9da15ac 100644 --- a/webapp/src/selectors/index.ts +++ b/webapp/src/selectors/index.ts @@ -73,3 +73,5 @@ export const instanceIsInstalled = (state): boolean => getInstalledInstances(sta export const getDefaultUserInstanceID = (state) => getPluginState(state).defaultUserInstanceID; export const getPluginSettings = (state) => getPluginState(state).pluginSettings; + +export const getStoredLinkTooltipIssue = (state) => getPluginState(state).storedLinkTooltipIssue; diff --git a/webapp/src/testdata/get-ticket-metadata-for-tooltip.ts b/webapp/src/testdata/get-ticket-metadata-for-tooltip.ts new file mode 100644 index 000000000..6a0342897 --- /dev/null +++ b/webapp/src/testdata/get-ticket-metadata-for-tooltip.ts @@ -0,0 +1,34 @@ +import {IssueAction} from 'types/tooltip'; + +export const ticketData = (assigneeName: string | null): IssueAction => ({ + data: { + key: 'ABC-123', + fields: { + assignee: { + displayName: assigneeName || '', + avatarUrls: assigneeName ? { + '48x48': 'https://something.atlassian.net/avatar.png', + '16x16': 'https://something.atlassian.net/avatar.png', + '24x24': 'https://something.atlassian.net/avatar.png', + '36x36': 'https://something.atlassian.net/avatar.png', + } : '', + }, + labels: ['label1', 'label2'], + description: 'This is a test description', + summary: 'This is a test summary', + project: { + avatarUrls: { + '48x48': 'https://something.atlassian.net/project.png', + }, + }, + versions: ['Version 1.0', 'Version 2.0'], + status: { + name: 'In Progress', + }, + issuetype: { + iconUrl: 'https://something.atlassian.net/issuetype.png', + }, + }, + }, + type: 'mockType', +}); diff --git a/webapp/src/types/tooltip.ts b/webapp/src/types/tooltip.ts new file mode 100644 index 000000000..69af8f1a7 --- /dev/null +++ b/webapp/src/types/tooltip.ts @@ -0,0 +1,39 @@ +import {JiraUser} from './model'; + +export type TicketDetails = { + assigneeName: string; + assigneeAvatar: string; + labels: string[]; + description: string; + summary: string; + ticketId: string; + jiraIcon: string; + versions: string; + statusKey: string; + issueIcon: string; +} + +export type TicketData = { + key: string; + fields: TicketDataFields; +} + +export type AvatarUrls = { + '48x48': string; +} + +export type TicketDataFields = { + assignee: JiraUser | null; + labels: string[]; + description: string; + summary: string; + project: {avatarUrls: AvatarUrls}; + versions: string[]; + status: {name: string}; + issuetype: {iconUrl: string}; +} + +export type IssueAction = { + type: string; + data: TicketData; +} diff --git a/webapp/src/utils/jira_issue_metadata.test.tsx b/webapp/src/utils/jira_issue_metadata.test.tsx index a69cbdd96..7349a5020 100644 --- a/webapp/src/utils/jira_issue_metadata.test.tsx +++ b/webapp/src/utils/jira_issue_metadata.test.tsx @@ -2,11 +2,13 @@ // See LICENSE.txt for license information. import createMeta from 'testdata/cloud-get-create-issue-metadata-for-project-many-fields.json'; +import {ticketData} from 'testdata/get-ticket-metadata-for-tooltip'; import {useFieldForIssueMetadata} from 'testdata/jira-issue-metadata-helpers'; import {IssueMetadata, JiraField, FilterField, ChannelSubscriptionFilters, FilterFieldInclusion, IssueType, Project} from 'types/model'; +import {IssueAction, TicketDetails} from 'types/tooltip'; -import {getCustomFieldFiltersForProjects, generateJQLStringFromSubscriptionFilters, getConflictingFields} from './jira_issue_metadata'; +import {getCustomFieldFiltersForProjects, generateJQLStringFromSubscriptionFilters, getConflictingFields, jiraIssueToReducer} from './jira_issue_metadata'; describe('utils/jira_issue_metadata', () => { const useField = (field: JiraField, key: string): IssueMetadata => { @@ -590,4 +592,48 @@ describe('utils/jira_issue_metadata', () => { expect(actual).toEqual('Project = KT AND IssueType IN (Bug) AND Priority IS EMPTY'); }); }); + + describe('jiraIssueToReducer', () => { + it('should return the ticket details with all fields', () => { + const action: IssueAction = ticketData('Mock Name'); + + const expectedTicketDetails: TicketDetails = { + assigneeName: 'Mock Name', + assigneeAvatar: 'https://something.atlassian.net/avatar.png', + labels: ['label1', 'label2'], + description: 'This is a test description', + summary: 'This is a test summary', + ticketId: 'ABC-123', + jiraIcon: 'https://something.atlassian.net/project.png', + versions: 'Version 1.0', + statusKey: 'In Progress', + issueIcon: 'https://something.atlassian.net/issuetype.png', + }; + + const result = jiraIssueToReducer(action.data); + + expect(result).toEqual(expectedTicketDetails); + }); + + it('should return the ticket details with empty assignee fields when assignee is null', () => { + const action: IssueAction = ticketData(null); + + const expectedTicketDetails: TicketDetails = { + assigneeName: '', + assigneeAvatar: '', + labels: ['label1', 'label2'], + description: 'This is a test description', + summary: 'This is a test summary', + ticketId: 'ABC-123', + jiraIcon: 'https://something.atlassian.net/project.png', + versions: 'Version 1.0', + statusKey: 'In Progress', + issueIcon: 'https://something.atlassian.net/issuetype.png', + }; + + const result = jiraIssueToReducer(action.data); + + expect(result).toEqual(expectedTicketDetails); + }); + }); }); diff --git a/webapp/src/utils/jira_issue_metadata.tsx b/webapp/src/utils/jira_issue_metadata.tsx index 05ccd7c31..4917466ac 100644 --- a/webapp/src/utils/jira_issue_metadata.tsx +++ b/webapp/src/utils/jira_issue_metadata.tsx @@ -17,6 +17,7 @@ import { JiraFieldCustomTypeEnums, JiraFieldTypeEnums, } from 'types/model'; +import {IssueAction, TicketData, TicketDetails} from 'types/tooltip'; type FieldWithInfo = JiraField & { changeLogID: string; @@ -374,3 +375,24 @@ export function generateJQLStringFromSubscriptionFilters(issueMetadata: IssueMet return [projectJQL, issueTypesJQL, filterFieldsJQL].filter(Boolean).join(' AND '); } + +export function jiraIssueToReducer(data: TicketData): TicketDetails | null { + if (!data) { + return null; + } + + const assignee = data && data.fields && data.fields.assignee ? data.fields.assignee : null; + const ticketDetails: TicketDetails = { + assigneeName: (assignee && assignee.displayName) || '', + assigneeAvatar: (assignee && assignee.avatarUrls && assignee.avatarUrls['48x48']) || '', + labels: data.fields.labels, + description: data.fields.description, + summary: data.fields.summary, + ticketId: data.key, + jiraIcon: data.fields.project.avatarUrls && data.fields.project.avatarUrls['48x48'], + versions: data.fields.versions.length ? data.fields.versions[0] : '', + statusKey: data.fields.status.name, + issueIcon: data.fields.issuetype.iconUrl, + }; + return ticketDetails; +}