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

ARC 2495 [BE] - Deferred Installation #2507

Merged
merged 16 commits into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions spa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@
"@atlaskit/heading": "^1.3.7",
"@atlaskit/icon": "^21.12.4",
"@atlaskit/lozenge": "^11.4.3",
"@atlaskit/modal-dialog": "^12.8.3",
"@atlaskit/page-header": "^10.4.4",
"@atlaskit/select": "^16.5.7",
"@atlaskit/skeleton": "^0.2.3",
"@atlaskit/spinner": "^15.6.1",
"@atlaskit/textarea": "^4.7.7",
"@atlaskit/tokens": "^1.11.1",
"@atlaskit/tooltip": "^17.8.3",
"@emotion/styled": "^11.11.0",
Expand Down
50 changes: 30 additions & 20 deletions spa/src/analytics/analytics-proxy-client.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,45 @@
import { AnalyticClient, ScreenEventProps, TrackEventProps, UIEventProps } from "./types";
import { axiosRest } from "../api/axiosInstance";
import { axiosRest, axiosRestWithNoJwt } from "../api/axiosInstance";
import { reportError } from "../utils";
const sendAnalytics = (eventType: string, eventProperties: Record<string, unknown>, eventAttributes?: Record<string, unknown>) => {
axiosRest.post(`/rest/app/cloud/analytics-proxy`,
{
eventType,
eventProperties,
eventAttributes
}).catch(e => {
reportError(e, {
path: "sendAnalytics",
eventType,
...eventProperties,
...eventAttributes
const sendAnalytics = (eventType: string, eventProperties: Record<string, unknown>, eventAttributes?: Record<string, unknown>, requestId?: string) => {
const eventData = {
eventType,
eventProperties,
eventAttributes
};
const eventError = {
path: "sendAnalytics",
eventType,
...eventProperties,
...eventAttributes
};

if (requestId) {
axiosRestWithNoJwt.post(`/rest/app/cloud/deferred/analytics-proxy/${requestId}`, eventData)
.catch(e => {
reportError(e, eventError);
});
});
} else {
axiosRest.post(`/rest/app/cloud/analytics-proxy`, eventData)
.catch(e => {
reportError(e, eventError);
});
}
};
export const analyticsProxyClient: AnalyticClient = {
sendScreenEvent: function(eventProps: ScreenEventProps, attributes?: Record<string, unknown>) {
sendAnalytics("screen", eventProps, attributes);
sendScreenEvent: function(eventProps: ScreenEventProps, attributes?: Record<string, unknown>, requestId?: string) {
sendAnalytics("screen", eventProps, attributes, requestId);
},
sendUIEvent: function (eventProps: UIEventProps, attributes?: Record<string, unknown>) {
sendUIEvent: function (eventProps: UIEventProps, attributes?: Record<string, unknown>, requestId?: string) {
sendAnalytics("ui", {
...eventProps,
source: "spa"
}, attributes);
}, attributes, requestId);
},
sendTrackEvent: function (eventProps: TrackEventProps, attributes?: Record<string, unknown>) {
sendTrackEvent: function (eventProps: TrackEventProps, attributes?: Record<string, unknown>, requestId?: string) {
sendAnalytics("track", {
...eventProps,
source: "spa"
}, attributes);
}, attributes, requestId);
}
};
18 changes: 12 additions & 6 deletions spa/src/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ type UIEventActionSubject =
| "connectOrganisation" | "installToNewOrganisation"
| "checkBackfillStatus"
| "dropExperienceViaBackButton"
| "checkOrgAdmin"
| "learnAboutIssueLinking" | "learnAboutDevelopmentWork";
| "learnAboutIssueLinking" | "learnAboutDevelopmentWork"
| "checkOrgAdmin" | "generateDeferredInstallationLink"
| "closedDeferredInstallationModal" | "copiedDeferredInstallationUrl"
| "signInAndConnectThroughDeferredInstallationStartScreen";

export type UIEventProps = {
actionSubject: UIEventActionSubject,
Expand All @@ -17,7 +19,11 @@ export type ScreenNames =
"StartConnectionEntryScreen"
| "AuthorisationScreen"
| "OrganisationConnectionScreen"
| "SuccessfulConnectedScreen";
| "SuccessfulConnectedScreen"
| "DeferredInstallationModal"
| "DeferredInstallationStartScreen"
| "DeferredInstallationFailedScreen"
| "DeferredInstallationSuccessScreen";

type TrackEventActionSubject =
"finishOAuthFlow"
Expand All @@ -35,8 +41,8 @@ export type ScreenEventProps = {
};

export type AnalyticClient = {
sendScreenEvent: (eventProps: ScreenEventProps, attributes?: Record<string, unknown>) => void;
sendUIEvent: (eventProps: UIEventProps, attributes?: Record<string, unknown>) => void;
sendTrackEvent: (eventProps: TrackEventProps, attributes?: Record<string, unknown>) => void;
sendScreenEvent: (eventProps: ScreenEventProps, attributes?: Record<string, unknown>, requestId?: string) => void;
sendUIEvent: (eventProps: UIEventProps, attributes?: Record<string, unknown>, requestId?: string) => void;
sendTrackEvent: (eventProps: TrackEventProps, attributes?: Record<string, unknown>, requestId?: string) => void;
};

6 changes: 3 additions & 3 deletions spa/src/api/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { GetRedirectUrlResponse, ExchangeTokenResponse } from "rest-interfaces";
import { axiosRest } from "../axiosInstance";
import { axiosRestWithNoJwt } from "../axiosInstance";

export default {
generateOAuthUrl: () => axiosRest.get<GetRedirectUrlResponse>("/rest/app/cloud/oauth/redirectUrl"),
exchangeToken: (code: string, state: string) => axiosRest.post<ExchangeTokenResponse>("/rest/app/cloud/oauth/exchangeToken", { code, state }),
generateOAuthUrl: () => axiosRestWithNoJwt.get<GetRedirectUrlResponse>("/rest/app/cloud/oauth/redirectUrl"),
exchangeToken: (code: string, state: string) => axiosRestWithNoJwt.post<ExchangeTokenResponse>("/rest/app/cloud/oauth/exchangeToken", { code, state }),
};
11 changes: 11 additions & 0 deletions spa/src/api/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getJiraJWT } from "../utils";

const THIRTY_SECONDS_IN_MS = 30_000;

const axiosRestWithNoJwt = axios.create({ timeout: THIRTY_SECONDS_IN_MS });
const axiosRest = axios.create({
timeout: THIRTY_SECONDS_IN_MS
});
Expand Down Expand Up @@ -47,9 +48,19 @@ axiosRestWithGitHubToken.interceptors.request.use(async (config) => {
return config;
});

const axiosRestWithNoJwtButWithGitHubToken = axios.create({
timeout: THIRTY_SECONDS_IN_MS
});
axiosRestWithNoJwtButWithGitHubToken.interceptors.request.use(async (config) => {
config.headers["github-auth"] = gitHubToken;
return config;
});

export {
axiosGitHub,
axiosRest,
axiosRestWithNoJwt,
axiosRestWithNoJwtButWithGitHubToken,
axiosRestWithGitHubToken,
clearGitHubToken,
setGitHubToken,
Expand Down
14 changes: 14 additions & 0 deletions spa/src/api/deferral/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {
DeferralParsedRequest,
DeferredInstallationUrlParams,
GetDeferredInstallationUrl
} from "rest-interfaces";
import { axiosRest, axiosRestWithNoJwt, axiosRestWithNoJwtButWithGitHubToken } from "../axiosInstance";

export default {
parseDeferredRequestId: (requestId: string) => axiosRestWithNoJwt.get<DeferralParsedRequest>(`/rest/app/cloud/deferred/parse/${requestId}`),
getDeferredInstallationUrl: (params: DeferredInstallationUrlParams) =>
axiosRest.get<GetDeferredInstallationUrl>("/rest/app/cloud/deferred/installation-url", { params }),
connectDeferredOrg: (requestId: string) => axiosRestWithNoJwtButWithGitHubToken.post(`/rest/app/cloud/deferred/connect/${requestId}`)
};

2 changes: 2 additions & 0 deletions spa/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import App from "./apps";
import Orgs from "./orgs";
import GitHub from "./github";
import Subscription from "./subscriptions";
import Deferral from "./deferral";

const ApiRequest = {
token: Token,
auth: Auth,
gitHub: GitHub,
app: App,
orgs: Orgs,
deferral: Deferral,
subscriptions: Subscription,
};

Expand Down
2 changes: 2 additions & 0 deletions spa/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import StartConnection from "./pages/StartConnection";
import ConfigSteps from "./pages/ConfigSteps";
import Connected from "./pages/Connected";
import InstallationRequested from "./pages/InstallationRequested";
import DeferredInstallation from "./pages/DeferredInstallation";
import Connections from "./pages/Connections";

import * as Sentry from "@sentry/react";
Expand Down Expand Up @@ -36,6 +37,7 @@ const App = () => {
<Route path="steps" element={<ConfigSteps/>}/>
<Route path="connected" element={<Connected />}/>
<Route path="installationRequested" element={<InstallationRequested />}/>
<Route path="deferred" element={<DeferredInstallation />}/>
</Route>
</SentryRoutes>
</BrowserRouter>
Expand Down
31 changes: 17 additions & 14 deletions spa/src/common/Wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,26 @@ const wrapperCenterStyle = css`
justify-content: center;
`;

const navigateToHomePage = () => {
analyticsClient.sendUIEvent({ actionSubject: "dropExperienceViaBackButton", action: "clicked" });
AP.getLocation((location: string) => {
const locationUrl = new URL(location);
AP.navigator.go( "site", { absoluteUrl: `${locationUrl.origin}/jira/marketplace/discover/app/com.github.integration.production` });
});
};
export const Wrapper = (attr: { hideClosedBtn?: boolean, children?: ReactNode | undefined }) => {
const navigateToHomePage = () => {
analyticsClient.sendUIEvent({ actionSubject: "dropExperienceViaBackButton", action: "clicked" });
AP.getLocation((location: string) => {
const locationUrl = new URL(location);
AP.navigator.go( "site", { absoluteUrl: `${locationUrl.origin}/jira/marketplace/discover/app/com.github.integration.production` });
});
};

export const Wrapper = (attr: { children?: ReactNode | undefined }) => {
return (
<div css={wrapperStyle}>
<Button
style={{ float: "right" }}
iconBefore={<CrossIcon label="Close" size="medium" />}
appearance="subtle"
onClick={navigateToHomePage}
/>
{
!attr.hideClosedBtn && <Button
style={{ float: "right" }}
iconBefore={<CrossIcon label="Close" size="medium" />}
appearance="subtle"
onClick={navigateToHomePage}
/>
}

<div css={wrapperCenterStyle}>{attr.children}</div>
</div>
);
Expand Down
130 changes: 122 additions & 8 deletions spa/src/components/Error/KnownErrors/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
/** @jsxImportSource @emotion/react */
import { useState } from "react";
import { css } from "@emotion/react";
import { token } from "@atlaskit/tokens";
import analyticsClient from "../../../analytics";
import { popup } from "../../../utils";
import { CheckAdminOrgSource, DeferredInstallationUrlParams } from "rest-interfaces";
import { HostUrlType } from "../../../utils/modifyError";
import Api from "../../../api";
import Modal, {
ModalBody,
ModalFooter,
ModalHeader,
ModalTitle,
ModalTransition,
} from "@atlaskit/modal-dialog";
import TextArea from "@atlaskit/textarea";
import Spinner from "@atlaskit/spinner";
import Button from "@atlaskit/button";

const olStyle = css`
padding-left: 1.2em;
`;
const paragraphStyle = css`
color: ${token("color.text.subtle")};
`;
Expand All @@ -15,6 +32,9 @@ const linkStyle = css`
padding-left: 0;
padding-right: 0;
`;
const textAreaStyle = css`
margin-top: 20px;
`;

/************************************************************************
* UI view for the 3 known errors
Expand All @@ -39,14 +59,108 @@ export const ErrorForSSO = ({ orgName, accessUrl, resetCallback, onPopupBlocked
</div>
</>;

export const ErrorForNonAdmins = ({ orgName, adminOrgsUrl }: { orgName?: string; adminOrgsUrl: string; }) => <div css={paragraphStyle}>
Can't connect, you're not the organization owner{orgName && <span> of <b>{orgName}</b></span>}.<br />
Ask an <a css={linkStyle} onClick={() => {
// TODO: Need to get this URL for Enterprise users too, this is only for Cloud users
popup(adminOrgsUrl);
analyticsClient.sendUIEvent({ actionSubject: "checkOrgAdmin", action: "clicked"}, { type: "cloud" });
}}>organization owner</a> to complete this step.
</div>;
export const ErrorForNonAdmins = ({ orgName, adminOrgsUrl, onPopupBlocked, deferredInstallationOrgDetails , hostUrl}: {
orgName?: string;
adminOrgsUrl: string;
onPopupBlocked: () => void;
deferredInstallationOrgDetails: DeferredInstallationUrlParams;
hostUrl?: HostUrlType;
}) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [deferredInstallationUrl, setDeferredInstallationUrl] = useState<string | null>(null);

const getOrgOwnerUrl = async (from: CheckAdminOrgSource) => {
// TODO: Need to get this URL for Enterprise users too, this is only for Cloud users
const win = popup(adminOrgsUrl);
if (win === null) onPopupBlocked();
analyticsClient.sendUIEvent({ actionSubject: "checkOrgAdmin", action: "clicked"}, { type: "cloud", from });
};

const getDeferredInstallationUrl = async () => {
if (!isOpen) {
analyticsClient.sendScreenEvent({ name: "DeferredInstallationModal" }, { type: "cloud" });
try {
setIsOpen(true);
setIsLoading(true);
const response = await Api.deferral.getDeferredInstallationUrl({
gitHubInstallationId: deferredInstallationOrgDetails?.gitHubInstallationId ,
gitHubOrgName: deferredInstallationOrgDetails?.gitHubOrgName
});
setDeferredInstallationUrl(response.data.deferredInstallUrl);
analyticsClient.sendUIEvent({ actionSubject: "generateDeferredInstallationLink", action: "clicked"}, { type: "cloud" });
} catch(e) {
// TODO: handle this error in UI/Modal ?
console.error("Could not fetch the deferred installation url: ", e);
} finally {
setIsLoading(false);
}
}
};

const closeModal = () => {
setIsOpen(false);
setDeferredInstallationUrl(null);
analyticsClient.sendUIEvent({ actionSubject: "closedDeferredInstallationModal", action: "clicked"}, { type: "cloud" });
};
return (
<div css={paragraphStyle}>
You’re not an owner for this organization. To connect:
<ol css={olStyle}>
<li>
<a css={linkStyle} onClick={() => getOrgOwnerUrl("ErrorInOrgList")}>
Find an organization owner.
</a>
</li>
<li>
<a css={linkStyle} onClick={getDeferredInstallationUrl}>
Send them a link and ask them to connect.
</a>
</li>
</ol>
<ModalTransition>
{isOpen && (
<Modal onClose={closeModal}>
{isLoading ? (
<Spinner interactionName="load" />
) : (
<>
<ModalHeader>
<ModalTitle>Send a link to an organization owner</ModalTitle>
</ModalHeader>
<ModalBody>
<div css={paragraphStyle}>
Copy the message and URL below, and send it to an
organization owner to approve.
<br />
<a css={linkStyle} onClick={() => getOrgOwnerUrl("DeferredInstallationModal")}>
Find an organization owner
</a>
</div>
<TextArea
onCopy={() => {
analyticsClient.sendUIEvent({ actionSubject: "copiedDeferredInstallationUrl", action: "clicked"}, { type: "cloud" });
}}
css={textAreaStyle}
id="deffered-installation-msg"
name="deffered-installation-msg"
defaultValue={`I want to connect the GitHub organization ${orgName} to the Jira site ${hostUrl?.jiraHost}, and I need your approval as an organization owner.\n\nIf you approve, can you go to this link and complete the connection?\n\n${deferredInstallationUrl}`}
readOnly
/>
</ModalBody>
<ModalFooter>
<Button appearance="primary" onClick={closeModal} autoFocus>
Close
</Button>
</ModalFooter>
</>
)}
</Modal>
)}
</ModalTransition>
</div>
);
};

export const ErrorForPopupBlocked = ({ onDismiss }: { onDismiss: () => void }) => (
<>
Expand Down
Loading
Loading