import { LitElement, html, css, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import * as api from '../api';
import '@shoelace-style/shoelace/dist/components/dialog/dialog.js';
import '@shoelace-style/shoelace/dist/components/button/button.js';
import '@shoelace-style/shoelace/dist/components/input/input.js';
import type SlInput from '@shoelace-style/shoelace/dist/components/input/input.js';
import type SlTreeItem from '@shoelace-style/shoelace/dist/components/tree-item/tree-item.js';
import '@shoelace-style/shoelace/dist/components/icon/icon.js';
import '@shoelace-style/shoelace/dist/components/select/select.js';
import '@shoelace-style/shoelace/dist/components/option/option.js';
import '@shoelace-style/shoelace/dist/components/checkbox/checkbox.js';
import '@shoelace-style/shoelace/dist/components/tree/tree.js';
import '@shoelace-style/shoelace/dist/components/tree-item/tree-item.js';
import '@shoelace-style/shoelace/dist/components/alert/alert.js';
import '@shoelace-style/shoelace/dist/components/card/card.js';
@customElement('add-tracker-modal')
export class AddTrackerModal extends LitElement {
@property({ type: Object })
tracker: any = null;
/**
* @internal
*/
_api = api;
@property({ type: Boolean })
opened = true;
@state()
private step = 1;
@state()
private trackerName = '';
@state()
private trackerType = 'github';
@state()
private trackerUrl = 'https://api.github.com';
@state()
private trackerToken = '';
@state()
private trackerUsername = '';
@state()
private orgs: any[] = [];
@state()
private projects: Record<string, any[]> = {};
@state()
private selectedOrgs: Record<string, boolean> = {};
@state()
private selectedProjects: Record<string, Record<string, boolean>> = {};
@state()
private areAllProjectsSelected = false;
@state()
private includeFutureProjects = true;
@state()
private isLoading = false;
@state()
private errorMessage = '';
@state()
private warningMessages: string[] = [];
@state()
private githubAppConfigured = false;
@state()
private authMethod: 'api_token' | 'github_app' = 'api_token';
// Properties passed from trackers-view after GitHub OAuth callback
@property({ type: String })
githubInstallationId: string | null = null;
@property({ type: String })
githubTargetLogin: string | null = null;
static styles = css`
.error {
color: var(--sl-color-danger-700);
}
sl-input,
sl-select {
margin-bottom: 1rem;
}
.select-all {
margin-bottom: 1rem;
margin-left: 0.5rem;
}
.include-future {
margin-left: 0.5rem;
margin-top: 1rem;
}
`;
connectedCallback() {
super.connectedCallback();
if (this.tracker) {
this.trackerName = this.tracker.name;
this.trackerType = this.tracker.tracker_type;
this.trackerUrl = this.tracker.url;
this.trackerToken = 'unchanged';
this.trackerUsername = this.tracker.connection_details?.username;
this.authMethod = this.tracker.auth_type || 'api_token';
// For GitHub App auth, initialize installation ID from tracker
if (
this.tracker.auth_type === 'github_app' &&
this.tracker.oauth_installation_id
) {
this.githubInstallationId = String(this.tracker.oauth_installation_id);
}
this.selectedOrgs = this.tracker.scope_rules
.filter(
(x: any) => x.rule_type == 'INCLUDE' && x.scope_type == 'ORGANIZATION'
)
.reduce((acc: any, x: any) => {
acc[x.identifier] = true;
return acc;
}, {});
this.selectedProjects = {};
// Object.keys(this.selectedOrgs).forEach((orgId: any) => {
// this.selectedProjects[orgId] = {};
// this.projects[orgId]?.forEach((project: any) => {
// if (this.tracker.scope_rules.filter((x: any) => x.scope_type == 'PROJECT' && x.rule_type == 'INCLUDE' && x.identifier == project.id).length ||
// !this.tracker.scope_rules.filter((x: any) => x.scope_type == 'PROJECT' && x.rule_type == 'EXCLUDE' && x.identifier == project.id).length) {
// this.selectedProjects[orgId][project.id] = true;
// }
// });
// });
this.includeFutureProjects = !this.tracker.scope_rules.filter(
(x: any) => x.rule_type == 'INCLUDE' && x.scope_type == 'PROJECT'
).length;
// Token is not pre-filled for security reasons
if (this.tracker) {
this.trackerToken = 'unchanged';
}
}
// Check if GitHub App OAuth is available
this.checkGitHubAppAvailability();
// If we have a GitHub installation ID from OAuth callback (not editing), set up for GitHub App flow
// Only apply these defaults for new trackers, not when editing existing ones
if (this.githubInstallationId && !this.tracker) {
this.trackerType = 'github';
this.authMethod = 'github_app';
this.trackerUrl = 'https://github.com';
if (this.githubTargetLogin) {
this.trackerName = `GitHub - ${this.githubTargetLogin}`;
}
// Skip auth method selection, go directly to step 1
this.step = 1;
}
}
async checkGitHubAppAvailability() {
try {
const authMethods = await this._api.getTrackerAuthMethods();
this.githubAppConfigured = authMethods.github_app_configured;
// Don't auto-show auth selection on load - user should see the tracker form first
// Auth selection is triggered when user clicks "Next" with GitHub selected
} catch (error) {
console.error('Failed to check GitHub App availability:', error);
this.githubAppConfigured = false;
}
}
firstUpdated() {
// Reset state when modal is shown
this.warningMessages = [];
this.errorMessage = '';
this.shadowRoot?.querySelector('sl-dialog')?.show();
setTimeout(() => {
const input = this.shadowRoot?.querySelector<SlInput>('sl-input');
input?.focus();
}, 100);
}
render() {
return html`
<sl-dialog
label="${this.tracker ? 'Edit' : 'Add'} Tracker"
@sl-request-close=${() => this.closeModal()}
>
${this.step === 1 ? this.renderStep1() : this.renderStep2()}
${this.warningMessages.length > 0
? html`
<sl-alert variant="warning" open style="margin-top: 1rem;">
<sl-icon slot="icon" name="exclamation-triangle"></sl-icon>
<strong>Warnings:</strong>
<ul style="margin: 0.5rem 0 0 0; padding-left: 1.5rem;">
${this.warningMessages.map((w) => html`<li>${w}</li>`)}
</ul>
</sl-alert>
`
: ''}
${this.errorMessage
? html`<p class="error">${this.errorMessage}</p>`
: ''}
<div slot="footer">${this.renderFooterButtons()}</div>
</sl-dialog>
`;
}
renderFooterButtons() {
// Show "Done" button after successful save with warnings
if (this.warningMessages.length > 0) {
return html`
<sl-button variant="primary" @click=${() => this.closeModal(true)}>
Done
</sl-button>
`;
}
if (this.step === 1) {
return html`
<sl-button @click=${() => this.closeModal()}>Cancel</sl-button>
<sl-button
variant="primary"
@click=${this.testConnection}
.loading=${this.isLoading}
>Next</sl-button
>
`;
}
return html`
<sl-button @click=${() => (this.step = 1)}>Back</sl-button>
<sl-button
variant="primary"
@click=${this.handleSave}
.loading=${this.isLoading}
>
${this.tracker ? 'Save' : 'Add'}
</sl-button>
`;
}
async startGitHubOAuth() {
this.isLoading = true;
this.errorMessage = '';
try {
const { authorization_url, state } = await this._api.getGitHubAuthUrl();
// Store state for CSRF validation on callback
sessionStorage.setItem('github_oauth_state', state);
// Redirect to GitHub
window.location.href = authorization_url;
} catch (error: any) {
this.errorMessage = error.message || 'Failed to start GitHub OAuth';
this.authMethod = 'api_token'; // Fall back to API token
} finally {
this.isLoading = false;
}
}
renderStep1() {
return html`
<sl-input
label="Name"
name="name"
.value=${this.trackerName}
@sl-input=${(e: any) => (this.trackerName = e.target.value)}
required
tabindex="0"
autofocus
></sl-input>
<sl-select
label="Type"
name="type"
.value=${this.trackerType}
@sl-change=${(e: any) => {
this.trackerType = e.target.value;
const urlInput = this.shadowRoot?.querySelector(
'sl-input[name="url"]'
) as HTMLInputElement;
if (this.trackerType === 'gitlab') {
this.trackerUrl = 'https://gitlab.com';
if (urlInput) {
urlInput.placeholder = 'e.g., https://gitlab.example.com';
}
} else if (this.trackerType === 'github') {
this.trackerUrl = 'https://github.com';
if (urlInput) {
urlInput.placeholder = 'e.g., https://github.example.com';
}
// Auth method selection is shown when clicking "Next" via testConnection
} else {
this.trackerUrl = '';
if (urlInput) {
urlInput.placeholder = 'e.g., https://your-team.atlassian.net';
}
}
}}
>
<sl-option value="github">GitHub</sl-option>
<sl-option value="gitlab">GitLab</sl-option>
<sl-option value="jira">Jira</sl-option>
</sl-select>
<sl-input
label="URL"
name="url"
.value=${this.trackerUrl}
@sl-input=${(e: any) => (this.trackerUrl = e.target.value)}
placeholder="e.g., https://github.example.com"
></sl-input>
${this.trackerType === 'jira'
? html`
<sl-input
label="Jira Username"
name="username"
.value=${this.trackerUsername}
@sl-input=${(e: any) => (this.trackerUsername = e.target.value)}
required
></sl-input>
`
: ''}
${this.authMethod === 'github_app' && this.githubInstallationId
? html`
<sl-alert variant="success" open>
<sl-icon slot="icon" name="check-circle"></sl-icon>
Connected to GitHub as <strong>${this.githubTargetLogin}</strong>
</sl-alert>
`
: this.trackerType === 'github' &&
this.githubAppConfigured &&
!this.tracker
? html`
<div style="margin-bottom: 1rem;">
<sl-button
variant="primary"
size="large"
@click=${this.startGitHubOAuth}
.loading=${this.isLoading}
style="width: 100%;"
>
<sl-icon slot="prefix" name="github"></sl-icon>
Connect with GitHub
</sl-button>
<p
style="text-align: center; margin: 0.75rem 0 0.5rem 0; color: var(--sl-color-neutral-500); font-size: var(--sl-font-size-small);"
>
Recommended: One-click OAuth connection
</p>
</div>
<details style="margin-bottom: 1rem;">
<summary
style="cursor: pointer; color: var(--sl-color-neutral-600); font-size: var(--sl-font-size-small);"
>
Or use an API token instead
</summary>
<sl-input
type="password"
label="API Key"
name="api_key"
.value=${this.trackerToken}
@sl-input=${(e: any) => (this.trackerToken = e.target.value)}
style="margin-top: 0.5rem;"
></sl-input>
</details>
`
: html`
<sl-input
type="password"
label="API Key"
name="api_key"
.value=${this.trackerToken}
@sl-input=${(e: any) => (this.trackerToken = e.target.value)}
required
></sl-input>
`}
`;
}
renderStep2() {
return html`
<h2>Configure Project Scope</h2>
<div>
<sl-checkbox
.checked=${this.areAllProjectsSelected}
@sl-change=${this.toggleSelectAll}
class="select-all"
>
Select All
</sl-checkbox>
</div>
${this.renderOrgTree()}
<div>
<sl-checkbox
.checked=${this.includeFutureProjects}
@sl-change=${(e: any) =>
(this.includeFutureProjects = e.target.checked)}
class="include-future"
>
Include future projects
</sl-checkbox>
</div>
`;
}
renderOrgTree() {
// For GitHub App auth, orgs represent installations - no expandable projects
const isGitHubApp = this.authMethod === 'github_app';
setTimeout(() => {
this.shadowRoot
?.querySelectorAll('sl-tree-item')
?.forEach((item: any) => {
if (item.selected && !isGitHubApp) {
item.dispatchEvent(
new CustomEvent('sl-lazy-load', { bubbles: true })
);
}
});
}, 300);
return html`
<sl-tree
selection="multiple"
@sl-selection-change=${this.handleSelectionChange}
>
${this.orgs.map(
(org: any) => html`
<sl-tree-item
value="${org.id}"
?selected=${this.selectedOrgs[org.id]}
?lazy=${!isGitHubApp && !this.projects[org.id]}
?loading=${this.isLoading}
?expanded=${!isGitHubApp && this.selectedOrgs[org.id]}
@sl-lazy-load=${(e: { target: SlTreeItem }) =>
this.loadProjects(org.id, e.target)}
>
${org.name}
${!isGitHubApp
? this.projects[org.id]?.map(
(proj: any) => html`
<sl-tree-item
value="${proj.id}"
?selected=${this.selectedProjects[org.id]?.[proj.id]}
>
${proj.name}
</sl-tree-item>
`
)
: ''}
</sl-tree-item>
`
)}
</sl-tree>
`;
}
async testConnection() {
this.isLoading = true;
this.errorMessage = '';
// For GitHub with GitHub App configured but no auth chosen yet, show error
if (
this.trackerType === 'github' &&
this.githubAppConfigured &&
!this.tracker &&
!this.githubInstallationId &&
!this.trackerToken
) {
this.isLoading = false;
this.errorMessage =
'Please connect with GitHub or enter an API token to continue.';
return;
}
try {
// For GitHub App auth, complete the installation and save directly
// Users already select org/repo access during GitHub App installation
if (this.authMethod === 'github_app' && this.githubInstallationId) {
// Complete the installation to associate it with the account
await this._api.completeGitHubInstallation({
installation_id: this.githubInstallationId,
});
// For GitHub App, fetch installations to build scope rules automatically
const installations = await this._api.getGitHubInstallations();
// Use target_id (GitHub org/user ID) as the identifier for scope rules
this.orgs = installations.map((inst) => ({
id: String(inst.target_id),
name: inst.target_login,
type: inst.target_type,
}));
// Auto-select all orgs from the installation (user already selected during GitHub App setup)
this.selectedOrgs = {};
for (const org of this.orgs) {
this.selectedOrgs[org.id] = true;
}
// Skip step 2 and save directly for new GitHub App trackers
if (!this.tracker) {
await this.handleSave();
return;
}
// For editing existing trackers, show step 2
this.step = 2;
} else {
// Standard API token flow
const response = await this._api.validateTrackerToken(
this.trackerType,
this.trackerToken,
this.trackerUrl,
this.trackerUsername,
this.tracker?.id
);
if (!response.success) {
this.errorMessage = response.message.split('\n')[0];
return;
}
this.orgs = response.orgs;
this.step = 2;
}
} catch (error: any) {
this.errorMessage = error.message;
} finally {
this.isLoading = false;
}
}
async loadProjects(orgId: string, item?: SlTreeItem) {
if (this.projects[orgId]) {
// Projects already loaded
return;
}
// GitHub App auth doesn't support per-project listing - access is at installation level
if (this.authMethod === 'github_app') {
return;
}
this.isLoading = true;
if (item) {
item.loading = true;
}
try {
const projects = await this._api.listProjectsForOrg(
this.trackerType,
this.trackerToken,
orgId,
this.trackerUrl,
this.trackerUsername,
this.tracker?.id
);
this.projects = { ...this.projects, [orgId]: projects };
if (this.selectedOrgs[orgId]) {
if (!this.includeFutureProjects) {
this.selectedProjects[orgId] = projects.reduce(
(acc: Record<string, boolean>, proj: any) => {
acc[proj.id] =
this.tracker?.scope_rules.filter(
(x: any) =>
x.rule_type == 'INCLUDE' &&
x.scope_type == 'PROJECT' &&
x.identifier == proj.id
).length > 0;
return acc;
},
{} as Record<string, boolean>
);
} else {
this.selectedProjects[orgId] = projects.reduce(
(acc: Record<string, boolean>, proj: any) => {
acc[proj.id] =
this.tracker?.scope_rules.filter(
(x: any) =>
x.rule_type == 'EXCLUDE' &&
x.scope_type == 'PROJECT' &&
x.identifier == proj.id
).length == 0;
return acc;
},
{} as Record<string, boolean>
);
}
}
} catch (error: any) {
this.errorMessage = error.message;
} finally {
this.isLoading = false;
if (item) {
item.loading = false;
item.lazy = false;
}
this.requestUpdate();
}
}
handleSelectionChange(event: CustomEvent) {
const selectedItems = event.detail.selection as SlTreeItem[];
const newSelectedOrgs: Record<string, boolean> = {};
const newSelectedProjects: Record<string, Record<string, boolean>> = {};
// Initialize projects map
this.orgs.forEach((org) => {
newSelectedProjects[org.id] = {};
});
selectedItems.forEach((item) => {
const project = item.getAttribute('value');
const org = item.parentElement?.getAttribute('value');
if (!org || !project) return;
newSelectedOrgs[org] = true;
newSelectedProjects[org][project] = true;
});
this.selectedOrgs = newSelectedOrgs;
this.selectedProjects = newSelectedProjects;
this.updateSelectAllState();
}
async toggleSelectAll() {
this.areAllProjectsSelected = !this.areAllProjectsSelected;
if (this.areAllProjectsSelected) {
this.isLoading = true;
const promises = this.orgs
.filter((org) => !this.projects[org.id])
.map((org) => this.loadProjects(org.id));
await Promise.all(promises);
this.isLoading = false;
}
const newSelectedOrgs: Record<string, boolean> = {};
const newSelectedProjects: Record<string, Record<string, boolean>> = {};
this.orgs.forEach((org) => {
newSelectedOrgs[org.id] = this.areAllProjectsSelected;
newSelectedProjects[org.id] = {};
if (this.projects[org.id]) {
this.projects[org.id].forEach((proj) => {
newSelectedProjects[org.id][proj.id] = this.areAllProjectsSelected;
});
}
});
this.selectedOrgs = newSelectedOrgs;
this.selectedProjects = newSelectedProjects;
this.requestUpdate();
}
updateSelectAllState() {
// Only consider loaded projects for the "Select All" state
const loadedProjects = this.orgs
.filter((org) => this.projects[org.id])
.flatMap((org) => this.projects[org.id]);
if (loadedProjects.length === 0) {
this.areAllProjectsSelected = false;
return;
}
let allSelected = true;
for (const org of this.orgs) {
if (this.projects[org.id]) {
for (const proj of this.projects[org.id]) {
if (!this.selectedProjects[org.id]?.[proj.id]) {
allSelected = false;
break;
}
}
}
if (!allSelected) break;
}
this.areAllProjectsSelected = allSelected;
}
async handleSave() {
this.isLoading = true;
this.errorMessage = '';
const scopeRules = [];
for (const org of this.orgs) {
if (this.selectedOrgs[org.id]) {
scopeRules.push({
rule_type: 'INCLUDE',
scope_type: 'ORGANIZATION',
identifier: org.id,
});
if (this.includeFutureProjects) {
for (const proj of this.projects[org.id] || []) {
if (!this.selectedProjects[org.id]?.[proj.id]) {
scopeRules.push({
rule_type: 'EXCLUDE',
scope_type: 'PROJECT',
identifier: proj.id,
});
}
}
} else {
for (const proj of this.projects[org.id] || []) {
if (this.selectedProjects[org.id]?.[proj.id]) {
scopeRules.push({
rule_type: 'INCLUDE',
scope_type: 'PROJECT',
identifier: proj.id,
});
}
}
}
}
}
const trackerData: any = {
name: this.trackerName,
type: this.trackerType,
url: this.trackerUrl,
scope_rules: scopeRules,
config: {
username: this.trackerUsername,
},
};
// Add auth-specific fields
if (this.authMethod === 'github_app' && this.githubInstallationId) {
trackerData.auth_type = 'github_app';
trackerData.github_installation_id = this.githubInstallationId;
// No API key needed for GitHub App auth
} else {
trackerData.auth_type = 'api_token';
trackerData.api_key = this.trackerToken;
}
try {
let response;
if (this.tracker) {
response = await this._api.updateTracker(this.tracker.id, trackerData);
} else {
response = await this._api.addTracker(trackerData);
}
// Check for warnings in response and display them before closing
if (response?.warnings && response.warnings.length > 0) {
this.warningMessages = response.warnings;
this.isLoading = false;
// Dispatch event but don't close modal yet - let user see the warnings
this.dispatchEvent(
new CustomEvent(this.tracker ? 'tracker-updated' : 'tracker-added', {
detail: { tracker: response, hasWarnings: true },
})
);
return;
}
// No warnings - dispatch event and close
this.dispatchEvent(
new CustomEvent(this.tracker ? 'tracker-updated' : 'tracker-added', {
detail: { tracker: response },
})
);
this.closeModal(true);
} catch (error: any) {
this.errorMessage = error.message;
} finally {
this.isLoading = false;
}
}
closeModal(success = false) {
if (typeof success !== 'boolean') {
success = false;
}
const event = new CustomEvent('close-modal', {
bubbles: true,
composed: true,
detail: { success },
});
this.dispatchEvent(event);
this.opened = false;
}
}