From 28c657abcf6b92216c225c067ec86c5057677f97 Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Tue, 18 Nov 2025 08:42:23 -0800 Subject: [PATCH 1/3] refactor(ui): implement global settings and fix reactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented global settings at /settings route and fixed signal reactivity in meetings/settings dashboards to properly respond to project context changes. Key changes: - Created new global settings module with reactive pattern - Fixed meetings dashboard to react to project context changes - Moved settings from project-specific to global route - Applied toObservable + merge pattern for proper reactivity - Removed old project settings components - Updated navigation paths and UI improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude LFXV2-756 Signed-off-by: Asitha de Silva --- apps/lfx-one/src/app/app.routes.ts | 4 + .../main-layout/main-layout.component.html | 8 +- .../main-layout/main-layout.component.ts | 4 +- .../project-layout.component.html | 87 ------------------- .../project-layout.component.ts | 3 +- .../committee-dashboard.component.html | 2 +- .../committee-form.component.html | 4 +- .../committee-table.component.html | 2 +- .../meetings-dashboard.component.ts | 52 +++++++---- .../committee-view.component.html | 10 +-- .../committee-view.component.ts | 5 +- .../meeting-manage.component.ts | 7 +- .../src/app/modules/project/project.routes.ts | 13 --- .../permissions-matrix.component.html | 0 .../permissions-matrix.component.ts | 0 .../user-form/user-form.component.html | 0 .../user-form/user-form.component.ts | 0 .../user-permissions-table.component.html | 0 .../user-permissions-table.component.ts | 10 +-- .../settings-dashboard.component.html | 0 .../settings-dashboard.component.ts | 64 +++++++++----- .../app/modules/settings/settings.routes.ts | 15 ++++ .../src/server/services/committee.service.ts | 37 +++++++- apps/lfx-one/src/styles.scss | 5 +- apps/lfx-one/tailwind.config.js | 1 + .../src/constants/committees.constants.ts | 2 +- 26 files changed, 167 insertions(+), 168 deletions(-) rename apps/lfx-one/src/app/modules/{project => }/settings/components/permissions-matrix/permissions-matrix.component.html (100%) rename apps/lfx-one/src/app/modules/{project => }/settings/components/permissions-matrix/permissions-matrix.component.ts (100%) rename apps/lfx-one/src/app/modules/{project => }/settings/components/user-form/user-form.component.html (100%) rename apps/lfx-one/src/app/modules/{project => }/settings/components/user-form/user-form.component.ts (100%) rename apps/lfx-one/src/app/modules/{project => }/settings/components/user-permissions-table/user-permissions-table.component.html (100%) rename apps/lfx-one/src/app/modules/{project => }/settings/components/user-permissions-table/user-permissions-table.component.ts (92%) rename apps/lfx-one/src/app/modules/{project => }/settings/settings-dashboard/settings-dashboard.component.html (100%) rename apps/lfx-one/src/app/modules/{project => }/settings/settings-dashboard/settings-dashboard.component.ts (52%) create mode 100644 apps/lfx-one/src/app/modules/settings/settings.routes.ts diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts index f5842df4..c31a4f69 100644 --- a/apps/lfx-one/src/app/app.routes.ts +++ b/apps/lfx-one/src/app/app.routes.ts @@ -27,6 +27,10 @@ export const routes: Routes = [ path: 'groups', loadChildren: () => import('./modules/committees/committees.routes').then((m) => m.COMMITTEE_ROUTES), }, + { + path: 'settings', + loadChildren: () => import('./modules/settings/settings.routes').then((m) => m.SETTINGS_ROUTES), + }, ], }, { diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html index 69e4caba..c95c73db 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html @@ -30,9 +30,11 @@

Menu

} -
- +
+
+ +
- +
diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts index 9ef075a5..cd302fba 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts @@ -11,6 +11,7 @@ import { SidebarComponent } from '@components/sidebar/sidebar.component'; import { COMMITTEE_LABEL } from '@lfx-one/shared/constants'; import { SidebarMenuItem } from '@lfx-one/shared/interfaces'; import { PersonaService } from '@services/persona.service'; +import { ProjectContextService } from '@services/project-context.service'; import { filter } from 'rxjs'; @Component({ @@ -26,8 +27,10 @@ export class MainLayoutComponent { private readonly appService = inject(AppService); private readonly featureFlagService = inject(FeatureFlagService); private readonly personaService = inject(PersonaService); + private readonly projectContextService = inject(ProjectContextService); // Expose mobile sidebar state from service + protected readonly selectedProject = this.projectContextService.selectedProject; protected readonly showMobileSidebar = this.appService.showMobileSidebar; // Feature flags @@ -79,7 +82,6 @@ export class MainLayoutComponent { label: 'Settings', icon: 'fa-light fa-gear', routerLink: '/settings', - disabled: true, }, { label: 'Profile', diff --git a/apps/lfx-one/src/app/layouts/project-layout/project-layout.component.html b/apps/lfx-one/src/app/layouts/project-layout/project-layout.component.html index a53dbcf9..75ebb8f0 100644 --- a/apps/lfx-one/src/app/layouts/project-layout/project-layout.component.html +++ b/apps/lfx-one/src/app/layouts/project-layout/project-layout.component.html @@ -4,93 +4,6 @@ -
-
- - - -
-
-
- -

- {{ projectTitle() }} -

- - - @if (projectDescription(); as description) { -

- {{ description }} -

- } -
- - - @if (categoryLabel(); as category) { -
- -
- } -
-
- - -
-
- @for (menu of menuItems(); track menu.label) { - - @if (menu.icon) { - - } - {{ menu.label }} - - } - @if (hasWriterAccess()) { - - } -
- @if (hasWriterAccess()) { - - } -
-
-
@if (project()) { } diff --git a/apps/lfx-one/src/app/layouts/project-layout/project-layout.component.ts b/apps/lfx-one/src/app/layouts/project-layout/project-layout.component.ts index f944b67e..b2a172dd 100644 --- a/apps/lfx-one/src/app/layouts/project-layout/project-layout.component.ts +++ b/apps/lfx-one/src/app/layouts/project-layout/project-layout.component.ts @@ -5,7 +5,6 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, input, Signal, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterModule } from '@angular/router'; -import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; import { FilterButton, Project } from '@lfx-one/shared/interfaces'; import { ProjectService } from '@services/project.service'; import { HeaderComponent } from '@shared/components/header/header.component'; @@ -16,7 +15,7 @@ import { of, switchMap } from 'rxjs'; @Component({ selector: 'lfx-project-layout', standalone: true, - imports: [CommonModule, RouterModule, HeaderComponent, BreadcrumbComponent, ChipModule], + imports: [CommonModule, RouterModule, HeaderComponent, ChipModule], templateUrl: './project-layout.component.html', styleUrl: './project-layout.component.scss', }) diff --git a/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html b/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html index 17a4e634..938f2b22 100644 --- a/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html @@ -15,7 +15,7 @@
-

{{ committeeLabel }}

+

{{ committeeLabelPlural }}

@if (canCreateGroup()) {
-

Basic Information

+

Basic Information

@@ -88,7 +88,7 @@

-

Settings

+

Settings

diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.html b/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.html index 6abce6ba..f31abdaf 100644 --- a/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.html +++ b/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.html @@ -25,7 +25,7 @@ {{ committee.category || 'Other' }}
-

+

{{ committee.name }}

@if (!committee.public) { diff --git a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts index 9bd68150..a2bc1bb7 100644 --- a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { MeetingCardComponent } from '@app/shared/components/meeting-card/meeting-card.component'; import { ProjectContextService } from '@app/shared/services/project-context.service'; import { ButtonComponent } from '@components/button/button.component'; @@ -11,7 +11,7 @@ import { Meeting, ProjectContext } from '@lfx-one/shared/interfaces'; import { getCurrentOrNextOccurrence } from '@lfx-one/shared/utils'; import { MeetingService } from '@services/meeting.service'; import { PersonaService } from '@services/persona.service'; -import { BehaviorSubject, map, switchMap, tap } from 'rxjs'; +import { BehaviorSubject, catchError, map, merge, of, switchMap, tap } from 'rxjs'; import { MeetingsTopBarComponent } from './components/meetings-top-bar/meetings-top-bar.component'; @@ -40,18 +40,24 @@ export class MeetingsDashboardComponent { public isNonFoundationProjectSelected: Signal; public constructor() { + // Initialize project context first (needed for reactive data loading) + this.project = computed(() => this.projectContextService.selectedProject() || this.projectContextService.selectedFoundation()); + + // Initialize permission checks + this.isMaintainer = computed(() => this.personaService.currentPersona() === 'maintainer'); + this.isNonFoundationProjectSelected = computed(() => this.projectContextService.selectedProject() !== null); + + // Initialize state this.meetingsLoading = signal(true); this.refresh$ = new BehaviorSubject(undefined); - this.meetings = this.initializeMeetings(); this.currentView = signal<'list' | 'calendar'>('list'); this.searchQuery = signal(''); this.timeFilter = signal<'upcoming' | 'past'>('upcoming'); this.topBarVisibilityFilter = signal<'mine' | 'public'>('mine'); + + // Initialize data with reactive pattern + this.meetings = this.initializeMeetings(); this.filteredMeetings = this.initializeFilteredMeetings(); - this.project = computed(() => this.projectContextService.selectedProject() || this.projectContextService.selectedFoundation()); - this.isMaintainer = computed(() => this.personaService.currentPersona() === 'maintainer'); - // A non-foundation project is selected if selectedProject is not null - this.isNonFoundationProjectSelected = computed(() => this.projectContextService.selectedProject() !== null); } public onViewChange(view: 'list' | 'calendar'): void { @@ -64,10 +70,23 @@ export class MeetingsDashboardComponent { } private initializeMeetings(): Signal { + // Convert project signal to observable to react to project changes + const project$ = toObservable(this.project); + return toSignal( - this.refresh$.pipe( - switchMap(() => - this.meetingService.getMeetings().pipe( + merge( + project$, // Triggers on project context changes + this.refresh$ // Triggers on manual refresh + ).pipe( + tap(() => this.meetingsLoading.set(true)), + switchMap(() => { + const project = this.project(); + if (!project?.projectId) { + this.meetingsLoading.set(false); + return of([]); + } + + return this.meetingService.getMeetings().pipe( map((meetings) => { // Sort meetings by current or next occurrence start time (earliest first) return meetings.sort((a, b) => { @@ -87,13 +106,16 @@ export class MeetingsDashboardComponent { return new Date(occurrenceA.start_time).getTime() - new Date(occurrenceB.start_time).getTime(); }); }), + catchError((error) => { + console.error('Failed to load meetings:', error); + this.meetingsLoading.set(false); + return of([]); + }), tap(() => this.meetingsLoading.set(false)) - ) - ) + ); + }) ), - { - initialValue: [], - } + { initialValue: [] } ); } diff --git a/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.html b/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.html index b298d08a..cd149e7b 100644 --- a/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.html +++ b/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.html @@ -7,7 +7,7 @@
-

Loading committee details...

+

Loading group details...

} @@ -17,9 +17,9 @@
-

Committee Not Found

-

The committee you're looking for doesn't exist or has been removed.

- +

Group Not Found

+

The group you're looking for doesn't exist or has been removed.

+
} @@ -33,7 +33,7 @@

Committee Not Found

{{ committee()?.name }}

diff --git a/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.ts b/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.ts index a3b57e2f..46a20125 100644 --- a/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.ts +++ b/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.ts @@ -75,10 +75,7 @@ export class CommitteeViewComponent { } public goBack(): void { - const project = this.project(); - if (project) { - this.router.navigate(['/project', project.slug, 'committees']); - } + this.router.navigate(['/', 'groups']); } public toggleActionMenu(event: Event, menuComponent: MenuComponent): void { diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts index 92630927..055c60e4 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts @@ -180,10 +180,7 @@ export class MeetingManageComponent { } public onCancel(): void { - const project = this.projectService.project(); - if (project?.slug) { - this.router.navigate(['/project', project.slug, 'meetings']); - } + this.router.navigate(['/', 'meetings']); } public onSubmit(): void { @@ -268,7 +265,7 @@ export class MeetingManageComponent { this.showRegistrantOperationToast(totalSuccess, totalFailed, totalOperations); if (!this.isEditMode()) { - this.router.navigate(['/project', this.projectService.project()?.slug, 'meetings']); + this.router.navigate(['/', 'meetings']); } else { this.registrantUpdatesRefresh$.next(); // Reset registrant updates only if there were some successes diff --git a/apps/lfx-one/src/app/modules/project/project.routes.ts b/apps/lfx-one/src/app/modules/project/project.routes.ts index 04c7eaf8..641734ff 100644 --- a/apps/lfx-one/src/app/modules/project/project.routes.ts +++ b/apps/lfx-one/src/app/modules/project/project.routes.ts @@ -3,8 +3,6 @@ import { Routes } from '@angular/router'; -import { writerGuard } from '../../shared/guards/writer.guard'; - export const PROJECT_ROUTES: Routes = [ { path: '', @@ -20,15 +18,4 @@ export const PROJECT_ROUTES: Routes = [ loadChildren: () => import('./committees/committees.routes').then((m) => m.COMMITTEES_ROUTES), data: { preload: true, preloadDelay: 1500 }, // Medium usage, moderate delay }, - { - path: 'mailing-lists', - loadChildren: () => import('./mailing-lists/mailing-lists.routes').then((m) => m.MAILING_LISTS_ROUTES), - data: { preload: true, preloadDelay: 3000 }, // Lower priority, longer delay - }, - { - path: 'settings', - loadComponent: () => import('./settings/settings-dashboard/settings-dashboard.component').then((m) => m.SettingsDashboardComponent), - canActivate: [writerGuard], - data: { preload: false }, // Settings accessed less frequently, don't preload - }, ]; diff --git a/apps/lfx-one/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.html b/apps/lfx-one/src/app/modules/settings/components/permissions-matrix/permissions-matrix.component.html similarity index 100% rename from apps/lfx-one/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.html rename to apps/lfx-one/src/app/modules/settings/components/permissions-matrix/permissions-matrix.component.html diff --git a/apps/lfx-one/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts b/apps/lfx-one/src/app/modules/settings/components/permissions-matrix/permissions-matrix.component.ts similarity index 100% rename from apps/lfx-one/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts rename to apps/lfx-one/src/app/modules/settings/components/permissions-matrix/permissions-matrix.component.ts diff --git a/apps/lfx-one/src/app/modules/project/settings/components/user-form/user-form.component.html b/apps/lfx-one/src/app/modules/settings/components/user-form/user-form.component.html similarity index 100% rename from apps/lfx-one/src/app/modules/project/settings/components/user-form/user-form.component.html rename to apps/lfx-one/src/app/modules/settings/components/user-form/user-form.component.html diff --git a/apps/lfx-one/src/app/modules/project/settings/components/user-form/user-form.component.ts b/apps/lfx-one/src/app/modules/settings/components/user-form/user-form.component.ts similarity index 100% rename from apps/lfx-one/src/app/modules/project/settings/components/user-form/user-form.component.ts rename to apps/lfx-one/src/app/modules/settings/components/user-form/user-form.component.ts diff --git a/apps/lfx-one/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.html b/apps/lfx-one/src/app/modules/settings/components/user-permissions-table/user-permissions-table.component.html similarity index 100% rename from apps/lfx-one/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.html rename to apps/lfx-one/src/app/modules/settings/components/user-permissions-table/user-permissions-table.component.html diff --git a/apps/lfx-one/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.ts b/apps/lfx-one/src/app/modules/settings/components/user-permissions-table/user-permissions-table.component.ts similarity index 92% rename from apps/lfx-one/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.ts rename to apps/lfx-one/src/app/modules/settings/components/user-permissions-table/user-permissions-table.component.ts index f2154d0d..dcddc7c2 100644 --- a/apps/lfx-one/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.ts +++ b/apps/lfx-one/src/app/modules/settings/components/user-permissions-table/user-permissions-table.component.ts @@ -2,14 +2,14 @@ // SPDX-License-Identifier: MIT import { CommonModule } from '@angular/common'; -import { Component, inject, input, output, signal, WritableSignal } from '@angular/core'; +import { Component, computed, inject, input, output, signal, WritableSignal } from '@angular/core'; +import { ProjectContextService } from '@app/shared/services/project-context.service'; import { ButtonComponent } from '@components/button/button.component'; import { CardComponent } from '@components/card/card.component'; import { MenuComponent } from '@components/menu/menu.component'; import { TableComponent } from '@components/table/table.component'; import { ProjectPermissionUser } from '@lfx-one/shared/interfaces'; import { PermissionsService } from '@services/permissions.service'; -import { ProjectService } from '@services/project.service'; import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService } from 'primeng/dynamicdialog'; @@ -26,15 +26,15 @@ import { UserFormComponent } from '../user-form/user-form.component'; }) export class UserPermissionsTableComponent { private readonly permissionsService = inject(PermissionsService); - private readonly projectService = inject(ProjectService); private readonly confirmationService = inject(ConfirmationService); private readonly messageService = inject(MessageService); private readonly dialogService = inject(DialogService); + private readonly projectContextService = inject(ProjectContextService); // State signals public users = input.required(); public loading = input(); - public project = this.projectService.project; + public project = computed(() => this.projectContextService.selectedProject() || this.projectContextService.selectedFoundation()); public isRemoving: WritableSignal = signal(null); public selectedUser: WritableSignal = signal(null); public userActionMenuItems: MenuItem[] = this.initializeUserActionMenuItems(); @@ -108,7 +108,7 @@ export class UserPermissionsTableComponent { this.isRemoving.set(user.username); this.permissionsService - .removeUserFromProject(this.project()!.uid, user.username) + .removeUserFromProject(this.project()!.projectId, user.username) .pipe(take(1)) .subscribe({ next: () => { diff --git a/apps/lfx-one/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.html b/apps/lfx-one/src/app/modules/settings/settings-dashboard/settings-dashboard.component.html similarity index 100% rename from apps/lfx-one/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.html rename to apps/lfx-one/src/app/modules/settings/settings-dashboard/settings-dashboard.component.html diff --git a/apps/lfx-one/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts b/apps/lfx-one/src/app/modules/settings/settings-dashboard/settings-dashboard.component.ts similarity index 52% rename from apps/lfx-one/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts rename to apps/lfx-one/src/app/modules/settings/settings-dashboard/settings-dashboard.component.ts index 8fc8a76c..63eb1da6 100644 --- a/apps/lfx-one/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/settings/settings-dashboard/settings-dashboard.component.ts @@ -1,16 +1,16 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, inject, signal, Signal, WritableSignal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { ProjectContextService } from '@app/shared/services/project-context.service'; import { CardComponent } from '@components/card/card.component'; import { MenuComponent } from '@components/menu/menu.component'; import { ProjectPermissionUser } from '@lfx-one/shared'; import { PermissionsService } from '@services/permissions.service'; -import { ProjectService } from '@services/project.service'; import { MenuItem } from 'primeng/api'; import { DialogService } from 'primeng/dynamicdialog'; -import { BehaviorSubject, of, switchMap, take, tap } from 'rxjs'; +import { BehaviorSubject, catchError, merge, of, switchMap, take, tap } from 'rxjs'; import { PermissionsMatrixComponent } from '../components/permissions-matrix/permissions-matrix.component'; import { UserFormComponent } from '../components/user-form/user-form.component'; @@ -22,14 +22,15 @@ import { UserPermissionsTableComponent } from '../components/user-permissions-ta templateUrl: './settings-dashboard.component.html', }) export class SettingsDashboardComponent { - private readonly projectService = inject(ProjectService); - private readonly dialogService = inject(DialogService); + private readonly projectContextService = inject(ProjectContextService); private readonly permissionsService = inject(PermissionsService); + private readonly dialogService = inject(DialogService); public users: Signal; public loading: WritableSignal = signal(true); - public refresh: BehaviorSubject = new BehaviorSubject(undefined); - public project = this.projectService.project; + public refresh$: BehaviorSubject = new BehaviorSubject(undefined); + public project = computed(() => this.projectContextService.selectedProject() || this.projectContextService.selectedFoundation()); + protected readonly menuItems: MenuItem[] = [ { label: 'Add User', @@ -39,22 +40,43 @@ export class SettingsDashboardComponent { ]; public constructor() { - // Initialize userPermissions signal from service - this.users = toSignal( - this.project()?.uid - ? this.refresh.pipe( - tap(() => this.loading.set(true)), - switchMap(() => this.permissionsService.getProjectPermissions(this.project()?.uid as string).pipe(tap(() => this.loading.set(false)))) - ) - : of([]), - { - initialValue: [], - } - ); + // Initialize users signal with reactive project context + this.users = this.initializeUsers(); } public refreshUsers(): void { - this.refresh.next(); + this.refresh$.next(); + } + + private initializeUsers(): Signal { + // Convert project signal to observable to react to project changes + const project$ = toObservable(this.project); + + return toSignal( + merge( + project$, // Triggers on project context changes + this.refresh$ // Triggers on manual refresh + ).pipe( + tap(() => this.loading.set(true)), + switchMap(() => { + const project = this.project(); + if (!project?.projectId) { + this.loading.set(false); + return of([]); + } + + return this.permissionsService.getProjectPermissions(project.projectId).pipe( + catchError((error) => { + console.error('Failed to load permissions:', error); + this.loading.set(false); + return of([]); + }), + tap(() => this.loading.set(false)) + ); + }) + ), + { initialValue: [] } + ); } private onAddUser(): void { diff --git a/apps/lfx-one/src/app/modules/settings/settings.routes.ts b/apps/lfx-one/src/app/modules/settings/settings.routes.ts new file mode 100644 index 00000000..b4a7a986 --- /dev/null +++ b/apps/lfx-one/src/app/modules/settings/settings.routes.ts @@ -0,0 +1,15 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Routes } from '@angular/router'; + +import { authGuard } from '../../shared/guards/auth.guard'; + +export const SETTINGS_ROUTES: Routes = [ + { + path: '', + loadComponent: () => import('./settings-dashboard/settings-dashboard.component').then((m) => m.SettingsDashboardComponent), + canActivate: [authGuard], + data: { preload: false }, // Settings accessed less frequently, don't preload + }, +]; diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index b8289d7d..179028c6 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -44,7 +44,18 @@ export class CommitteeService { const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); - const committees = resources.map((resource) => resource.data); + let committees = resources.map((resource) => resource.data); + + // Get member count for each committee + committees = await Promise.all( + committees.map(async (committee) => { + const memberCount = await this.getCommitteeMembersCount(req, committee.uid); + return { + ...committee, + total_members: memberCount, + }; + }) + ); // Add writer access field to all committees return await this.accessCheckService.addAccessToResources(req, committees, 'committee'); @@ -222,6 +233,30 @@ export class CommitteeService { return resources.map((resource) => resource.data); } + /** + * Fetches count of all members for a specific committee + */ + public async getCommitteeMembersCount(req: Request, committeeId: string, query: Record = {}): Promise { + req.log.info( + { + operation: 'get_committee_members_count', + committee_id: committeeId, + query: query, + }, + 'Fetching committee members count' + ); + + const params = { + ...query, + type: 'committee_member', + tags: `committee_uid:${committeeId}`, + }; + + const { count } = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/query/resources/count`, 'GET', params); + + return count; + } + /** * Fetches a single committee member by ID */ diff --git a/apps/lfx-one/src/styles.scss b/apps/lfx-one/src/styles.scss index d00cbc0d..63297a8d 100644 --- a/apps/lfx-one/src/styles.scss +++ b/apps/lfx-one/src/styles.scss @@ -2,7 +2,7 @@ /* SPDX-License-Identifier: MIT */ /* Google Fonts - Must be at the very top */ -@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Roboto+Slab:wght@100..900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Roboto+Slab:wght@100..900&display=swap'); @layer tailwind-base, primeng, tailwind-utilities; @@ -19,6 +19,9 @@ /* Open Sans as the primary sans-serif font */ --font-sans: 'Open Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + /* Inter as additional sans-serif font */ + --font-inter: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + /* Roboto Slab for headings and display text */ --font-display: 'Roboto Slab', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; diff --git a/apps/lfx-one/tailwind.config.js b/apps/lfx-one/tailwind.config.js index 2eb13dec..9f85bd82 100644 --- a/apps/lfx-one/tailwind.config.js +++ b/apps/lfx-one/tailwind.config.js @@ -66,6 +66,7 @@ export default { fontSize: lfxFontSizes, fontFamily: { sans: ['Open Sans', 'sans-serif'], + inter: ['Inter', 'sans-serif'], display: ['Roboto Slab', 'serif'], serif: ['Roboto Slab', 'serif'], }, diff --git a/packages/shared/src/constants/committees.constants.ts b/packages/shared/src/constants/committees.constants.ts index 79ddaad9..6558d8f3 100644 --- a/packages/shared/src/constants/committees.constants.ts +++ b/packages/shared/src/constants/committees.constants.ts @@ -160,7 +160,7 @@ export function getCommitteeTypeColor(category: string | undefined): string { if (lowerCategory.includes('technical oversight')) return 'bg-teal-100 text-teal-800'; if (lowerCategory.includes('marketing oversight')) return 'bg-pink-100 text-pink-800'; if (lowerCategory.includes('marketing committee')) return 'bg-pink-100 text-pink-800'; - if (lowerCategory.includes('finance')) return 'bg-emerald-100 text-emerald-800'; + if (lowerCategory.includes('finance')) return 'bg-amber-100 text-amber-800'; // Fallback to exact match or default return COMMITTEE_TYPE_COLORS[category as keyof typeof COMMITTEE_TYPE_COLORS] || DEFAULT_COMMITTEE_TYPE_COLOR; From a874f4a75a27f65016b9685496f2c8ece58a6169 Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Tue, 18 Nov 2025 08:48:43 -0800 Subject: [PATCH 2/3] fix(ui): prevent race condition in committees dashboard loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed issue where "no groups found" message would briefly appear alongside the groups table during refresh due to race condition between committeesLoading signal and committees data updates. Changed template from: @if (committeesLoading()) { ... } @if (committees() && !committeesLoading()) { ... } To: @if (committeesLoading()) { ... } @else { ... } This ensures only one state is displayed at a time, preventing the visual glitch where both loading and content states could appear simultaneously. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude LFXV2-756 Signed-off-by: Asitha de Silva --- .../src/app/layouts/main-layout/main-layout.component.ts | 2 +- .../committee-dashboard/committee-dashboard.component.html | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts index cd302fba..8504c26a 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts @@ -34,7 +34,7 @@ export class MainLayoutComponent { protected readonly showMobileSidebar = this.appService.showMobileSidebar; // Feature flags - private readonly showProjectsInSidebar = this.featureFlagService.getBooleanFlag('sidebar-projects', true); + private readonly showProjectsInSidebar = this.featureFlagService.getBooleanFlag('sidebar-projects', false); // Base sidebar navigation items - matching React NavigationSidebar design private readonly baseSidebarItems: SidebarMenuItem[] = [ diff --git a/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html b/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html index 938f2b22..07bbae92 100644 --- a/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html @@ -9,9 +9,7 @@

Loading {{ committeeLabel.toLowerCase() }} details...

- } - - @if (committees() && !committeesLoading()) { + } @else {
From f39893272d631e2f29c5b9c94bd76d55b9cbdf75 Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Tue, 18 Nov 2025 08:52:18 -0800 Subject: [PATCH 3/3] fix(ui): complete navigation path updates to global routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completed the navigation refactoring by updating remaining project-specific paths to use global routes in error handlers and success callbacks: - Committee view error handler now navigates to /groups - Meeting manage success handlers now navigate to /meetings - Removed unused project parameter from handleMeetingSuccess - Simplified handleAttachmentOperationsResults signature This ensures consistent navigation behavior across the application after moving settings, meetings, and committees to global routes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude LFXV2-756 Signed-off-by: Asitha de Silva --- .../committee-view.component.ts | 2 +- .../meeting-manage.component.ts | 25 ++++++++----------- .../user-form/user-form.component.ts | 16 ++++++------ 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.ts b/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.ts index 46a20125..74f3aae9 100644 --- a/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.ts +++ b/apps/lfx-one/src/app/modules/project/committees/committee-view/committee-view.component.ts @@ -152,7 +152,7 @@ export class CommitteeViewComponent { summary: 'Error', detail: 'Failed to load committee', }); - this.router.navigate(['/project', this.project()!.slug, 'committees']); + this.router.navigate(['/', 'groups']); return throwError(() => new Error('Failed to load committee')); }) ); diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts index 055c60e4..22b4655e 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts @@ -212,7 +212,7 @@ export class MeetingManageComponent { : this.meetingService.createMeeting(meetingData as CreateMeetingRequest); operation.subscribe({ - next: (meeting) => this.handleMeetingSuccess(meeting, project), + next: (meeting) => this.handleMeetingSuccess(meeting), error: (error) => this.handleMeetingError(error), }); } @@ -344,7 +344,7 @@ export class MeetingManageComponent { }; } - private handleMeetingSuccess(meeting: Meeting, project: any): void { + private handleMeetingSuccess(meeting: Meeting): void { this.meetingId.set(meeting.uid); // If we're in create mode and not on the last step, continue to next step @@ -384,7 +384,7 @@ export class MeetingManageComponent { .subscribe({ next: (result) => { // Process attachment operations after meeting save - this.handleAttachmentOperationsResults(result, project); + this.handleAttachmentOperationsResults(result); }, error: (attachmentError: any) => { console.error('Error processing attachments:', attachmentError); @@ -396,7 +396,7 @@ export class MeetingManageComponent { summary: this.isEditMode() ? 'Meeting Updated' : 'Meeting Created', detail: warningMessage, }); - this.router.navigate(['/project', project.slug, 'meetings']); + this.router.navigate(['/meetings']); }, }); } else { @@ -405,7 +405,7 @@ export class MeetingManageComponent { summary: 'Success', detail: `Meeting ${this.isEditMode() ? 'updated' : 'created'} successfully`, }); - this.router.navigate(['/project', project.slug, 'meetings']); + this.router.navigate(['/meetings']); } } @@ -419,14 +419,11 @@ export class MeetingManageComponent { this.submitting.set(false); } - private handleAttachmentOperationsResults( - result: { - deletions: { successes: number; failures: string[] }; - uploads: { successes: MeetingAttachment[]; failures: { fileName: string; error: any }[] }; - links: { successes: MeetingAttachment[]; failures: { linkName: string; error: any }[] }; - }, - project: any - ): void { + private handleAttachmentOperationsResults(result: { + deletions: { successes: number; failures: string[] }; + uploads: { successes: MeetingAttachment[]; failures: { fileName: string; error: any }[] }; + links: { successes: MeetingAttachment[]; failures: { linkName: string; error: any }[] }; + }): void { const totalDeleteSuccesses = result.deletions.successes; const totalDeleteFailures = result.deletions.failures.length; const totalUploadSuccesses = result.uploads.successes.length; @@ -509,7 +506,7 @@ export class MeetingManageComponent { this.pendingAttachmentDeletions.set([]); } - this.router.navigate(['/project', project.slug, 'meetings']); + this.router.navigate(['/meetings']); } private initializeMeeting() { diff --git a/apps/lfx-one/src/app/modules/settings/components/user-form/user-form.component.ts b/apps/lfx-one/src/app/modules/settings/components/user-form/user-form.component.ts index a9813819..a74a854b 100644 --- a/apps/lfx-one/src/app/modules/settings/components/user-form/user-form.component.ts +++ b/apps/lfx-one/src/app/modules/settings/components/user-form/user-form.component.ts @@ -4,12 +4,12 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Component, computed, inject, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ProjectContextService } from '@app/shared/services/project-context.service'; import { ButtonComponent } from '@components/button/button.component'; import { InputTextComponent } from '@components/input-text/input-text.component'; import { RadioButtonComponent } from '@components/radio-button/radio-button.component'; import { AddUserToProjectRequest, ProjectPermissionUser, UpdateUserRoleRequest } from '@lfx-one/shared'; import { PermissionsService } from '@services/permissions.service'; -import { ProjectService } from '@services/project.service'; import { ConfirmationService, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; @@ -25,7 +25,7 @@ import { take } from 'rxjs'; export class UserFormComponent { private readonly config = inject(DynamicDialogConfig); private readonly dialogRef = inject(DynamicDialogRef); - private readonly projectService = inject(ProjectService); + private readonly projectContextService = inject(ProjectContextService); private readonly messageService = inject(MessageService); private readonly permissionsService = inject(PermissionsService); private readonly confirmationService = inject(ConfirmationService); @@ -41,7 +41,7 @@ export class UserFormComponent { public isEditing = computed(() => this.config.data?.isEditing || false); public user = computed(() => (this.config.data?.user as ProjectPermissionUser) || null); - public project = this.projectService.project; + public project = computed(() => this.projectContextService.selectedProject() || this.projectContextService.selectedFoundation()); // Permission options - simplified to only View/Manage public permissionLevelOptions = [ @@ -67,7 +67,7 @@ export class UserFormComponent { return; } - const project = this.projectService.project(); + const project = this.project(); if (!project) { this.messageService.add({ severity: 'error', @@ -83,7 +83,7 @@ export class UserFormComponent { // For editing, update role only if (this.isEditing()) { this.permissionsService - .updateUserRole(project.uid, this.user()!.username, { + .updateUserRole(project.projectId, this.user()!.username, { role: formValue.role, } as UpdateUserRoleRequest) .pipe(take(1)) @@ -113,7 +113,7 @@ export class UserFormComponent { if (!this.showManualFields()) { // Try to add user with just email (backend will resolve to username) this.permissionsService - .addUserToProject(project.uid, { + .addUserToProject(project.projectId, { username: formValue.email, // Pass email as username, backend will resolve role: formValue.role, } as AddUserToProjectRequest) @@ -196,7 +196,7 @@ export class UserFormComponent { } private addUserWithManualData(formValue: any): void { - const project = this.projectService.project(); + const project = this.project(); if (!project) { this.messageService.add({ severity: 'error', @@ -223,7 +223,7 @@ export class UserFormComponent { // Since the user doesn't exist in the system, we need to send the full user data // The backend should handle this case by accepting UserInfo objects this.permissionsService - .addUserToProject(project.uid, userData as any) + .addUserToProject(project.projectId, userData as any) .pipe(take(1)) .subscribe({ next: () => {