From e378c61f2a7c7f49d41f49cfd58676387fc009ea Mon Sep 17 00:00:00 2001 From: Neethika Date: Wed, 29 Apr 2026 16:20:57 -0400 Subject: [PATCH] feat(nav): vertical sidebar, standalone integrations page, remove integrations from profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert app-nav from horizontal topbar to fixed 220px vertical sidebar with icons for Dashboard, Integrations, Profile and Logout at bottom - Add /integrations top-level route backed by new IntegrationsPageComponent which fetches workspaces and passes the active workspace to WorkspaceIntegrationsComponent; multi-workspace dropdown included - Remove integrations section from ProfileComponent and clean up WorkspaceService dependency — profile now covers personal settings only - Update dashboard and profile CSS to use margin-left: 220px offset instead of padding-top: 56px - Fix dashboard.spec: .nav-logout -> .sidebar-logout - Add 10 tests for repo edit/save UX in workspace-integrations.spec - Add 9 tests for IntegrationsPageComponent (new integrations-page.spec) --- src/app/app.routes.ts | 5 + src/app/components/app-nav/app-nav.css | 104 ++++-- src/app/components/app-nav/app-nav.html | 49 ++- src/app/components/dashboard/dashboard.css | 7 +- .../components/dashboard/dashboard.spec.ts | 2 +- .../integrations-page/integrations-page.css | 127 +++++++ .../integrations-page/integrations-page.html | 41 +++ .../integrations-page.spec.ts | 146 ++++++++ .../integrations-page/integrations-page.ts | 45 +++ src/app/components/profile/profile.css | 11 +- src/app/components/profile/profile.html | 330 ++++++++---------- src/app/components/profile/profile.ts | 23 +- .../workspace-integrations.spec.ts | 133 +++++++ 13 files changed, 768 insertions(+), 255 deletions(-) create mode 100644 src/app/components/integrations-page/integrations-page.css create mode 100644 src/app/components/integrations-page/integrations-page.html create mode 100644 src/app/components/integrations-page/integrations-page.spec.ts create mode 100644 src/app/components/integrations-page/integrations-page.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 6e92b31..1a935e1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -26,6 +26,11 @@ export const routes: Routes = [ loadComponent: () => import('./components/profile/profile').then(m => m.ProfileComponent), canActivate: [authGuard] }, + { + path: 'integrations', + loadComponent: () => import('./components/integrations-page/integrations-page').then(m => m.IntegrationsPageComponent), + canActivate: [authGuard] + }, { path: 'workspace/create', component: CreateWorkspace, diff --git a/src/app/components/app-nav/app-nav.css b/src/app/components/app-nav/app-nav.css index 515e5b3..c43f618 100644 --- a/src/app/components/app-nav/app-nav.css +++ b/src/app/components/app-nav/app-nav.css @@ -2,40 +2,44 @@ display: block; } -.app-nav { +/* ── Sidebar shell ───────────────────────────────────────── */ + +.sidebar { position: fixed; top: 0; left: 0; - right: 0; + bottom: 0; + width: 220px; z-index: 100; - height: 56px; display: flex; - align-items: center; - justify-content: space-between; - padding: 0 28px; - background: rgba(5, 5, 5, 0.88); + flex-direction: column; + padding: 20px 12px; + background: rgba(5, 5, 5, 0.94); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); - border-bottom: 1px solid rgba(255, 255, 255, 0.07); + border-right: 1px solid rgba(255, 255, 255, 0.07); } /* ── Brand ───────────────────────────────────────────────── */ -.nav-brand { +.sidebar-brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; color: #f7f7f7; - font-size: 17px; + font-size: 16px; font-weight: 800; letter-spacing: -0.02em; + padding: 6px 8px; + margin-bottom: 28px; + flex-shrink: 0; } -.nav-brand-icon { - width: 32px; - height: 32px; - border-radius: 8px; +.sidebar-brand-icon { + width: 30px; + height: 30px; + border-radius: 7px; background: #f7f7f7; color: #050505; display: grid; @@ -43,41 +47,56 @@ flex-shrink: 0; } -/* ── Links ───────────────────────────────────────────────── */ +/* ── Nav links ───────────────────────────────────────────── */ -.nav-links { +.sidebar-links { display: flex; - align-items: center; - gap: 6px; + flex-direction: column; + gap: 2px; + flex: 1; } -.nav-link { +.sidebar-link { position: relative; - display: inline-flex; + display: flex; align-items: center; - gap: 7px; + gap: 10px; text-decoration: none; - color: rgba(247, 247, 247, 0.65); + color: rgba(247, 247, 247, 0.55); font-size: 14px; font-weight: 600; - padding: 6px 12px; - border-radius: 8px; + padding: 9px 10px; + border-radius: 9px; transition: color 0.15s ease, background 0.15s ease; } -.nav-link:hover { +.sidebar-link svg { + flex-shrink: 0; + opacity: 0.7; +} + +.sidebar-link:hover { color: #f7f7f7; background: rgba(255, 255, 255, 0.07); } -.nav-link.active { +.sidebar-link:hover svg { + opacity: 1; +} + +.sidebar-link.active { color: #f7f7f7; background: rgba(255, 255, 255, 0.1); } +.sidebar-link.active svg { + opacity: 1; +} + /* ── Unread badge ────────────────────────────────────────── */ -.nav-badge { +.sidebar-badge { + margin-left: auto; display: inline-flex; align-items: center; justify-content: center; @@ -94,21 +113,34 @@ /* ── Logout ──────────────────────────────────────────────── */ -.nav-logout { - border: 1px solid rgba(255, 255, 255, 0.18); +.sidebar-logout { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + border: none; background: transparent; - color: rgba(247, 247, 247, 0.65); - border-radius: 8px; - padding: 6px 12px; + color: rgba(247, 247, 247, 0.45); font-size: 14px; font-weight: 600; + padding: 9px 10px; + border-radius: 9px; cursor: pointer; - transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; - margin-left: 6px; + transition: color 0.15s ease, background 0.15s ease; + flex-shrink: 0; + text-align: left; +} + +.sidebar-logout svg { + flex-shrink: 0; + opacity: 0.6; } -.nav-logout:hover { +.sidebar-logout:hover { color: #f7f7f7; background: rgba(255, 255, 255, 0.07); - border-color: rgba(255, 255, 255, 0.3); +} + +.sidebar-logout:hover svg { + opacity: 1; } diff --git a/src/app/components/app-nav/app-nav.html b/src/app/components/app-nav/app-nav.html index a4d6fee..c4138d9 100644 --- a/src/app/components/app-nav/app-nav.html +++ b/src/app/components/app-nav/app-nav.html @@ -1,7 +1,7 @@ - diff --git a/src/app/components/dashboard/dashboard.css b/src/app/components/dashboard/dashboard.css index d89cd23..a494433 100644 --- a/src/app/components/dashboard/dashboard.css +++ b/src/app/components/dashboard/dashboard.css @@ -21,7 +21,7 @@ position: relative; z-index: 1; min-height: 100vh; - padding-top: 56px; + margin-left: 220px; } .hero { @@ -565,6 +565,11 @@ } @media (max-width: 768px) { + .dashboard-shell { + margin-left: 0; + padding-top: 56px; + } + .hero, .content { padding-left: 18px; diff --git a/src/app/components/dashboard/dashboard.spec.ts b/src/app/components/dashboard/dashboard.spec.ts index e46be63..ca9b105 100644 --- a/src/app/components/dashboard/dashboard.spec.ts +++ b/src/app/components/dashboard/dashboard.spec.ts @@ -112,7 +112,7 @@ describe('Dashboard', () => { }); it('should call logout on button click', () => { - const button = fixture.nativeElement.querySelector('.nav-logout') as HTMLButtonElement; + const button = fixture.nativeElement.querySelector('.sidebar-logout') as HTMLButtonElement; button.click(); expect(mockAuthService.logout).toHaveBeenCalled(); }); diff --git a/src/app/components/integrations-page/integrations-page.css b/src/app/components/integrations-page/integrations-page.css new file mode 100644 index 0000000..5dc7b0c --- /dev/null +++ b/src/app/components/integrations-page/integrations-page.css @@ -0,0 +1,127 @@ +:host { + display: block; + min-height: 100vh; + background: #f4f4f4; + color: #0a0a0a; +} + +* { + box-sizing: border-box; +} + +.integrations-page { + margin-left: 220px; + min-height: 100vh; +} + +.integrations-page-shell { + max-width: 960px; + margin: 0 auto; + padding: 42px 32px 80px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + margin-bottom: 32px; +} + +.eyebrow { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.75rem; + color: #2563eb; + font-weight: 700; +} + +.page-header h1 { + margin: 0 0 8px; + font-size: 32px; + font-weight: 800; + letter-spacing: -0.02em; +} + +.intro { + margin: 0; + color: #475569; + line-height: 1.6; + max-width: 560px; +} + +.workspace-selector { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.workspace-selector-label { + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.workspace-select { + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 8px 12px; + font-size: 14px; + font-weight: 500; + background: #fff; + color: #0f172a; + cursor: pointer; +} + +.loading-text { + color: #64748b; + font-size: 15px; + padding-top: 60px; + text-align: center; +} + +.empty-state { + text-align: center; + padding: 80px 32px; +} + +.empty-state h2 { + margin: 0 0 10px; + font-size: 24px; +} + +.empty-state p { + margin: 0 0 24px; + color: #64748b; +} + +.primary-btn { + display: inline-block; + padding: 10px 20px; + background: #111827; + color: #fff; + border-radius: 999px; + text-decoration: none; + font-weight: 700; + font-size: 14px; +} + +@media (max-width: 768px) { + .integrations-page { + margin-left: 0; + padding-top: 56px; + } + + .integrations-page-shell { + padding-left: 18px; + padding-right: 18px; + } + + .page-header { + flex-direction: column; + } +} diff --git a/src/app/components/integrations-page/integrations-page.html b/src/app/components/integrations-page/integrations-page.html new file mode 100644 index 0000000..94bf96f --- /dev/null +++ b/src/app/components/integrations-page/integrations-page.html @@ -0,0 +1,41 @@ + + +
+ +
+

Loading workspaces...

+
+ +
+
+

No workspace yet

+

Create a workspace first to connect your tools.

+ Create a workspace +
+
+ +
+ + + +
+ +
diff --git a/src/app/components/integrations-page/integrations-page.spec.ts b/src/app/components/integrations-page/integrations-page.spec.ts new file mode 100644 index 0000000..112eb26 --- /dev/null +++ b/src/app/components/integrations-page/integrations-page.spec.ts @@ -0,0 +1,146 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { IntegrationsPageComponent } from './integrations-page'; +import { WorkspaceService } from '../../services/workspace'; +import { IntegrationService } from '../../services/integration.service'; +import { SignalService } from '../../services/signal.service'; + +describe('IntegrationsPageComponent', () => { + let component: IntegrationsPageComponent; + let fixture: ComponentFixture; + let mockWorkspaceService: jasmine.SpyObj; + let mockIntegrationService: jasmine.SpyObj; + let mockSignalService: jasmine.SpyObj; + + const twoWorkspaces = [ + { id: '1', name: 'Alpha Workspace', description: '', ownerId: '1', createdDate: new Date() }, + { id: '2', name: 'Beta Workspace', description: '', ownerId: '1', createdDate: new Date() }, + ]; + + const oneWorkspace = [twoWorkspaces[0]]; + + beforeEach(async () => { + mockWorkspaceService = jasmine.createSpyObj('WorkspaceService', ['getWorkspaces']); + mockWorkspaceService.getWorkspaces.and.returnValue(of(oneWorkspace)); + + // IntegrationService is used by the embedded WorkspaceIntegrationsComponent + mockIntegrationService = jasmine.createSpyObj('IntegrationService', [ + 'getSlackChannels', + 'getGitHubRepos', + 'getJiraStatus', + 'getJiraProjects', + 'syncSlack', + 'syncGitHub', + 'syncJira', + 'connectSlack', + 'connectGitHub', + 'connectJira', + 'disconnectSlack', + 'disconnectGitHub', + 'disconnectJira', + 'updateSlackChannels', + 'updateGitHubRepos', + ]); + mockIntegrationService.getSlackChannels.and.returnValue(of({ connected: false, channels: [] })); + mockIntegrationService.getGitHubRepos.and.returnValue(of({ connected: false, repos: [] })); + mockIntegrationService.getJiraStatus.and.returnValue(of({ connected: false })); + mockIntegrationService.getJiraProjects.and.returnValue(of({ connected: false, resources: [] })); + + mockSignalService = jasmine.createSpyObj('SignalService', ['getSignals']); + mockSignalService.getSignals.and.returnValue(of([])); + + await TestBed.configureTestingModule({ + imports: [IntegrationsPageComponent, RouterTestingModule], + providers: [ + { provide: WorkspaceService, useValue: mockWorkspaceService }, + { provide: IntegrationService, useValue: mockIntegrationService }, + { provide: SignalService, useValue: mockSignalService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { paramMap: convertToParamMap({}) }, + pathFromRoot: [{ snapshot: { paramMap: convertToParamMap({}) } }] + } + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(IntegrationsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('loads workspaces on init and sets first workspace as selected', () => { + expect(mockWorkspaceService.getWorkspaces).toHaveBeenCalled(); + expect(component.workspaces.length).toBe(1); + expect(component.selectedWorkspaceId).toBe('1'); + expect(component.isLoading).toBeFalse(); + }); + + it('renders the page heading', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Integrations'); + }); + + it('renders the embedded WorkspaceIntegrationsComponent when workspace is loaded', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('app-workspace-integrations')).not.toBeNull(); + }); + + it('shows the empty state when no workspaces exist', async () => { + mockWorkspaceService.getWorkspaces.and.returnValue(of([])); + + fixture = TestBed.createComponent(IntegrationsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.workspaces.length).toBe(0); + expect(component.selectedWorkspaceId).toBe(''); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('No workspace yet'); + }); + + it('hides the workspace selector when only one workspace', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.workspace-select')).toBeNull(); + }); + + it('shows the workspace selector when multiple workspaces exist', async () => { + mockWorkspaceService.getWorkspaces.and.returnValue(of(twoWorkspaces)); + + fixture = TestBed.createComponent(IntegrationsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.workspace-select')).not.toBeNull(); + const options = compiled.querySelectorAll('.workspace-select option'); + expect(options.length).toBe(2); + expect(options[0].textContent?.trim()).toBe('Alpha Workspace'); + expect(options[1].textContent?.trim()).toBe('Beta Workspace'); + }); + + it('selectWorkspace() updates the selected workspace id', () => { + component.selectWorkspace('2'); + expect(component.selectedWorkspaceId).toBe('2'); + }); + + it('handles workspace load error gracefully and stops loading', () => { + mockWorkspaceService.getWorkspaces.and.returnValue( + throwError(() => new Error('Server error')) + ); + + fixture = TestBed.createComponent(IntegrationsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.isLoading).toBeFalse(); + expect(component.workspaces.length).toBe(0); + }); +}); diff --git a/src/app/components/integrations-page/integrations-page.ts b/src/app/components/integrations-page/integrations-page.ts new file mode 100644 index 0000000..f1187be --- /dev/null +++ b/src/app/components/integrations-page/integrations-page.ts @@ -0,0 +1,45 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { WorkspaceService } from '../../services/workspace'; +import { Workspace } from '../../models/workspace'; +import { WorkspaceIntegrationsComponent } from '../workspace-integrations/workspace-integrations'; +import { AppNavComponent } from '../app-nav/app-nav'; + +@Component({ + selector: 'app-integrations-page', + standalone: true, + imports: [CommonModule, RouterLink, WorkspaceIntegrationsComponent, AppNavComponent], + templateUrl: './integrations-page.html', + styleUrl: './integrations-page.css', +}) +export class IntegrationsPageComponent implements OnInit { + private readonly workspaceService = inject(WorkspaceService); + private readonly cdr = inject(ChangeDetectorRef); + + workspaces: Workspace[] = []; + selectedWorkspaceId = ''; + isLoading = true; + + ngOnInit(): void { + this.workspaceService.getWorkspaces().subscribe({ + next: (ws) => { + this.workspaces = ws; + if (ws.length > 0) { + this.selectedWorkspaceId = String(ws[0].id); + } + this.isLoading = false; + this.cdr.detectChanges(); + }, + error: () => { + this.isLoading = false; + this.cdr.detectChanges(); + } + }); + } + + selectWorkspace(id: string): void { + this.selectedWorkspaceId = id; + this.cdr.detectChanges(); + } +} diff --git a/src/app/components/profile/profile.css b/src/app/components/profile/profile.css index 2770ecf..9e28f17 100644 --- a/src/app/components/profile/profile.css +++ b/src/app/components/profile/profile.css @@ -11,16 +11,17 @@ box-sizing: border-box; } +.profile-page { + margin-left: 220px; + min-height: 100vh; +} + .profile-shell { - max-width: 1180px; + max-width: 960px; margin: 0 auto; padding: 42px 24px 64px; } -:host > .profile-shell:first-of-type { - padding-top: calc(56px + 42px); -} - .profile-hero { display: flex; justify-content: space-between; diff --git a/src/app/components/profile/profile.html b/src/app/components/profile/profile.html index 1eadcd9..e73cb2b 100644 --- a/src/app/components/profile/profile.html +++ b/src/app/components/profile/profile.html @@ -1,202 +1,174 @@ -
-
-

Loading

-

Loading your profile...

-

We're pulling your account details from Sentinent now.

-
-
- -
-
-

Profile unavailable

-

We couldn't load this profile yet.

-

{{ errorMessage || 'Try refreshing after the backend is running with the new profile API.' }}

-
- - Back to dashboard -
-
-
- -
-
-
-

Account Profile

-

{{ displayName() }}

-

Keep your identity and workspace context accurate so teammates know who is behind each signal, decision, and integration.

-
- {{ withFallback(profile.roleLabel, 'Role not set') }} - {{ withFallback(profile.organization, 'Organization not set') }} - {{ withFallback(profile.timezone, 'Timezone not set') }} +
+ +
+
+

Loading

+

Loading your profile...

+

We're pulling your account details from Sentinent now.

+
+
+ +
+
+

Profile unavailable

+

We couldn't load this profile yet.

+

{{ errorMessage || 'Try refreshing after the backend is running with the new profile API.' }}

+
+ + Back to dashboard
-
- -
- - -
-
- - - - -
- - -
-
-
-
-

Identity

-

Profile details

-
+
+
+ +
+
+
+

Account Profile

+

{{ displayName() }}

+

Keep your identity and workspace context accurate so teammates know who is behind each signal, decision, and integration.

+
+ {{ withFallback(profile.roleLabel, 'Role not set') }} + {{ withFallback(profile.organization, 'Organization not set') }} + {{ withFallback(profile.timezone, 'Timezone not set') }}
+
-
-
- Full name - {{ displayName() }} -
-
- Email - {{ profile.email || 'Not provided yet' }} -
-
- Job title - {{ withFallback(profile.jobTitle, 'Not provided yet') }} -
-
- Organization or team - {{ withFallback(profile.organization, 'Not provided yet') }} -
-
- Timezone - {{ withFallback(profile.timezone, 'Not provided yet') }} +
+ + +
+
+ + + + +
+ + +
+
+
+
+

Identity

+

Profile details

+
-
-
-
-
- - +
+
+ Full name + {{ displayName() }}
- -
- - +
+ Email + {{ profile.email || 'Not provided yet' }}
- -
- - +
+ Job title + {{ withFallback(profile.jobTitle, 'Not provided yet') }}
- -
- - +
+ Organization or team + {{ withFallback(profile.organization, 'Not provided yet') }}
- -
- - +
+ Timezone + {{ withFallback(profile.timezone, 'Not provided yet') }}
- -
- - +
+ Role label + {{ withFallback(profile.roleLabel, 'Not provided yet') }}
+ -
- -
- - - -
-
-
-

About

-

Personal summary

+
+
+
+

Edit mode

+

Update your profile

+
-
-

{{ withFallback(profile.bio, 'Add a short bio to help teammates understand your role.') }}

-
-
-
- -
-
-
-

Connected accounts

-

Integrations

-

Connect Slack, GitHub, and Jira to pull your assigned work and team messages into the signals feed.

-
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
-
- - +
+ +
+ + + +
+
+
+

About

+

Personal summary

+
+
+

{{ withFallback(profile.bio, 'Add a short bio to help teammates understand your role.') }}

+
-
- - +
-
-

Connected accounts

-

Integrations

-

Create a workspace first to set up your Slack, GitHub, and Jira connections.

- Create a workspace -
-
+ diff --git a/src/app/components/profile/profile.ts b/src/app/components/profile/profile.ts index 1683cf1..f10e43c 100644 --- a/src/app/components/profile/profile.ts +++ b/src/app/components/profile/profile.ts @@ -5,15 +5,12 @@ import { RouterLink } from '@angular/router'; import { Subscription } from 'rxjs'; import { UserProfile, UserProfileUpdate } from '../../models/user-profile.model'; import { UserProfileService } from '../../services/user-profile.service'; -import { WorkspaceService } from '../../services/workspace'; -import { Workspace } from '../../models/workspace'; -import { WorkspaceIntegrationsComponent } from '../workspace-integrations/workspace-integrations'; import { AppNavComponent } from '../app-nav/app-nav'; @Component({ selector: 'app-profile', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, WorkspaceIntegrationsComponent, AppNavComponent], + imports: [CommonModule, FormsModule, RouterLink, AppNavComponent], templateUrl: './profile.html', styleUrl: './profile.css', }) @@ -21,7 +18,6 @@ export class ProfileComponent implements OnInit, OnDestroy { private static readonly PROFILE_LOAD_TIMEOUT_MS = 8000; private readonly userProfileService = inject(UserProfileService); - private readonly workspaceService = inject(WorkspaceService); private readonly cdr = inject(ChangeDetectorRef); private loadSubscription?: Subscription; private loadTimeoutId?: ReturnType; @@ -41,31 +37,14 @@ export class ProfileComponent implements OnInit, OnDestroy { errorMessage = ''; successMessage = ''; - workspaces: Workspace[] = []; - selectedWorkspaceId = ''; - ngOnInit(): void { this.loadProfile(); - this.workspaceService.getWorkspaces().subscribe({ - next: (ws) => { - this.workspaces = ws; - if (ws.length > 0) { - this.selectedWorkspaceId = String(ws[0].id); - } - this.cdr.detectChanges(); - }, - error: () => {} - }); } ngOnDestroy(): void { this.clearActiveLoad(); } - selectWorkspace(id: string): void { - this.selectedWorkspaceId = id; - } - startEditing(): void { if (!this.profile) return; this.isEditing = true; diff --git a/src/app/components/workspace-integrations/workspace-integrations.spec.ts b/src/app/components/workspace-integrations/workspace-integrations.spec.ts index 41a715d..a59c2af 100644 --- a/src/app/components/workspace-integrations/workspace-integrations.spec.ts +++ b/src/app/components/workspace-integrations/workspace-integrations.spec.ts @@ -217,4 +217,137 @@ describe('WorkspaceIntegrationsComponent', () => { expect(mockIntegrationService.syncJira).toHaveBeenCalledWith('workspace-1'); expect(component.jiraFeedbackMessage).toContain('started'); }); + + // --- Repo edit/save UX tests --- + + it('should start in summary view (not editing) when repos are already saved', () => { + // Default mock has one repo with isConnected: true + expect(component.isEditingRepos).toBeFalse(); + }); + + it('should start in edit mode when connected but no repos are saved', () => { + mockIntegrationService.getGitHubRepos.and.returnValue(of({ + connected: true, + accountName: 'Sentinent Engineering', + accountHandle: '@sentinent-dev', + repos: [ + { id: 2, name: 'backend-go', fullName: 'Sentinent-AI/backend-go', isConnected: false } + ], + lastSyncAt: undefined + })); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.isEditingRepos).toBeTrue(); + }); + + it('hasSavedRepos returns true when at least one repo is connected', () => { + // Default mock has one repo with isConnected: true + expect(component.hasSavedRepos).toBeTrue(); + }); + + it('hasSavedRepos returns false when no repos are connected', () => { + mockIntegrationService.getGitHubRepos.and.returnValue(of({ + connected: true, + accountName: 'Sentinent Engineering', + accountHandle: '@sentinent-dev', + repos: [ + { id: 2, name: 'backend-go', fullName: 'Sentinent-AI/backend-go', isConnected: false } + ], + lastSyncAt: undefined + })); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.hasSavedRepos).toBeFalse(); + }); + + it('editRepos() switches to edit mode', () => { + expect(component.isEditingRepos).toBeFalse(); + + component.editRepos(); + + expect(component.isEditingRepos).toBeTrue(); + }); + + it('cancelEditRepos() exits edit mode and resets checkboxes to last saved state', () => { + // Start in edit mode + component.editRepos(); + expect(component.isEditingRepos).toBeTrue(); + + // Change selection + component.toggleRepo(1, false); + expect(component.selectedRepoIds).not.toContain(1); + + // Cancel — should restore isConnected repos from component.repos + component.cancelEditRepos(); + + expect(component.isEditingRepos).toBeFalse(); + expect(component.selectedRepoIds).toContain(1); + }); + + it('saveRepoSelection() exits edit mode on success', () => { + component.editRepos(); + expect(component.isEditingRepos).toBeTrue(); + + component.saveRepoSelection(); + fixture.detectChanges(); + + expect(mockIntegrationService.updateGitHubRepos).toHaveBeenCalledWith('workspace-1', [1]); + expect(component.isEditingRepos).toBeFalse(); + expect(component.githubFeedbackMessage).toContain('saved'); + }); + + it('saveRepoSelection() stays in edit mode and shows error on failure', () => { + const { throwError } = require('rxjs'); + mockIntegrationService.updateGitHubRepos.and.returnValue( + throwError(() => new Error('Network error')) + ); + + component.editRepos(); + component.saveRepoSelection(); + fixture.detectChanges(); + + expect(component.isGitHubSaving).toBeFalse(); + expect(component.githubErrorMessage).toContain('Could not save'); + }); + + it('saveRepoSelection() always clears the loading spinner via finalize', () => { + component.editRepos(); + component.isGitHubSaving = true; + + component.saveRepoSelection(); + fixture.detectChanges(); + + expect(component.isGitHubSaving).toBeFalse(); + }); + + it('summary view renders only connected repos with Active badge', () => { + // isEditingRepos is false by default (repos saved) + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + + const activeTag = compiled.querySelector('.repo-tag'); + expect(activeTag).not.toBeNull(); + expect(activeTag?.textContent?.trim()).toBe('Active'); + + // No checkboxes in summary view + const checkboxes = compiled.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes.length).toBe(0); + }); + + it('edit view renders checkboxes and Save Selection button', () => { + component.editRepos(); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + + const checkboxes = compiled.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes.length).toBeGreaterThan(0); + + const saveBtn = Array.from(compiled.querySelectorAll('button')) + .find(b => b.textContent?.includes('Save Selection')); + expect(saveBtn).not.toBeNull(); + }); });