Skip to content

Commit a24759d

Browse files
authored
feat(jira): actionable jira integration and persistence fixes (#47)
* feat(jira): add UI for jira transitions and comments * fix(integrations): ensure workspace_id persistence across navigation (ref Sentinent-AI/Sentinent#29)
1 parent 466f893 commit a24759d

9 files changed

Lines changed: 432 additions & 21 deletions

File tree

src/app/components/signal-board/signal-board.css

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,94 @@
127127
align-items: flex-start;
128128
}
129129
}
130+
131+
/* Jira UI Styles */
132+
.status-container {
133+
position: relative;
134+
}
135+
136+
.jira-status-select {
137+
appearance: none;
138+
background: #dbeafe;
139+
color: #1d4ed8;
140+
border: 1px solid #bfdbfe;
141+
border-radius: 999px;
142+
padding: 0.28rem 1.6rem 0.28rem 0.6rem;
143+
font-size: 0.78rem;
144+
font-weight: 700;
145+
cursor: pointer;
146+
outline: none;
147+
transition: all 0.2s;
148+
}
149+
150+
.jira-status-select:disabled {
151+
opacity: 0.7;
152+
cursor: not-allowed;
153+
}
154+
155+
.jira-status-select:hover:not(:disabled) {
156+
background: #bfdbfe;
157+
}
158+
159+
.status-container::after {
160+
content: "▼";
161+
font-size: 0.5rem;
162+
color: #1d4ed8;
163+
position: absolute;
164+
right: 0.6rem;
165+
top: 50%;
166+
transform: translateY(-50%);
167+
pointer-events: none;
168+
}
169+
170+
.jira-comment-section {
171+
display: flex;
172+
gap: 0.5rem;
173+
width: 100%;
174+
margin-bottom: 0.75rem;
175+
}
176+
177+
.jira-comment-section input {
178+
flex: 1;
179+
border: 1px solid #cbd5e1;
180+
border-radius: 999px;
181+
padding: 0.5rem 1rem;
182+
font-size: 0.9rem;
183+
outline: none;
184+
transition: border-color 0.2s;
185+
}
186+
187+
.jira-comment-section input:focus {
188+
border-color: #6366f1;
189+
}
190+
191+
.action-buttons-group {
192+
display: flex;
193+
gap: 0.75rem;
194+
flex-wrap: wrap;
195+
}
196+
197+
/* Toast */
198+
.toast-container {
199+
position: fixed;
200+
bottom: 2rem;
201+
left: 50%;
202+
transform: translateX(-50%);
203+
z-index: 1000;
204+
}
205+
206+
.toast {
207+
background: #111827;
208+
color: #ffffff;
209+
padding: 0.75rem 1.5rem;
210+
border-radius: 999px;
211+
font-size: 0.9rem;
212+
font-weight: 600;
213+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
214+
animation: toast-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
215+
}
216+
217+
@keyframes toast-in {
218+
from { opacity: 0; transform: translateY(1rem); }
219+
to { opacity: 1; transform: translateY(0); }
220+
}

src/app/components/signal-board/signal-board.html

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@
55
<span class="signal-source" [ngClass]="getSourceClass(signal)">{{ getSourceLabel(signal) }}</span>
66
<h3>{{ signal.title }}</h3>
77
</div>
8-
<span class="signal-status" [class.unread]="signal.status === 'unread'">
9-
{{ signal.status }}
10-
</span>
8+
<ng-container *ngIf="signal.sourceType === 'jira'; else defaultStatus">
9+
<div class="status-container" (mouseenter)="loadJiraTransitions(signal)">
10+
<select class="jira-status-select"
11+
[disabled]="loadingTransitions[signal.id]"
12+
(change)="onJiraTransitionChange(signal, $event)">
13+
<option value="" disabled selected>{{ signal.metadata['status'] || signal.status }}</option>
14+
<option *ngFor="let t of activeJiraTransitions[signal.id]" [value]="t.id">{{ t.name }}</option>
15+
</select>
16+
</div>
17+
</ng-container>
18+
<ng-template #defaultStatus>
19+
<span class="signal-status" [class.unread]="signal.status === 'unread'">
20+
{{ signal.status }}
21+
</span>
22+
</ng-template>
1123
</div>
1224

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

3143
<div class="signal-actions">
32-
<a class="link-btn" *ngIf="signal.url" [href]="signal.url" target="_blank" rel="noreferrer">{{ getOpenLabel(signal) }}</a>
33-
<button type="button" class="ghost-btn" (click)="markAsRead.emit(signal.id)">Mark as read</button>
34-
<button type="button" class="ghost-btn" (click)="archive.emit(signal.id)">Archive</button>
44+
<div class="jira-comment-section" *ngIf="signal.sourceType === 'jira'">
45+
<input type="text" placeholder="Add a comment..." [(ngModel)]="jiraComments[signal.id]" [disabled]="submittingComment[signal.id]">
46+
<button type="button" class="ghost-btn" [disabled]="submittingComment[signal.id] || !jiraComments[signal.id]" (click)="addJiraComment(signal)">Comment</button>
47+
</div>
48+
49+
<div class="action-buttons-group">
50+
<a class="link-btn" *ngIf="signal.url" [href]="signal.url" target="_blank" rel="noreferrer">{{ getOpenLabel(signal) }}</a>
51+
<button type="button" class="ghost-btn" (click)="markAsRead.emit(signal.id)">Mark as read</button>
52+
<button type="button" class="ghost-btn" (click)="archive.emit(signal.id)">Archive</button>
53+
</div>
3554
</div>
3655
</article>
3756
</section>
57+
58+
<div class="toast-container" *ngIf="toastMessage">
59+
<div class="toast">{{ toastMessage }}</div>
60+
</div>

src/app/components/signal-board/signal-board.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { CommonModule } from '@angular/common';
2-
import { Component, EventEmitter, Input, Output } from '@angular/core';
2+
import { Component, EventEmitter, Input, Output, inject, ChangeDetectorRef } from '@angular/core';
3+
import { FormsModule } from '@angular/forms';
34
import { Signal } from '../../models/signal.model';
5+
import { IntegrationService } from '../../services/integration.service';
46

57
@Component({
68
selector: 'app-signal-board',
79
standalone: true,
8-
imports: [CommonModule],
10+
imports: [CommonModule, FormsModule],
911
templateUrl: './signal-board.html',
1012
styleUrl: './signal-board.css'
1113
})
@@ -14,6 +16,15 @@ export class SignalBoardComponent {
1416
@Output() markAsRead = new EventEmitter<string>();
1517
@Output() archive = new EventEmitter<string>();
1618

19+
private integrationService = inject(IntegrationService);
20+
private cdr = inject(ChangeDetectorRef);
21+
22+
activeJiraTransitions: { [signalId: string]: any[] } = {};
23+
loadingTransitions: { [signalId: string]: boolean } = {};
24+
jiraComments: { [signalId: string]: string } = {};
25+
submittingComment: { [signalId: string]: boolean } = {};
26+
toastMessage = '';
27+
1728
trackBySignal(_: number, signal: Signal): string {
1829
return signal.id;
1930
}
@@ -61,4 +72,80 @@ export class SignalBoardComponent {
6172
getSlackChannel(signal: Signal): string {
6273
return String(signal.metadata['channel'] ?? 'channel');
6374
}
75+
76+
loadJiraTransitions(signal: Signal) {
77+
if (signal.sourceType !== 'jira' || this.activeJiraTransitions[signal.id]) return;
78+
79+
const workspaceId = String(signal.workspaceId ?? '');
80+
const issueKey = signal.externalId;
81+
if (!workspaceId || !issueKey) return;
82+
83+
this.loadingTransitions[signal.id] = true;
84+
this.integrationService.getJiraTransitions(workspaceId, issueKey).subscribe({
85+
next: (transitions) => {
86+
this.activeJiraTransitions[signal.id] = transitions;
87+
this.loadingTransitions[signal.id] = false;
88+
this.cdr.detectChanges();
89+
},
90+
error: () => {
91+
this.loadingTransitions[signal.id] = false;
92+
this.showToast('Failed to load Jira transitions');
93+
}
94+
});
95+
}
96+
97+
onJiraTransitionChange(signal: Signal, event: Event) {
98+
const transitionId = (event.target as HTMLSelectElement).value;
99+
if (!transitionId) return;
100+
101+
const workspaceId = String(signal.workspaceId ?? '');
102+
const issueKey = signal.externalId;
103+
104+
this.integrationService.performJiraTransition(workspaceId, issueKey, transitionId).subscribe({
105+
next: () => {
106+
this.showToast('Status updated successfully');
107+
const transition = this.activeJiraTransitions[signal.id]?.find(t => t.id === transitionId);
108+
if (transition && signal.metadata) {
109+
signal.metadata['status'] = transition.to?.name || 'Updated';
110+
}
111+
delete this.activeJiraTransitions[signal.id];
112+
this.cdr.detectChanges();
113+
},
114+
error: (err) => {
115+
this.showToast(err.message || 'Failed to update Jira status');
116+
}
117+
});
118+
}
119+
120+
addJiraComment(signal: Signal) {
121+
const workspaceId = String(signal.workspaceId ?? '');
122+
const issueKey = signal.externalId;
123+
const comment = this.jiraComments[signal.id];
124+
125+
if (!comment || !comment.trim()) return;
126+
127+
this.submittingComment[signal.id] = true;
128+
this.integrationService.addJiraComment(workspaceId, issueKey, comment).subscribe({
129+
next: () => {
130+
this.showToast('Comment added successfully');
131+
this.jiraComments[signal.id] = '';
132+
this.submittingComment[signal.id] = false;
133+
this.cdr.detectChanges();
134+
},
135+
error: (err) => {
136+
this.showToast('Failed to add comment');
137+
this.submittingComment[signal.id] = false;
138+
this.cdr.detectChanges();
139+
}
140+
});
141+
}
142+
143+
showToast(msg: string) {
144+
this.toastMessage = msg;
145+
this.cdr.detectChanges();
146+
setTimeout(() => {
147+
this.toastMessage = '';
148+
this.cdr.detectChanges();
149+
}, 4000);
150+
}
64151
}

src/app/components/workspace-integrations/workspace-integrations.spec.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ComponentFixture, TestBed } from '@angular/core/testing';
22
import { ActivatedRoute, convertToParamMap } from '@angular/router';
3-
import { of } from 'rxjs';
3+
import { of, throwError } from 'rxjs';
44
import { WorkspaceIntegrationsComponent } from './workspace-integrations';
55
import { IntegrationService } from '../../services/integration.service';
66

@@ -21,6 +21,7 @@ describe('WorkspaceIntegrationsComponent', () => {
2121
'disconnectGitHub',
2222
'syncGitHub',
2323
'getJiraProjects',
24+
'getJiraStatus',
2425
'connectJira',
2526
'disconnectJira',
2627
'syncJira'
@@ -53,10 +54,14 @@ describe('WorkspaceIntegrationsComponent', () => {
5354
mockIntegrationService.disconnectGitHub.and.returnValue(of(void 0));
5455
mockIntegrationService.connectGitHub.and.returnValue(of(void 0));
5556

57+
mockIntegrationService.getJiraStatus.and.returnValue(of({
58+
connected: true,
59+
lastSyncAt: new Date('2026-04-01T12:00:00Z')
60+
}));
5661
mockIntegrationService.getJiraProjects.and.returnValue(of({
57-
connected: false,
58-
resources: [],
59-
lastSyncAt: undefined
62+
connected: true,
63+
resources: [{ id: 'abc', url: 'https://test.atlassian.net', name: 'Test Site', scopes: [], avatarUrl: '' }],
64+
lastSyncAt: new Date('2026-04-01T12:00:00Z')
6065
}));
6166
mockIntegrationService.connectJira.and.returnValue(of(void 0));
6267
mockIntegrationService.disconnectJira.and.returnValue(of(void 0));
@@ -104,4 +109,95 @@ describe('WorkspaceIntegrationsComponent', () => {
104109
expect(mockIntegrationService.updateSlackChannels).toHaveBeenCalledWith('workspace-1', ['C123']);
105110
expect(component.slackFeedbackMessage).toContain('saved');
106111
});
112+
113+
// --- Jira persistence tests ---
114+
115+
it('should show Jira as connected when integration record exists', () => {
116+
// getJiraStatus returns connected: true (set in beforeEach)
117+
expect(component.isJiraConnected).toBeTrue();
118+
expect(mockIntegrationService.getJiraStatus).toHaveBeenCalledWith('workspace-1');
119+
});
120+
121+
it('should show Jira as disconnected when no integration record exists', () => {
122+
mockIntegrationService.getJiraStatus.and.returnValue(of({
123+
connected: false
124+
}));
125+
126+
component.ngOnInit();
127+
fixture.detectChanges();
128+
129+
expect(component.isJiraConnected).toBeFalse();
130+
});
131+
132+
it('should keep Jira connected even when project API call fails', () => {
133+
mockIntegrationService.getJiraStatus.and.returnValue(of({
134+
connected: true,
135+
lastSyncAt: new Date('2026-04-01T12:00:00Z')
136+
}));
137+
mockIntegrationService.getJiraProjects.and.returnValue(
138+
throwError(() => new Error('Token expired'))
139+
);
140+
141+
component.ngOnInit();
142+
fixture.detectChanges();
143+
144+
// The key assertion: Jira should still appear connected even though projects API failed
145+
expect(component.isJiraConnected).toBeTrue();
146+
expect(component.jiraResources).toEqual([]);
147+
expect(component.jiraErrorMessage).toContain('Could not load Jira projects');
148+
});
149+
150+
it('should load Jira projects when connected', () => {
151+
expect(component.jiraResources.length).toBe(1);
152+
expect(component.jiraResources[0].name).toBe('Test Site');
153+
});
154+
155+
it('should not load projects when Jira is disconnected', () => {
156+
mockIntegrationService.getJiraStatus.and.returnValue(of({
157+
connected: false
158+
}));
159+
160+
component.ngOnInit();
161+
fixture.detectChanges();
162+
163+
// getJiraProjects should only have been called during the first beforeEach init,
164+
// not during this re-init
165+
const callCount = mockIntegrationService.getJiraProjects.calls.count();
166+
// First call is from beforeEach, second ngOnInit should NOT call it
167+
expect(callCount).toBe(1);
168+
});
169+
170+
it('should disconnect Jira only through explicit disconnect button', () => {
171+
expect(component.isJiraConnected).toBeTrue();
172+
173+
component.disconnectJira();
174+
fixture.detectChanges();
175+
176+
expect(mockIntegrationService.disconnectJira).toHaveBeenCalledWith('workspace-1');
177+
});
178+
179+
it('should preserve Jira connection state when status check errors', () => {
180+
// Start connected
181+
expect(component.isJiraConnected).toBeTrue();
182+
183+
// Now simulate a network error on re-check
184+
mockIntegrationService.getJiraStatus.and.returnValue(
185+
throwError(() => new Error('Network error'))
186+
);
187+
188+
// Simulate re-navigating to the page (window focus)
189+
component.ngOnInit();
190+
fixture.detectChanges();
191+
192+
// Connection state should NOT flip to false due to the error
193+
expect(component.isJiraConnected).toBeTrue();
194+
});
195+
196+
it('should trigger Jira sync correctly', () => {
197+
component.syncJiraNow();
198+
fixture.detectChanges();
199+
200+
expect(mockIntegrationService.syncJira).toHaveBeenCalledWith('workspace-1');
201+
expect(component.jiraFeedbackMessage).toContain('started');
202+
});
107203
});

0 commit comments

Comments
 (0)