Skip to content

Commit

Permalink
Support for YouTrack Standalone
Browse files Browse the repository at this point in the history
This required a small API change for goToOauthPage().
  • Loading branch information
fschopp committed Jul 22, 2019
1 parent 09ec006 commit 6dec2d7
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 75 deletions.
143 changes: 107 additions & 36 deletions src/demo/demo.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { strict as assert } from 'assert';
import {
appendSchedule,
Failure,
Expand Down Expand Up @@ -34,12 +35,6 @@ type AlertKind = 'success' | 'warning';
const ALERT_KINDS: readonly AlertKind[] = Object.freeze(['success', 'warning']);


// Global state (sigh)

let hashFromShareLink: string = '';
let lastProjectPlan: ProjectPlan | undefined;


// HTML elements
// Implied assumption here is this script is loaded after all of the following elements (the <script> element is at the
// very end).
Expand All @@ -48,9 +43,12 @@ const feedback = document.getElementById('feedback') as HTMLDivElement;
const feedbackTitle: HTMLElement = feedback.querySelector('strong')!;
const feedbackMsg: HTMLElement = feedback.querySelector('span')!;
const inpBaseUrl = document.getElementById('baseUrl')! as HTMLInputElement;
const inpHubUrl = document.getElementById('hubUrl')! as HTMLInputElement;
const inpServiceId = document.getElementById('serviceId')! as HTMLInputElement;
const anchHubIntegrationLink = document.getElementById('hubIntegrationLink')! as HTMLAnchorElement;
const anchHubIntegrationLinks =
document.getElementsByClassName('hubIntegrationLink') as HTMLCollectionOf<HTMLAnchorElement>;
const spanCurrentUri = document.getElementById('currentUri')! as HTMLSpanElement;
const spanCurrentOrigin = document.getElementById('currentOrigin')! as HTMLSpanElement;
const anchHubConfiguration = document.getElementById('hubConfiguration')! as HTMLAnchorElement;
const anchGlobalSettingsLink = document.getElementById('globalSettingsLink')! as HTMLAnchorElement;
const btnConnect = document.getElementById('btnConnect')! as HTMLButtonElement;
Expand All @@ -67,23 +65,39 @@ const btnFuture = document.getElementById('btnFuture')! as HTMLButtonElement;
const divProgressBar = document.getElementById('progressBar')! as HTMLDivElement;
const preOutput = document.getElementById('output')! as HTMLPreElement;


// Global state (sigh)

let hashFromShareLink: string = '';
let lastProjectPlan: ProjectPlan | undefined;
let previousNormalizedBaseUrl: string = toNormalizedUrl(inpBaseUrl.value);


interface AppState {
baseUrl: string;
hubUrl: string;
serviceId: string;
youTrackInstance: string;
schedulingOptions: string;
isSplittableFn: string;
}

function verifiedBaseUrl(): string | undefined {
function toNormalizedUrl(urlString: string): string {
try {
const url = new URL(inpBaseUrl.value);
if (url.pathname.length === 0 || url.pathname.charAt(url.pathname.length - 1) !== '/') {
const url = new URL(urlString);
if (url.host.length === 0) {
// Oddly, Safari 12's implementation of the URL constructor does not throw on 'http:' or 'https:' whereas both
// Chrome and Firefox do.
return '';
} else if (url.pathname.length === 0 || url.pathname.charAt(url.pathname.length - 1) !== '/') {
url.pathname = url.pathname.concat('/');
}
return url.toString();
} catch (exception) {
return undefined;
if (!(exception instanceof TypeError)) {
throw exception;
}
return '';
}
}

Expand Down Expand Up @@ -118,27 +132,71 @@ function verifiedSchedulingOptions(): SchedulingOptions | undefined {
}
}

function onBaseUrlOrServiceIdChanged() {
const actualBaseUrl: string | undefined = verifiedBaseUrl();
if (actualBaseUrl !== undefined) {
anchHubIntegrationLink.setAttribute('href', new URL('youtrack/admin/ring', actualBaseUrl).toString());
anchGlobalSettingsLink.setAttribute('href', new URL('youtrack/admin/settings', actualBaseUrl).toString());
function hubUrlFromYouTrackBaseUrl(baseUrl: string): {hubUrl: string, isInCloudUrl: boolean} {
assert(baseUrl.length === 0 || baseUrl.endsWith('/'));

const inCloudMatch: RegExpMatchArray | null = baseUrl.match(/^(https:\/\/[^./]*\.myjetbrains\.com\/)youtrack\/$/);
let hubUrl: string;
let isInCloudUrl: boolean = false;
if (inCloudMatch !== null) {
// https://www.jetbrains.com/help/youtrack/incloud/OAuth-Authorization.html#HubOauthEndpoints
hubUrl = `${inCloudMatch[1]}hub`;
isInCloudUrl = true;
} else if (baseUrl.length > 0) {
// https://www.jetbrains.com/help/youtrack/standalone/OAuth-Authorization.html#HubOauthEndpoints
hubUrl = `${baseUrl}hub`;
} else {
anchHubIntegrationLink.removeAttribute('href');
hubUrl = '';
}
return {hubUrl, isInCloudUrl};
}

function onBaseUrlChanged() {
const normalizedBaseUrl = toNormalizedUrl(inpBaseUrl.value);
const {hubUrl, isInCloudUrl} = hubUrlFromYouTrackBaseUrl(normalizedBaseUrl);
inpHubUrl.disabled = isInCloudUrl;

if (inpHubUrl.value === '' || isInCloudUrl ||
inpHubUrl.value === hubUrlFromYouTrackBaseUrl(previousNormalizedBaseUrl).hubUrl) {
// The hub URL is currently the default one for the base URL. Keep in sync.
// If the current if-condition is not true, the user has modified the hub URL, so it shouldn't be updates.
previousNormalizedBaseUrl = normalizedBaseUrl;
inpHubUrl.value = hubUrl;
}
}

function foreach<T extends Element>(collection: HTMLCollectionOf<T>, fn: (element: T) => void) {
const length = collection.length;
for (let i = 0; i < length; ++i) {
fn(collection.item(i)!);
}
}

function onYouTrackConnectionParametersChanged() {
const normalizedBaseUrl: string = toNormalizedUrl(inpBaseUrl.value);
if (normalizedBaseUrl.length > 0) {
foreach(anchHubIntegrationLinks,
(anchor) => anchor.setAttribute('href', new URL('admin/ring', normalizedBaseUrl).toString()));
anchGlobalSettingsLink.setAttribute('href', new URL('admin/settings', normalizedBaseUrl).toString());
} else {
foreach(anchHubIntegrationLinks, (anchor) => anchor.removeAttribute('href'));
anchGlobalSettingsLink.removeAttribute('href');
}
if (actualBaseUrl !== undefined && inpServiceId.value.length > 0) {
if (normalizedBaseUrl.length > 0 && inpServiceId.value.length > 0) {
anchHubConfiguration.setAttribute('href',
new URL(`youtrack/admin/hub/services/${inpServiceId.value}?tab=settings`, actualBaseUrl).toString());
new URL(`admin/hub/services/${inpServiceId.value}?tab=settings`, normalizedBaseUrl).toString());
} else {
anchHubConfiguration.removeAttribute('href');
}
btnConnect.disabled = actualBaseUrl === undefined || inpServiceId.value.length === 0;
const normalizedHubUrl: string = toNormalizedUrl(inpHubUrl.value);
btnConnect.disabled = normalizedBaseUrl.length === 0 || normalizedHubUrl.length === 0 ||
inpServiceId.value.length === 0;
}

function getAppState(): AppState {
return {
baseUrl: inpBaseUrl.value,
hubUrl: inpHubUrl.value,
serviceId: inpServiceId.value,
youTrackInstance: textYouTrackConfig.value,
schedulingOptions: textSchedulingOptions.value,
Expand All @@ -147,8 +205,9 @@ function getAppState(): AppState {
}

function connect() {
// The button is only enabled if verifiedBaseUrl() returns a string.
goToOauthPage<AppState>(verifiedBaseUrl()!, inpServiceId.value, getAppState());
// The button is only enabled if toNormalizedUrl(inpHubUrl.value).length > 0 returns a string.
goToOauthPage<AppState>(toNormalizedUrl(inpBaseUrl.value), toNormalizedUrl(inpHubUrl.value), inpServiceId.value,
getAppState());
}

async function loadFromYouTrack<T>(baseUrl: string, relativePath: string, fields: string): Promise<T[]> {
Expand Down Expand Up @@ -210,13 +269,19 @@ function onReceivedYouTrackMetadata(baseUrl: string, customFields: CustomField[]
spanMinutesPerWorkWeek.textContent = minutesPerWorkWeek.toString();
}

function currentUri(): string {
function currentUri(): URL {
const uri = new URL(window.location.href);
uri.hash = '';
uri.username = '';
uri.password = '';
uri.search = '';
return uri.toString();
return uri;
}

function currentOrigin(): URL {
const uri = currentUri();
uri.pathname = '';
return uri;
}

function showAlert(title: string, message: string, alertKind: 'success' | 'warning'): void {
Expand Down Expand Up @@ -256,8 +321,11 @@ function shareLink(): void {

function loadAppState(appState: AppState): void {
inpBaseUrl.value = appState.baseUrl;
inpHubUrl.value = appState.hubUrl;
inpServiceId.value = appState.serviceId;
onBaseUrlOrServiceIdChanged();
previousNormalizedBaseUrl = toNormalizedUrl(appState.baseUrl);
onBaseUrlChanged();
onYouTrackConnectionParametersChanged();

textYouTrackConfig.value = appState.youTrackInstance;
textSchedulingOptions.value = appState.schedulingOptions;
Expand Down Expand Up @@ -358,10 +426,10 @@ async function computePastProjectPlanAndPrediction(baseUrl: string, youTrackConf
}

function scheduleFromActivityLog(): void {
const baseUrl: string | undefined = verifiedBaseUrl();
const baseUrl: string = toNormalizedUrl(inpBaseUrl.value);
const youTrackConfig: YouTrackConfig | undefined = verifiedYouTrackConfig();
const schedulingOptions: SchedulingOptions | undefined = verifiedSchedulingOptions();
if (baseUrl === undefined || youTrackConfig === undefined || schedulingOptions === undefined) {
if (baseUrl.length === 0 || youTrackConfig === undefined || schedulingOptions === undefined) {
return;
}

Expand Down Expand Up @@ -418,6 +486,7 @@ function freshAppState() {
};
const appState: AppState = {
baseUrl: '',
hubUrl: '',
serviceId: '',
youTrackInstance: JSON.stringify(youTrackInstance, undefined, 2),
schedulingOptions: JSON.stringify(schedulingOptions, undefined, 2),
Expand All @@ -429,27 +498,27 @@ function freshAppState() {
function resumeFromAppState(appState: AppState) {
loadAppState(appState);
// Not bullet-proof, but enough for this demo. We should only get here if the base URL is valid.
const actualBaseUrl: string = verifiedBaseUrl()!;
const actualBaseUrl: string = toNormalizedUrl(inpBaseUrl.value);
Promise
.all([
loadFromYouTrack<CustomField>(
actualBaseUrl,
'youtrack/api/admin/customFieldSettings/customFields',
'api/admin/customFieldSettings/customFields',
'fieldDefaults(bundle(id,values(id,name,isResolved,ordinal))),fieldType(id),id,name'
),
loadFromYouTrack<SavedQuery>(
actualBaseUrl,
'youtrack/api/savedQueries',
'api/savedQueries',
'id,name,owner(fullName)'
),
loadFromYouTrack<IssueLinkType>(
actualBaseUrl,
'youtrack/api/issueLinkTypes',
'api/issueLinkTypes',
'directed,id,name,sourceToTarget,targetToSource'
),
loadFromYouTrack<User>(
actualBaseUrl,
'youtrack/api/admin/users',
'api/admin/users',
'avatarUrl,id,fullName'
),
getMinutesPerWorkWeek(actualBaseUrl),
Expand All @@ -467,8 +536,10 @@ function resumeFromAppState(appState: AppState) {

// Set up events

inpBaseUrl.addEventListener('input', onBaseUrlOrServiceIdChanged);
inpServiceId.addEventListener('input', onBaseUrlOrServiceIdChanged);
inpBaseUrl.addEventListener('input', onBaseUrlChanged);
inpBaseUrl.addEventListener('input', onYouTrackConnectionParametersChanged);
inpHubUrl.addEventListener('input', onYouTrackConnectionParametersChanged);
inpServiceId.addEventListener('input', onYouTrackConnectionParametersChanged);
btnConnect.onclick = connect;
btnPast.onclick = scheduleFromActivityLog;
btnFuture.onclick = predict;
Expand All @@ -486,8 +557,8 @@ window.onhashchange = loadFromHash;


// Initialization
onBaseUrlOrServiceIdChanged();
spanCurrentUri.textContent = currentUri();
spanCurrentUri.textContent = currentUri().toString();
spanCurrentOrigin.textContent = currentOrigin().toString();
// Unfortunate workaround for Safari.
// https://github.com/fschopp/project-planning-for-you-track/issues/1
const DELAY_BEFORE_ACCESSING_SESSION_STORAGE_MS = 50;
Expand Down
21 changes: 14 additions & 7 deletions src/demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,18 @@ <h1 class="mt-5">Demo: Project Planning for YouTrack</h1>
<div class="form-group row">
<label for="baseUrl" class="col-form-label col-md-4 col-lg-3 text-md-right">YouTrack Base URL:</label>
<div class="col-md-8 col-lg-9">
<input id="baseUrl" type="text" class="form-control" aria-describedby="baseUrlHelp"
placeholder="Enter URL" />
<input id="baseUrl" type="text" class="form-control" aria-describedby="baseUrlHelp" />
<small id="baseUrlHelp" class="form-text text-muted">
For YouTrack InCloud, this is of form <code>https://&lt;name&gt;.myjetbrains.com/</code>. For a YouTrack Standalone installation, this is the “Base URL” shown at Server Settings &gt; Global Settings.
For YouTrack InCloud, enter the “Base URL” shown at Server Settings &gt; Domain Settings. The URL should be of form “https://&lt;your-domain&gt;/youtrack”. For YouTrack Standalone, enter the “Base URL” shown at Server Settings &gt; Global Settings.
</small>
</div>
</div>
<div class="form-group row">
<label for="hubUrl" class="col-form-label col-md-4 col-lg-3 text-md-right">Hub URL:</label>
<div class="col-md-8 col-lg-9">
<input id="hubUrl" type="text" class="form-control" aria-describedby="hubUrlHelp" />
<small id="hubUrlHelp" class="form-text text-muted">
For YouTrack InCloud without a custom domain, this setting is not configurable. Otherwise, enter the “Hub URL” shown at <a class="hubIntegrationLink" target="_blank">Server Settings &gt; Hub Integration</a>.
</small>
</div>
</div>
Expand All @@ -26,11 +34,10 @@ <h1 class="mt-5">Demo: Project Planning for YouTrack</h1>
YouTrack Service ID in Hub:
</label>
<div class="col-md-8 col-lg-9">
<input id="serviceId" type="text" class="form-control" aria-describedby="serviceIdHelp"
placeholder="Enter Service ID in Hub" />
<input id="serviceId" type="text" class="form-control" aria-describedby="serviceIdHelp" />
<small id="serviceIdHelp" class="form-text text-muted">
The YouTrack service ID is shown at
<a id="hubIntegrationLink" target="_blank">Server Settings &gt; Hub Integration</a>.
<a class="hubIntegrationLink" target="_blank">Server Settings &gt; Hub Integration</a>.
</small>
</div>
</div>
Expand All @@ -40,7 +47,7 @@ <h1 class="mt-5">Demo: Project Planning for YouTrack</h1>
Connect…
</button>
<small id="loginHelp" class="form-text text-muted">
If you are not logged into YouTrack yet, this will take you to the YouTrack login page. Once logged in, you will be redirected back here. Please note: The URI of this web app (that is, “<span id="currentUri"></span>”) needs to be registered in the <a id="hubConfiguration" target="_blank">Hub Settings</a> under “Redirect URIs”. The URI also needs to be added under “Allowed origins” at <a id="globalSettingsLink" target="_blank">Server Settings &gt; Global Settings</a>.
If you are not logged into YouTrack yet, this will take you to the YouTrack login page. Once logged in, you will be redirected back here. Please note: The URI of this web app (that is, “<span id="currentUri"></span>”) needs to be registered in the <a id="hubConfiguration" target="_blank">Hub Settings</a> under “Redirect URIs”. The current origin (that is, “<span id="currentOrigin"></span>”) also needs to be added under “Allowed origins” at <a id="globalSettingsLink" target="_blank">Server Settings &gt; Global Settings</a>.
</small>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/main/you-track-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ function isYouTrackError<T>(value: any): value is YouTrackError {
* immediately.
*
* @typeparam T the type of the response by YouTrack (after parsing the JSON)
* @param baseUrl The YouTrack base URL to which relative paths like `youtrack/api/...` or `hub/api/...` will be
* appended. The base URL is expected to end in a slash (/). For an InCloud instance, this is of form
* `https://<name>.myjetbrains.com/`.
* @param baseUrl The YouTrack base URL to which relative paths of form `api/...` will be appended. The base URL is
* expected to end in a slash (/). For an InCloud instance without a custom domain, this is of form
* `https://<name>.myjetbrains.com/youtrack/`.
* @param resourcePath relative path to the REST API resource requested
* @param queryParams parameters that will be added to the query string
* @return A promise that in case of success will be fulfilled with the retrieved object. In case of any failure, it
Expand Down
Loading

0 comments on commit 6dec2d7

Please sign in to comment.