diff --git a/src/app/components/signal-board/signal-board.css b/src/app/components/signal-board/signal-board.css
index 89749e3..b0d2bd2 100644
--- a/src/app/components/signal-board/signal-board.css
+++ b/src/app/components/signal-board/signal-board.css
@@ -127,3 +127,94 @@
align-items: flex-start;
}
}
+
+/* Jira UI Styles */
+.status-container {
+ position: relative;
+}
+
+.jira-status-select {
+ appearance: none;
+ background: #dbeafe;
+ color: #1d4ed8;
+ border: 1px solid #bfdbfe;
+ border-radius: 999px;
+ padding: 0.28rem 1.6rem 0.28rem 0.6rem;
+ font-size: 0.78rem;
+ font-weight: 700;
+ cursor: pointer;
+ outline: none;
+ transition: all 0.2s;
+}
+
+.jira-status-select:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+.jira-status-select:hover:not(:disabled) {
+ background: #bfdbfe;
+}
+
+.status-container::after {
+ content: "▼";
+ font-size: 0.5rem;
+ color: #1d4ed8;
+ position: absolute;
+ right: 0.6rem;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
+.jira-comment-section {
+ display: flex;
+ gap: 0.5rem;
+ width: 100%;
+ margin-bottom: 0.75rem;
+}
+
+.jira-comment-section input {
+ flex: 1;
+ border: 1px solid #cbd5e1;
+ border-radius: 999px;
+ padding: 0.5rem 1rem;
+ font-size: 0.9rem;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.jira-comment-section input:focus {
+ border-color: #6366f1;
+}
+
+.action-buttons-group {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+/* Toast */
+.toast-container {
+ position: fixed;
+ bottom: 2rem;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1000;
+}
+
+.toast {
+ background: #111827;
+ color: #ffffff;
+ padding: 0.75rem 1.5rem;
+ border-radius: 999px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
+ animation: toast-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+@keyframes toast-in {
+ from { opacity: 0; transform: translateY(1rem); }
+ to { opacity: 1; transform: translateY(0); }
+}
diff --git a/src/app/components/signal-board/signal-board.html b/src/app/components/signal-board/signal-board.html
index c6f11b3..37fff06 100644
--- a/src/app/components/signal-board/signal-board.html
+++ b/src/app/components/signal-board/signal-board.html
@@ -5,9 +5,21 @@
{{ getSourceLabel(signal) }}
{{ signal.title }}
-
- {{ signal.status }}
-
+
+
+
+
+
+
+
+ {{ signal.status }}
+
+
{{ getPrimaryContext(signal) }}
@@ -29,9 +41,20 @@ {{ signal.title }}
+
+
diff --git a/src/app/components/signal-board/signal-board.ts b/src/app/components/signal-board/signal-board.ts
index 5e14e49..1dfa39d 100644
--- a/src/app/components/signal-board/signal-board.ts
+++ b/src/app/components/signal-board/signal-board.ts
@@ -1,11 +1,13 @@
import { CommonModule } from '@angular/common';
-import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { Component, EventEmitter, Input, Output, inject, ChangeDetectorRef } from '@angular/core';
+import { FormsModule } from '@angular/forms';
import { Signal } from '../../models/signal.model';
+import { IntegrationService } from '../../services/integration.service';
@Component({
selector: 'app-signal-board',
standalone: true,
- imports: [CommonModule],
+ imports: [CommonModule, FormsModule],
templateUrl: './signal-board.html',
styleUrl: './signal-board.css'
})
@@ -14,6 +16,15 @@ export class SignalBoardComponent {
@Output() markAsRead = new EventEmitter();
@Output() archive = new EventEmitter();
+ private integrationService = inject(IntegrationService);
+ private cdr = inject(ChangeDetectorRef);
+
+ activeJiraTransitions: { [signalId: string]: any[] } = {};
+ loadingTransitions: { [signalId: string]: boolean } = {};
+ jiraComments: { [signalId: string]: string } = {};
+ submittingComment: { [signalId: string]: boolean } = {};
+ toastMessage = '';
+
trackBySignal(_: number, signal: Signal): string {
return signal.id;
}
@@ -61,4 +72,80 @@ export class SignalBoardComponent {
getSlackChannel(signal: Signal): string {
return String(signal.metadata['channel'] ?? 'channel');
}
+
+ loadJiraTransitions(signal: Signal) {
+ if (signal.sourceType !== 'jira' || this.activeJiraTransitions[signal.id]) return;
+
+ const workspaceId = String(signal.workspaceId ?? '');
+ const issueKey = signal.externalId;
+ if (!workspaceId || !issueKey) return;
+
+ this.loadingTransitions[signal.id] = true;
+ this.integrationService.getJiraTransitions(workspaceId, issueKey).subscribe({
+ next: (transitions) => {
+ this.activeJiraTransitions[signal.id] = transitions;
+ this.loadingTransitions[signal.id] = false;
+ this.cdr.detectChanges();
+ },
+ error: () => {
+ this.loadingTransitions[signal.id] = false;
+ this.showToast('Failed to load Jira transitions');
+ }
+ });
+ }
+
+ onJiraTransitionChange(signal: Signal, event: Event) {
+ const transitionId = (event.target as HTMLSelectElement).value;
+ if (!transitionId) return;
+
+ const workspaceId = String(signal.workspaceId ?? '');
+ const issueKey = signal.externalId;
+
+ this.integrationService.performJiraTransition(workspaceId, issueKey, transitionId).subscribe({
+ next: () => {
+ this.showToast('Status updated successfully');
+ const transition = this.activeJiraTransitions[signal.id]?.find(t => t.id === transitionId);
+ if (transition && signal.metadata) {
+ signal.metadata['status'] = transition.to?.name || 'Updated';
+ }
+ delete this.activeJiraTransitions[signal.id];
+ this.cdr.detectChanges();
+ },
+ error: (err) => {
+ this.showToast(err.message || 'Failed to update Jira status');
+ }
+ });
+ }
+
+ addJiraComment(signal: Signal) {
+ const workspaceId = String(signal.workspaceId ?? '');
+ const issueKey = signal.externalId;
+ const comment = this.jiraComments[signal.id];
+
+ if (!comment || !comment.trim()) return;
+
+ this.submittingComment[signal.id] = true;
+ this.integrationService.addJiraComment(workspaceId, issueKey, comment).subscribe({
+ next: () => {
+ this.showToast('Comment added successfully');
+ this.jiraComments[signal.id] = '';
+ this.submittingComment[signal.id] = false;
+ this.cdr.detectChanges();
+ },
+ error: (err) => {
+ this.showToast('Failed to add comment');
+ this.submittingComment[signal.id] = false;
+ this.cdr.detectChanges();
+ }
+ });
+ }
+
+ showToast(msg: string) {
+ this.toastMessage = msg;
+ this.cdr.detectChanges();
+ setTimeout(() => {
+ this.toastMessage = '';
+ this.cdr.detectChanges();
+ }, 4000);
+ }
}
diff --git a/src/app/components/workspace-integrations/workspace-integrations.spec.ts b/src/app/components/workspace-integrations/workspace-integrations.spec.ts
index 440a8b8..0f99a92 100644
--- a/src/app/components/workspace-integrations/workspace-integrations.spec.ts
+++ b/src/app/components/workspace-integrations/workspace-integrations.spec.ts
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
-import { of } from 'rxjs';
+import { of, throwError } from 'rxjs';
import { WorkspaceIntegrationsComponent } from './workspace-integrations';
import { IntegrationService } from '../../services/integration.service';
@@ -21,6 +21,7 @@ describe('WorkspaceIntegrationsComponent', () => {
'disconnectGitHub',
'syncGitHub',
'getJiraProjects',
+ 'getJiraStatus',
'connectJira',
'disconnectJira',
'syncJira'
@@ -53,10 +54,14 @@ describe('WorkspaceIntegrationsComponent', () => {
mockIntegrationService.disconnectGitHub.and.returnValue(of(void 0));
mockIntegrationService.connectGitHub.and.returnValue(of(void 0));
+ mockIntegrationService.getJiraStatus.and.returnValue(of({
+ connected: true,
+ lastSyncAt: new Date('2026-04-01T12:00:00Z')
+ }));
mockIntegrationService.getJiraProjects.and.returnValue(of({
- connected: false,
- resources: [],
- lastSyncAt: undefined
+ connected: true,
+ resources: [{ id: 'abc', url: 'https://test.atlassian.net', name: 'Test Site', scopes: [], avatarUrl: '' }],
+ lastSyncAt: new Date('2026-04-01T12:00:00Z')
}));
mockIntegrationService.connectJira.and.returnValue(of(void 0));
mockIntegrationService.disconnectJira.and.returnValue(of(void 0));
@@ -104,4 +109,95 @@ describe('WorkspaceIntegrationsComponent', () => {
expect(mockIntegrationService.updateSlackChannels).toHaveBeenCalledWith('workspace-1', ['C123']);
expect(component.slackFeedbackMessage).toContain('saved');
});
+
+ // --- Jira persistence tests ---
+
+ it('should show Jira as connected when integration record exists', () => {
+ // getJiraStatus returns connected: true (set in beforeEach)
+ expect(component.isJiraConnected).toBeTrue();
+ expect(mockIntegrationService.getJiraStatus).toHaveBeenCalledWith('workspace-1');
+ });
+
+ it('should show Jira as disconnected when no integration record exists', () => {
+ mockIntegrationService.getJiraStatus.and.returnValue(of({
+ connected: false
+ }));
+
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(component.isJiraConnected).toBeFalse();
+ });
+
+ it('should keep Jira connected even when project API call fails', () => {
+ mockIntegrationService.getJiraStatus.and.returnValue(of({
+ connected: true,
+ lastSyncAt: new Date('2026-04-01T12:00:00Z')
+ }));
+ mockIntegrationService.getJiraProjects.and.returnValue(
+ throwError(() => new Error('Token expired'))
+ );
+
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ // The key assertion: Jira should still appear connected even though projects API failed
+ expect(component.isJiraConnected).toBeTrue();
+ expect(component.jiraResources).toEqual([]);
+ expect(component.jiraErrorMessage).toContain('Could not load Jira projects');
+ });
+
+ it('should load Jira projects when connected', () => {
+ expect(component.jiraResources.length).toBe(1);
+ expect(component.jiraResources[0].name).toBe('Test Site');
+ });
+
+ it('should not load projects when Jira is disconnected', () => {
+ mockIntegrationService.getJiraStatus.and.returnValue(of({
+ connected: false
+ }));
+
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ // getJiraProjects should only have been called during the first beforeEach init,
+ // not during this re-init
+ const callCount = mockIntegrationService.getJiraProjects.calls.count();
+ // First call is from beforeEach, second ngOnInit should NOT call it
+ expect(callCount).toBe(1);
+ });
+
+ it('should disconnect Jira only through explicit disconnect button', () => {
+ expect(component.isJiraConnected).toBeTrue();
+
+ component.disconnectJira();
+ fixture.detectChanges();
+
+ expect(mockIntegrationService.disconnectJira).toHaveBeenCalledWith('workspace-1');
+ });
+
+ it('should preserve Jira connection state when status check errors', () => {
+ // Start connected
+ expect(component.isJiraConnected).toBeTrue();
+
+ // Now simulate a network error on re-check
+ mockIntegrationService.getJiraStatus.and.returnValue(
+ throwError(() => new Error('Network error'))
+ );
+
+ // Simulate re-navigating to the page (window focus)
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ // Connection state should NOT flip to false due to the error
+ expect(component.isJiraConnected).toBeTrue();
+ });
+
+ it('should trigger Jira sync correctly', () => {
+ component.syncJiraNow();
+ fixture.detectChanges();
+
+ expect(mockIntegrationService.syncJira).toHaveBeenCalledWith('workspace-1');
+ expect(component.jiraFeedbackMessage).toContain('started');
+ });
});
diff --git a/src/app/components/workspace-integrations/workspace-integrations.ts b/src/app/components/workspace-integrations/workspace-integrations.ts
index 7762db3..b641304 100644
--- a/src/app/components/workspace-integrations/workspace-integrations.ts
+++ b/src/app/components/workspace-integrations/workspace-integrations.ts
@@ -49,20 +49,30 @@ export class WorkspaceIntegrationsComponent implements OnInit, OnDestroy {
isJiraSyncing = false;
ngOnInit(): void {
- this.workspaceId = this.route.snapshot.paramMap.get('id') ?? '';
+ this.workspaceId = this.getWorkspaceIdFromRoute() ?? '';
this.loadAllIntegrations();
window.addEventListener('focus', this.onWindowFocus);
}
+ private getWorkspaceIdFromRoute(): string | null {
+ for (const route of this.route.pathFromRoot) {
+ const id = route.snapshot.paramMap.get('id');
+ if (id) {
+ return id;
+ }
+ }
+ return null;
+ }
+
ngOnDestroy(): void {
window.removeEventListener('focus', this.onWindowFocus);
}
private onWindowFocus = (): void => {
// If we have an active feedback message, let's refresh to see if connection succeeded in background
- if (this.slackFeedbackMessage === 'Starting Slack connection...' ||
- this.githubFeedbackMessage === 'Starting GitHub connection...' ||
- this.jiraFeedbackMessage === 'Starting Jira connection...') {
+ if (this.slackFeedbackMessage === 'Starting Slack connection...' ||
+ this.githubFeedbackMessage === 'Starting GitHub connection...' ||
+ this.jiraFeedbackMessage === 'Starting Jira connection...') {
this.slackFeedbackMessage = '';
this.githubFeedbackMessage = '';
this.jiraFeedbackMessage = '';
@@ -260,10 +270,46 @@ export class WorkspaceIntegrationsComponent implements OnInit, OnDestroy {
}
private loadJira(): void {
- this.integrationService.getJiraProjects(this.workspaceId).subscribe(response => {
- this.isJiraConnected = response.connected;
- this.jiraResources = response.resources;
- this.jiraLastSyncAt = response.lastSyncAt;
+ console.log('[WorkspaceIntegrations] loadJira() called, workspaceId:', this.workspaceId);
+ // First, check connection status from the DB record (source of truth).
+ // This prevents transient Jira API failures from flipping the UI to "disconnected".
+ this.integrationService.getJiraStatus(this.workspaceId).subscribe({
+ next: (status) => {
+ console.log('[WorkspaceIntegrations] getJiraStatus returned:', status);
+ this.isJiraConnected = status.connected;
+ this.jiraLastSyncAt = status.lastSyncAt;
+
+ // Only attempt to load projects if the integration record exists.
+ if (status.connected) {
+ this.loadJiraProjects();
+ } else {
+ this.jiraResources = [];
+ }
+ },
+ error: (err) => {
+ console.error('[WorkspaceIntegrations] getJiraStatus error:', err);
+ // On error checking status, don't change connection state
+ // (preserve whatever it was before — "don't disconnect on error").
+ }
+ });
+ }
+
+ private loadJiraProjects(): void {
+ this.integrationService.getJiraProjects(this.workspaceId).subscribe({
+ next: (response) => {
+ // Projects loaded successfully; update resource list.
+ // Connection status is already set by loadJira(), so we don't touch isJiraConnected here.
+ this.jiraResources = response.resources ?? [];
+ if (response.lastSyncAt) {
+ this.jiraLastSyncAt = response.lastSyncAt;
+ }
+ },
+ error: () => {
+ // Project fetch failed (e.g., expired token) but integration still exists.
+ // Keep isJiraConnected = true; just show empty resources.
+ this.jiraResources = [];
+ this.jiraErrorMessage = 'Could not load Jira projects. The connection may need to be refreshed.';
+ }
});
}
}
diff --git a/src/app/components/workspace-members/workspace-members.ts b/src/app/components/workspace-members/workspace-members.ts
index 3102faf..8381bc6 100644
--- a/src/app/components/workspace-members/workspace-members.ts
+++ b/src/app/components/workspace-members/workspace-members.ts
@@ -30,11 +30,21 @@ export class WorkspaceMembersComponent implements OnInit {
readonly availableRoles: WorkspaceRole[] = ['owner', 'member', 'viewer'];
ngOnInit(): void {
- this.workspaceId = this.route.snapshot.paramMap.get('id') ?? '';
+ this.workspaceId = this.getWorkspaceIdFromRoute() ?? '';
this.loadMembers();
this.loadInvitations();
}
+ private getWorkspaceIdFromRoute(): string | null {
+ for (const route of this.route.pathFromRoot) {
+ const id = route.snapshot.paramMap.get('id');
+ if (id) {
+ return id;
+ }
+ }
+ return null;
+ }
+
inviteMember(): void {
const trimmedEmail = this.inviteEmail.trim().toLowerCase();
if (!trimmedEmail) {
diff --git a/src/app/models/signal.model.ts b/src/app/models/signal.model.ts
index bfc2534..01dc24a 100644
--- a/src/app/models/signal.model.ts
+++ b/src/app/models/signal.model.ts
@@ -18,6 +18,7 @@ export interface SignalMetadata {
export interface Signal {
id: string;
+ workspaceId?: number;
sourceType: SignalSourceType;
sourceId: string;
externalId: string;
diff --git a/src/app/services/integration.service.ts b/src/app/services/integration.service.ts
index f6aa728..17cbd8a 100644
--- a/src/app/services/integration.service.ts
+++ b/src/app/services/integration.service.ts
@@ -241,6 +241,33 @@ export class IntegrationService {
);
}
+ getJiraTransitions(workspaceId: string, issueKey: string): Observable {
+ const params = new HttpParams().set('workspace_id', workspaceId);
+ return this.http.get(`${this.apiUrl}/jira/issues/${issueKey}/transitions`, { params }).pipe(
+ catchError((error) => throwError(() => toError(error, 'Unable to load Jira transitions.'))),
+ );
+ }
+
+ performJiraTransition(workspaceId: string, issueKey: string, transitionId: string): Observable {
+ const params = new HttpParams().set('workspace_id', workspaceId);
+ return this.http.post(`${this.apiUrl}/jira/issues/${issueKey}/transitions`, { transitionId }, { params }).pipe(
+ catchError((error) => {
+ // If it's a 400, it might be due to missing fields required by the transition
+ if (error.status === 400) {
+ return throwError(() => new Error('This transition requires additional fields. Please update directly in Jira.'));
+ }
+ return throwError(() => toError(error, 'Unable to perform Jira transition.'));
+ }),
+ );
+ }
+
+ addJiraComment(workspaceId: string, issueKey: string, comment: string): Observable {
+ const params = new HttpParams().set('workspace_id', workspaceId);
+ return this.http.post(`${this.apiUrl}/jira/issues/${issueKey}/comments`, { body: comment }, { params }).pipe(
+ catchError((error) => throwError(() => toError(error, 'Unable to add Jira comment.'))),
+ );
+ }
+
private getIntegrations(workspaceId?: string): Observable {
let params = new HttpParams();
if (workspaceId) {
@@ -248,7 +275,12 @@ export class IntegrationService {
}
return this.http.get(this.apiUrl, { params }).pipe(
+ map((records) => {
+ console.log('[IntegrationService] getIntegrations response:', JSON.stringify(records));
+ return records;
+ }),
catchError((error) => {
+ console.error('[IntegrationService] getIntegrations error:', error.status, error.message);
if (error.status === 404) {
return of([]);
}
@@ -290,4 +322,28 @@ export class IntegrationService {
isConnected: selectedRepoIds.includes(repo.id),
};
}
+
+ /**
+ * Checks Jira connection status by looking for the integration record in the DB.
+ * This is separate from getJiraProjects() which makes actual Jira API calls.
+ * Connection state should be determined by the DB record, not by whether the API is reachable.
+ */
+ getJiraStatus(workspaceId: string): Observable<{ connected: boolean; lastSyncAt?: Date }> {
+ console.log('[IntegrationService] getJiraStatus called for workspace:', workspaceId);
+ return this.getIntegrations(workspaceId).pipe(
+ map((integrations) => {
+ const jiraIntegration = integrations.find((i) => i.provider === 'jira');
+ const result = {
+ connected: !!jiraIntegration,
+ lastSyncAt: jiraIntegration?.updated_at ? new Date(jiraIntegration.updated_at) : undefined,
+ };
+ console.log('[IntegrationService] getJiraStatus result:', result, 'jiraRecord:', jiraIntegration);
+ return result;
+ }),
+ catchError((err) => {
+ console.error('[IntegrationService] getJiraStatus error:', err);
+ return of({ connected: false });
+ }),
+ );
+ }
}
diff --git a/src/app/services/signal.service.ts b/src/app/services/signal.service.ts
index cf221a7..13155a3 100644
--- a/src/app/services/signal.service.ts
+++ b/src/app/services/signal.service.ts
@@ -68,6 +68,7 @@ export class SignalService {
private mapSignal(signal: SignalResponse): Signal {
return {
id: String(signal.id),
+ workspaceId: signal.workspace_id,
sourceType: signal.source_type,
sourceId: signal.source_id,
externalId: signal.external_id ?? '',