Skip to content
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
91 changes: 91 additions & 0 deletions src/app/components/signal-board/signal-board.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}
35 changes: 29 additions & 6 deletions src/app/components/signal-board/signal-board.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@
<span class="signal-source" [ngClass]="getSourceClass(signal)">{{ getSourceLabel(signal) }}</span>
<h3>{{ signal.title }}</h3>
</div>
<span class="signal-status" [class.unread]="signal.status === 'unread'">
{{ signal.status }}
</span>
<ng-container *ngIf="signal.sourceType === 'jira'; else defaultStatus">
<div class="status-container" (mouseenter)="loadJiraTransitions(signal)">
<select class="jira-status-select"
[disabled]="loadingTransitions[signal.id]"
(change)="onJiraTransitionChange(signal, $event)">
<option value="" disabled selected>{{ signal.metadata['status'] || signal.status }}</option>
<option *ngFor="let t of activeJiraTransitions[signal.id]" [value]="t.id">{{ t.name }}</option>
</select>
</div>
</ng-container>
<ng-template #defaultStatus>
<span class="signal-status" [class.unread]="signal.status === 'unread'">
{{ signal.status }}
</span>
</ng-template>
</div>

<p class="signal-meta">{{ getPrimaryContext(signal) }}</p>
Expand All @@ -29,9 +41,20 @@ <h3>{{ signal.title }}</h3>
</div>

<div class="signal-actions">
<a class="link-btn" *ngIf="signal.url" [href]="signal.url" target="_blank" rel="noreferrer">{{ getOpenLabel(signal) }}</a>
<button type="button" class="ghost-btn" (click)="markAsRead.emit(signal.id)">Mark as read</button>
<button type="button" class="ghost-btn" (click)="archive.emit(signal.id)">Archive</button>
<div class="jira-comment-section" *ngIf="signal.sourceType === 'jira'">
<input type="text" placeholder="Add a comment..." [(ngModel)]="jiraComments[signal.id]" [disabled]="submittingComment[signal.id]">
<button type="button" class="ghost-btn" [disabled]="submittingComment[signal.id] || !jiraComments[signal.id]" (click)="addJiraComment(signal)">Comment</button>
</div>

<div class="action-buttons-group">
<a class="link-btn" *ngIf="signal.url" [href]="signal.url" target="_blank" rel="noreferrer">{{ getOpenLabel(signal) }}</a>
<button type="button" class="ghost-btn" (click)="markAsRead.emit(signal.id)">Mark as read</button>
<button type="button" class="ghost-btn" (click)="archive.emit(signal.id)">Archive</button>
</div>
</div>
</article>
</section>

<div class="toast-container" *ngIf="toastMessage">
<div class="toast">{{ toastMessage }}</div>
</div>
91 changes: 89 additions & 2 deletions src/app/components/signal-board/signal-board.ts
Original file line number Diff line number Diff line change
@@ -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'
})
Expand All @@ -14,6 +16,15 @@ export class SignalBoardComponent {
@Output() markAsRead = new EventEmitter<string>();
@Output() archive = new EventEmitter<string>();

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;
}
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -21,6 +21,7 @@ describe('WorkspaceIntegrationsComponent', () => {
'disconnectGitHub',
'syncGitHub',
'getJiraProjects',
'getJiraStatus',
'connectJira',
'disconnectJira',
'syncJira'
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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');
});
});
Loading
Loading