diff --git a/src/app/components/dashboard/dashboard.css b/src/app/components/dashboard/dashboard.css index ca05606..9288d76 100644 --- a/src/app/components/dashboard/dashboard.css +++ b/src/app/components/dashboard/dashboard.css @@ -109,8 +109,26 @@ font-size: 28px; } +.signals-header { + flex-direction: column; + align-items: flex-start; + gap: 20px; +} + +.title-group { + display: flex; + align-items: center; + gap: 24px; + width: 100%; +} + +.signals-search { + flex: 1; + max-width: 400px; +} + .section-copy { - margin: 6px 0 0; + margin: -10px 0 18px; color: #4a5568; } diff --git a/src/app/components/dashboard/dashboard.html b/src/app/components/dashboard/dashboard.html index d113e98..0813b0b 100644 --- a/src/app/components/dashboard/dashboard.html +++ b/src/app/components/dashboard/dashboard.html @@ -52,10 +52,10 @@

No workspaces yet

-
-
+
+

Signals

-

Slack messages and GitHub updates appear here as action-ready signals.

+
@@ -65,6 +65,7 @@

Signals

+

Slack messages and GitHub updates appear here as action-ready signals.

+
+
+ + + + +
+ +
+
+
+
+ +
+ +
+ + + + +
+ + +
+
+ No matches found for "{{query}}" +
+ +
+
+ # + GH + D +
+
+
+
+
+
+ {{ res.date | date:'shortDate' }} +
+
+
+ + +
+
+ Recent Searches + +
+
+ + + + + {{ s }} +
+
+
+
diff --git a/src/app/components/search-bar/search-bar.ts b/src/app/components/search-bar/search-bar.ts new file mode 100644 index 0000000..26153ef --- /dev/null +++ b/src/app/components/search-bar/search-bar.ts @@ -0,0 +1,126 @@ +import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { Subject, Subscription, of } from 'rxjs'; +import { Decision } from '../../models/decision.model'; +import { Signal } from '../../models/signal.model'; +import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { SearchService, SearchResult } from '../../services/search.service'; + +@Component({ + selector: 'app-search-bar', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule], + templateUrl: './search-bar.html', + styleUrls: ['./search-bar.css'] +}) +export class SearchBarComponent implements OnInit, OnDestroy { + query: string = ''; + sourceFilter: string = 'all'; + results: SearchResult[] = []; + showResults: boolean = false; + recentSearches: string[] = []; + isSearching: boolean = false; + + private searchSubject = new Subject<{q: string, f: string}>(); + private searchSubscription?: Subscription; + + @ViewChild('searchInput') searchInput!: ElementRef; + + @HostListener('document:click', ['$event']) + handleClickOutside(event: Event): void { + if (!this.elRef.nativeElement.contains(event.target)) { + this.showResults = false; + } + } + + constructor( + private searchService: SearchService, + private router: Router, + private elRef: ElementRef, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.recentSearches = this.searchService.getRecentSearches(); + + this.searchSubscription = this.searchSubject.pipe( + debounceTime(150), + distinctUntilChanged((prev, curr) => prev.q === curr.q && prev.f === curr.f), + switchMap(({q, f}) => { + return this.searchService.search(q, f); + }) + ).subscribe({ + next: (results) => { + this.results = results; + this.isSearching = false; + this.cdr.detectChanges(); + }, + error: (err) => { + console.error('Search error:', err); + this.isSearching = false; + this.cdr.detectChanges(); + } + }); + } + + ngOnDestroy(): void { + this.searchSubscription?.unsubscribe(); + } + + onSearchChange(): void { + if (this.query.trim().length < 2) { + this.results = []; + this.isSearching = false; + this.showResults = true; + } else { + this.isSearching = true; + this.showResults = true; + // Do not clear this.results here to provide a smoother dynamic feel while typing + } + this.searchSubject.next({ q: this.query, f: this.sourceFilter }); + } + + onFilterChange(filter: string): void { + this.sourceFilter = filter; + this.onSearchChange(); + } + + selectResult(result: SearchResult): void { + this.searchService.addRecentSearch(this.query); + this.recentSearches = this.searchService.getRecentSearches(); + this.showResults = false; + this.query = ''; + + if (result.type === 'signal') { + const element = document.getElementById('signal-' + result.id); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.classList.add('search-highlight'); + setTimeout(() => element.classList.remove('search-highlight'), 3000); + } + } else if (result.type === 'decision') { + const decision = result.original as Decision; + this.router.navigate(['/workspaces', decision.workspaceId, 'decisions']); + // After navigation, we might also want to scroll to it, but that requires more coordination. + // For now, jumping to the list is a good start. + } + } + + useRecentSearch(search: string): void { + this.query = search; + this.onSearchChange(); + } + + clearRecent(): void { + this.searchService.clearRecentSearches(); + this.recentSearches = []; + } + + highlightMatch(text: string): string { + if (!this.query || !text) return text; + const regex = new RegExp(`(${this.query})`, 'gi'); + return text.replace(regex, '$1'); + } +} diff --git a/src/app/components/signal-board/signal-board.css b/src/app/components/signal-board/signal-board.css index 67a960b..8394950 100644 --- a/src/app/components/signal-board/signal-board.css +++ b/src/app/components/signal-board/signal-board.css @@ -1,3 +1,15 @@ +@keyframes highlight-pulse { + 0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); border-color: #6366f1; } + 70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); } + 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); } +} + +.search-highlight { + animation: highlight-pulse 2s ease-out; + border-color: #6366f1 !important; + scroll-margin-top: 100px; +} + .signals-board { display: grid; gap: 1rem; diff --git a/src/app/components/signal-board/signal-board.html b/src/app/components/signal-board/signal-board.html index aaf570d..2608f1f 100644 --- a/src/app/components/signal-board/signal-board.html +++ b/src/app/components/signal-board/signal-board.html @@ -1,5 +1,5 @@
-
+
{{ getSourceLabel(signal) }} diff --git a/src/app/services/search.service.spec.ts b/src/app/services/search.service.spec.ts new file mode 100644 index 0000000..7708aa3 --- /dev/null +++ b/src/app/services/search.service.spec.ts @@ -0,0 +1,184 @@ +import { TestBed } from '@angular/core/testing'; +import { SearchService } from './search.service'; +import { SignalService } from './signal.service'; +import { DecisionService } from './decision.service'; +import { of } from 'rxjs'; +import { Signal } from '../models/signal.model'; +import { Decision } from '../models/decision.model'; + +describe('SearchService', () => { + let service: SearchService; + let mockSignalService: any; + let mockDecisionService: any; + + beforeEach(() => { + mockSignalService = { + getSignals: jasmine.createSpy('getSignals').and.returnValue(of([])) + }; + + mockDecisionService = { + getDecisions: jasmine.createSpy('getDecisions').and.returnValue(of([])) + }; + + TestBed.configureTestingModule({ + providers: [ + SearchService, + { provide: SignalService, useValue: mockSignalService }, + { provide: DecisionService, useValue: mockDecisionService } + ] + }); + + service = TestBed.inject(SearchService); + + // Clear local storage for recent searches testing + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('search', () => { + const mockSignals: Signal[] = [ + { + id: '1', + title: 'Fix login bug', + content: 'Users cannot log in via SSO', + sourceType: 'github', + sourceId: 'src-1', + externalId: 'ext-1', + author: 'UserA', + receivedAt: new Date('2023-01-01T10:00:00Z'), + status: 'unread', + metadata: {} + }, + { + id: '2', + title: 'Deployment issue', + content: 'Server is down on production', + sourceType: 'slack', + sourceId: 'src-2', + externalId: 'ext-2', + author: 'UserB', + receivedAt: new Date('2023-01-02T10:00:00Z'), + status: 'unread', + metadata: {} + } + ]; + + const mockDecisions: Decision[] = [ + { + id: '1', + title: 'Use OAuth2 for SSO', + description: 'Decided to use OAuth2 to fix login bug', + createdAt: new Date('2023-01-03T10:00:00Z'), + updatedAt: new Date('2023-01-03T10:00:00Z'), + workspaceId: 'ws-1', + status: 'CLOSED', + userId: 'user-1', + isDeleted: false + } + ]; + + beforeEach(() => { + mockSignalService.getSignals.and.returnValue(of(mockSignals)); + mockDecisionService.getDecisions.and.returnValue(of(mockDecisions)); + }); + + it('should return empty array if query is less than 2 characters', (done) => { + service.search('a').subscribe(results => { + expect(results.length).toBe(0); + expect(mockSignalService.getSignals).not.toHaveBeenCalled(); + expect(mockDecisionService.getDecisions).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should find results across signals and decisions with matching text', (done) => { + service.search('login').subscribe(results => { + expect(results.length).toBe(2); + // Should sort by date descending (Decision is 01-03, Signal is 01-01) + expect(results[0].type).toBe('decision'); + expect(results[0].title).toBe('Use OAuth2 for SSO'); + expect(results[1].type).toBe('signal'); + expect(results[1].title).toBe('Fix login bug'); + done(); + }); + }); + + it('should filter by slack source', (done) => { + service.search('issue', 'slack').subscribe(results => { + expect(results.length).toBe(1); + expect(results[0].type).toBe('signal'); + expect(results[0].source).toBe('slack'); + expect(results[0].title).toBe('Deployment issue'); + done(); + }); + }); + + it('should filter by github source', (done) => { + service.search('login', 'github').subscribe(results => { + expect(results.length).toBe(1); + expect(results[0].type).toBe('signal'); + expect(results[0].source).toBe('github'); + done(); + }); + }); + + it('should filter by decision source', (done) => { + service.search('oauth', 'decision').subscribe(results => { + expect(results.length).toBe(1); + expect(results[0].type).toBe('decision'); + expect(results[0].title).toBe('Use OAuth2 for SSO'); + done(); + }); + }); + }); + + describe('Recent Searches', () => { + it('should add valid searches to localStorage and move them to front', () => { + service.addRecentSearch('first'); + service.addRecentSearch('second'); + service.addRecentSearch('third'); + + let recent = service.getRecentSearches(); + expect(recent).toEqual(['third', 'second', 'first']); + + // Adding duplicate should move it to front + service.addRecentSearch('first'); + recent = service.getRecentSearches(); + expect(recent).toEqual(['first', 'third', 'second']); + }); + + it('should not add queries less than 2 characters', () => { + service.addRecentSearch('a'); + expect(service.getRecentSearches().length).toBe(0); + }); + + it('should limit recent searches to maximum 5', () => { + service.addRecentSearch('q1'); + service.addRecentSearch('q2'); + service.addRecentSearch('q3'); + service.addRecentSearch('q4'); + service.addRecentSearch('q5'); + service.addRecentSearch('q6'); // Should push out q1 + + const recent = service.getRecentSearches(); + expect(recent.length).toBe(5); + expect(recent[0]).toBe('q6'); + expect(recent.includes('q1')).toBeFalse(); + }); + + it('should clear recent searches', () => { + service.addRecentSearch('query'); + expect(service.getRecentSearches().length).toBe(1); + + service.clearRecentSearches(); + expect(service.getRecentSearches().length).toBe(0); + }); + }); +}); diff --git a/src/app/services/search.service.ts b/src/app/services/search.service.ts new file mode 100644 index 0000000..28b91f3 --- /dev/null +++ b/src/app/services/search.service.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@angular/core'; +import { Observable, combineLatest, map, of } from 'rxjs'; +import { SignalService } from './signal.service'; +import { DecisionService } from './decision.service'; +import { Signal } from '../models/signal.model'; +import { Decision } from '../models/decision.model'; + +export type SearchResultType = 'signal' | 'decision'; + +export interface SearchResult { + id: string; + type: SearchResultType; + title: string; + content: string; + source: string; + date: Date; + url?: string; + original: Signal | Decision; +} + +@Injectable({ + providedIn: 'root' +}) +export class SearchService { + private readonly RECENT_SEARCHES_KEY = 'sentinent_recent_searches'; + private readonly MAX_RECENT_SEARCHES = 5; + + constructor( + private signalService: SignalService, + private decisionService: DecisionService + ) {} + + search(query: string, sourceFilter: string = 'all'): Observable { + const q = query.toLowerCase().trim(); + if (q.length < 2) return of([]); + + // Signal filtering + const signals$ = this.signalService.getSignals({ source: 'all', status: 'all' }).pipe( + map(signals => signals.filter(s => { + const matchesQuery = (s.title + ' ' + s.content).toLowerCase().includes(q); + const matchesSource = sourceFilter === 'all' || sourceFilter === s.sourceType; + return matchesQuery && matchesSource; + })), + map(signals => signals.map(s => this.mapSignalToResult(s))) + ); + + // Decision filtering + const decisions$ = this.decisionService.getDecisions('ws-1').pipe( + map(decisions => decisions.filter(d => { + const matchesQuery = (d.title + ' ' + (d.description || '')).toLowerCase().includes(q); + const matchesSource = sourceFilter === 'all' || sourceFilter === 'decision'; + return matchesQuery && matchesSource; + })), + map(decisions => decisions.map(d => this.mapDecisionToResult(d))) + ); + + return combineLatest([signals$, decisions$]).pipe( + map(([signals, decisions]) => { + const combined = [...signals, ...decisions]; + // Sort by date descending + return combined.sort((a, b) => b.date.getTime() - a.date.getTime()); + }) + ); + } + + private mapSignalToResult(s: Signal): SearchResult { + return { + id: s.id, + type: 'signal', + title: s.title, + content: s.content, + source: s.sourceType, + date: s.receivedAt, + url: s.url, + original: s + }; + } + + private mapDecisionToResult(d: Decision): SearchResult { + return { + id: d.id, + type: 'decision', + title: d.title, + content: d.description || '', + source: 'decision', + date: d.createdAt, + original: d + }; + } + + getRecentSearches(): string[] { + const stored = localStorage.getItem(this.RECENT_SEARCHES_KEY); + return stored ? JSON.parse(stored) : []; + } + + addRecentSearch(query: string): void { + if (!query || query.trim().length < 2) return; + + let searches = this.getRecentSearches(); + // Remove if already exists (to move to top) + searches = searches.filter(s => s.toLowerCase() !== query.toLowerCase()); + // Add to front + searches.unshift(query); + // Limit size + searches = searches.slice(0, this.MAX_RECENT_SEARCHES); + + localStorage.setItem(this.RECENT_SEARCHES_KEY, JSON.stringify(searches)); + } + + clearRecentSearches(): void { + localStorage.removeItem(this.RECENT_SEARCHES_KEY); + } +}