From 8beeb268613abe2b3f83f24399ead46d0443a990 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 28 Apr 2026 13:17:36 -0400 Subject: [PATCH 1/2] feat(jira): add UI for jira transitions and comments --- .../components/signal-board/signal-board.css | 91 +++++++++++++++ .../components/signal-board/signal-board.html | 35 +++++- .../components/signal-board/signal-board.ts | 91 ++++++++++++++- .../workspace-integrations.spec.ts | 104 +++++++++++++++++- .../workspace-integrations.ts | 50 +++++++-- src/app/models/signal.model.ts | 1 + src/app/services/integration.service.ts | 56 ++++++++++ src/app/services/signal.service.ts | 1 + 8 files changed, 410 insertions(+), 19 deletions(-) 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 }}

- {{ getOpenLabel(signal) }} - - +
+ + +
+ +
+ {{ getOpenLabel(signal) }} + + +
+ +
+
{{ toastMessage }}
+
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..a8460d1 100644 --- a/src/app/components/workspace-integrations/workspace-integrations.ts +++ b/src/app/components/workspace-integrations/workspace-integrations.ts @@ -60,9 +60,9 @@ export class WorkspaceIntegrationsComponent implements OnInit, OnDestroy { 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 +260,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/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 ?? '', From e63e1201446e4aba661ea74a75d033e0717540ff Mon Sep 17 00:00:00 2001 From: = Date: Tue, 28 Apr 2026 14:05:02 -0400 Subject: [PATCH 2/2] fix(integrations): ensure workspace_id persistence across navigation (ref Sentinent-AI/Sentinent#29) --- .../workspace-integrations/workspace-integrations.ts | 12 +++++++++++- .../workspace-members/workspace-members.ts | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/app/components/workspace-integrations/workspace-integrations.ts b/src/app/components/workspace-integrations/workspace-integrations.ts index a8460d1..b641304 100644 --- a/src/app/components/workspace-integrations/workspace-integrations.ts +++ b/src/app/components/workspace-integrations/workspace-integrations.ts @@ -49,11 +49,21 @@ 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); } 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) {