diff --git a/apps/lfx-one/src/app/modules/committees/committee-manage/committee-manage.component.ts b/apps/lfx-one/src/app/modules/committees/committee-manage/committee-manage.component.ts index 5a05a9750..b86b9041e 100644 --- a/apps/lfx-one/src/app/modules/committees/committee-manage/committee-manage.component.ts +++ b/apps/lfx-one/src/app/modules/committees/committee-manage/committee-manage.component.ts @@ -208,13 +208,13 @@ export class CommitteeManageComponent { // Update existing committee this.committeeService.updateCommittee(this.committeeId()!, committeeData).subscribe({ next: () => this.handleCommitteeSuccess('updated'), - error: (error) => this.handleCommitteeError(error, 'update'), + error: (err: unknown) => this.handleCommitteeError('update', err), }); } else { // Create new committee this.committeeService.createCommittee(committeeData).subscribe({ next: (committee) => this.handleCreateSuccess(committee), - error: (error) => this.handleCommitteeError(error, 'create'), + error: (err: unknown) => this.handleCommitteeError('create', err), }); } } @@ -246,8 +246,7 @@ export class CommitteeManageComponent { this.showMemberOperationToast(totalSuccess, totalFailed, totalSuccess + totalFailed); this.router.navigate(['/groups']); }, - error: (error) => { - console.error('Error processing member changes:', error); + error: () => { this.messageService.add({ severity: 'error', summary: 'Error', @@ -333,8 +332,7 @@ export class CommitteeManageComponent { // Navigate back to committees list this.router.navigate(['/groups']); }, - error: (error: unknown) => { - console.error('Error saving committee and members:', error); + error: () => { this.messageService.add({ severity: 'error', summary: 'Error', @@ -455,10 +453,11 @@ export class CommitteeManageComponent { } } - private handleCommitteeError(error: unknown, operation: 'create' | 'update'): void { - console.error(`Error ${operation} committee:`, error); + private handleCommitteeError(operation: 'create' | 'update', error?: unknown): void { this.submitting.set(false); + console.error(`Failed to ${operation} committee:`, error); + this.messageService.add({ severity: 'error', summary: 'Error', @@ -570,10 +569,7 @@ export class CommitteeManageComponent { private createMemberOperation(type: string, operation: () => Observable) { return operation().pipe( switchMap(() => of({ type, success: 1, failed: 0 })), - catchError((error) => { - console.error(`Error ${type} member:`, error); - return of({ type, success: 0, failed: 1 }); - }) + catchError(() => of({ type, success: 0, failed: 1 })) ); } diff --git a/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.html b/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.html index c19741ac3..a2a58bc18 100644 --- a/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.html +++ b/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.html @@ -38,9 +38,6 @@

Something Went Wrong

{{ committee()?.name }}

- @if (!committee()?.public) { - - }
@if (committee()?.description) {

{{ committee()?.description }}

@@ -54,7 +51,7 @@

{{ committee()?.name }}

} Created {{ committee()?.created_at | date: 'MMM d, y' }} @if (committee()?.updated_at) { - · Updated {{ committee()?.updated_at | date: 'MMM d, y' }} + · Updated {{ committee()?.updated_at | date: 'MMM d, y' }} } @@ -67,7 +64,7 @@

{{ committee()?.name }}

} - +
} - - - diff --git a/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.ts b/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.ts index 84e3cafb5..9e4f3ae30 100644 --- a/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.ts +++ b/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.ts @@ -8,11 +8,10 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; import { ButtonComponent } from '@components/button/button.component'; import { TagComponent } from '@components/tag/tag.component'; +import { RouteLoadingComponent } from '@components/loading/route-loading.component'; import { Committee, CommitteeMemberVisibility, getCommitteeCategorySeverity, TagSeverity } from '@lfx-one/shared'; import { CommitteeService } from '@services/committee.service'; -import { RouteLoadingComponent } from '@components/loading/route-loading.component'; import { MenuItem, MessageService } from 'primeng/api'; -import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { catchError, combineLatest, finalize, of, switchMap } from 'rxjs'; import { CommitteeOverviewComponent } from '../components/committee-overview/committee-overview.component'; @@ -25,7 +24,6 @@ type CommitteeTab = 'overview' | 'members' | 'votes' | 'meetings' | 'surveys' | BreadcrumbComponent, ButtonComponent, TagComponent, - ConfirmDialogModule, RouterLink, RouteLoadingComponent, DatePipe, @@ -77,6 +75,17 @@ export class CommitteeViewComponent { this.refresh.update((v) => v + 1); } + public createSurvey(): void { + const committee = this.committee(); + if (!committee) return; + this.router.navigate(['/surveys/create'], { + queryParams: { + committee_uid: committee.uid, + committee_name: committee.name, + }, + }); + } + // -- Private initializer functions -- private initializeCommittee(): Signal { return toSignal( diff --git a/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.html b/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.html new file mode 100644 index 000000000..2d4fe9748 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.html @@ -0,0 +1,79 @@ + + + +
+ +
+ + + +
+ + +
+ + + +
+ + +
+
+ @if (currentLeader) { + + + } +
+
+ + + + +
+
+
diff --git a/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.ts b/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.ts new file mode 100644 index 000000000..154da5170 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.ts @@ -0,0 +1,175 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, inject, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { ButtonComponent } from '@components/button/button.component'; +import { CalendarComponent } from '@components/calendar/calendar.component'; +import { SelectComponent } from '@components/select/select.component'; +import { CommitteeMemberRole } from '@lfx-one/shared/enums'; +import { Committee, CommitteeLeadership, CommitteeMember, CreateCommitteeMemberRequest, LeadershipRole } from '@lfx-one/shared/interfaces'; +import { formatDateToISOString } from '@lfx-one/shared/utils'; +import { CommitteeService } from '@services/committee.service'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { catchError, of, switchMap } from 'rxjs'; + +@Component({ + selector: 'lfx-assign-leadership-dialog', + imports: [ReactiveFormsModule, ButtonComponent, CalendarComponent, SelectComponent], + templateUrl: './assign-leadership-dialog.component.html', +}) +export class AssignLeadershipDialogComponent { + private readonly config = inject(DynamicDialogConfig); + private readonly dialogRef = inject(DynamicDialogRef); + private readonly committeeService = inject(CommitteeService); + private readonly messageService = inject(MessageService); + + public readonly role: LeadershipRole; + public readonly committee: Committee; + public readonly members: CommitteeMember[]; + public readonly currentLeader: CommitteeLeadership | null; + public readonly roleLabel: string; + + public form: FormGroup; + + public submitting = signal(false); + public removing = signal(false); + + public memberOptions: { label: string; value: string }[]; + + public constructor() { + this.role = this.config.data?.role ?? 'chair'; + this.committee = this.config.data?.committee; + this.members = this.config.data?.members ?? []; + this.currentLeader = this.config.data?.currentLeader ?? null; + + if (!this.committee) { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Committee data is missing. Please try again.', + }); + this.dialogRef.close(); + } + + this.roleLabel = this.role === 'chair' ? 'Chair' : 'Co-Chair'; + + this.form = new FormGroup({ + member_uid: new FormControl(this.currentLeader?.uid ?? null), + elected_date: new FormControl(this.currentLeader?.elected_date ? new Date(this.currentLeader.elected_date) : null), + }); + + this.memberOptions = this.initializeMemberOptions(); + } + + public onCancel(): void { + this.dialogRef.close(); + } + + public onSubmit(): void { + const memberUid = this.form.value.member_uid; + if (!memberUid) return; + + const selectedMember = this.members.find((m) => m.uid === memberUid); + if (!selectedMember) return; + + this.submitting.set(true); + + const leadership: CommitteeLeadership = { + uid: selectedMember.uid, + first_name: selectedMember.first_name, + last_name: selectedMember.last_name, + email: selectedMember.email, + elected_date: formatDateToISOString(this.form.value.elected_date) || undefined, + organization: selectedMember.organization?.name, + }; + + const roleName = this.role === 'chair' ? CommitteeMemberRole.CHAIR : CommitteeMemberRole.VICE_CHAIR; + const roleUpdate: Partial = { + role: { + name: roleName, + start_date: formatDateToISOString(this.form.value.elected_date) || null, + }, + }; + + this.committeeService + .updateCommitteeMember(this.committee.uid, memberUid, roleUpdate) + .pipe( + switchMap(() => { + if (this.currentLeader && this.currentLeader.uid !== memberUid) { + return this.committeeService + .updateCommitteeMember(this.committee.uid, this.currentLeader.uid, { + role: { name: CommitteeMemberRole.NONE }, + }) + .pipe( + catchError(() => { + // Assignment succeeded but clearing the previous leader's role failed. + // Warn the user so they can resolve the duplicate manually. + this.messageService.add({ + severity: 'warn', + summary: 'Partial Update', + detail: `New ${this.roleLabel.toLowerCase()} assigned, but the previous leader's role could not be cleared automatically.`, + }); + return of(null); + }) + ); + } + return of(null); + }) + ) + .subscribe({ + next: () => { + this.submitting.set(false); + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: `${this.roleLabel} assigned successfully`, + }); + this.dialogRef.close({ role: this.role, leadership }); + }, + error: () => { + this.submitting.set(false); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: `Failed to assign ${this.roleLabel.toLowerCase()}`, + }); + this.dialogRef.close(); + }, + }); + } + + public onRemove(): void { + if (!this.currentLeader) return; + + this.removing.set(true); + + this.committeeService.updateCommitteeMember(this.committee.uid, this.currentLeader.uid, { role: { name: CommitteeMemberRole.NONE } }).subscribe({ + next: () => { + this.removing.set(false); + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: `${this.roleLabel} removed`, + }); + this.dialogRef.close({ role: this.role, leadership: null }); + }, + error: () => { + this.removing.set(false); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: `Failed to remove ${this.roleLabel.toLowerCase()}`, + }); + }, + }); + } + + private initializeMemberOptions(): { label: string; value: string }[] { + return this.members.map((m) => ({ + label: `${m.first_name} ${m.last_name}${m.organization?.name ? ` — ${m.organization.name}` : ''}`, + value: m.uid, + })); + } +} diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.html b/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.html index 68cb12bd2..5e9b0eabc 100644 --- a/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.html +++ b/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.html @@ -6,171 +6,209 @@

{{ committeeLabel.singular }} Members

- - @if (canManageMembers()) { - - } + +
+ @if (canManageMembers()) { + + + } +
- -
- -
- - -
+ @if (!isMembersVisible()) { +
Member list is not publicly visible for this group.
+ } @else { + +
+ +
+ + +
- -
- - -
+ +
+ + +
- -
- - -
+ +
+ + +
- -
- - + +
+ + +
-
- - - - - - Name - - - Email - - - Organization - - @if (committee()?.enable_voting) { + + + + - Role + Name - Voting Status + Email - } - -
- Actions -
- - -
- - - - - - {{ member | fullName | titlecase }} - - - - {{ member.email || '-' }} - - - @if (member.organization?.website) { - - {{ member.organization.name }} - - } @else { - {{ member.organization?.name || '-' }} + + Organization + + @if (committee()?.enable_voting) { + + Role + + + Voting Status + } - - @if (committee()?.enable_voting) { + +
+ Actions +
+ + +
+ + + - {{ member.role?.name || 'None' }} + + {{ member | fullName | titlecase }} + - {{ member.voting?.status || 'None' }} + {{ member.email || '-' }} - } - - - @if (canManageMembers()) { - - - } @else { - - - } - - - - - - - @if (membersLoading()) { - - - + + @if (member.organization?.website) { + + {{ member.organization.name }} + + } @else { + {{ member.organization?.name || '-' }} + } - - } @else { - - -
- -

No members found

-
+ @if (committee()?.enable_voting) { + + {{ member.role?.name || 'None' }} + + + {{ member.voting?.status || 'None' }} + + } + + + @if (canManageMembers()) { + + + } @else { + + + } - } -
-
+
+ + + + @if (membersLoading()) { + + + +
+ @for (_ of [1, 2, 3]; track _) { +
+
+
+
+
+
+ } +
+ + + } @else { + + +
+ @if (groupBehavioralClass() === 'governing-board' || groupBehavioralClass() === 'oversight-committee') { + +

No Board Members Found

+

Board members with voting rights will appear here once added.

+ } @else { + +

No Contributors Yet

+

Contributors will appear here as they join this working group.

+ } + @if (canManageMembers()) { +
+ +
+ } +
+ + + } +
+
+ }
diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.ts b/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.ts index bee6d3e24..7a0e5174c 100644 --- a/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.ts @@ -1,9 +1,9 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { TitleCasePipe } from '@angular/common'; +import { isPlatformBrowser, TitleCasePipe } from '@angular/common'; +import { Component, computed, inject, input, OnInit, output, PLATFORM_ID, signal, Signal, WritableSignal } from '@angular/core'; import { FullNamePipe } from '@pipes/full-name.pipe'; -import { Component, computed, inject, input, OnInit, output, signal, Signal, WritableSignal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; @@ -13,11 +13,12 @@ import { MenuComponent } from '@components/menu/menu.component'; import { SelectComponent } from '@components/select/select.component'; import { TableComponent } from '@components/table/table.component'; import { COMMITTEE_LABEL } from '@lfx-one/shared/constants'; -import { Committee, CommitteeMember } from '@lfx-one/shared/interfaces'; +import { Committee, CommitteeMember, GroupBehavioralClass } from '@lfx-one/shared/interfaces'; import { CommitteeService } from '@services/committee.service'; +import { PersonaService } from '@services/persona.service'; import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; -import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog'; import { debounceTime, distinctUntilChanged, startWith, take } from 'rxjs'; import { MemberFormComponent } from '../member-form/member-form.component'; @@ -47,11 +48,14 @@ export class CommitteeMembersComponent implements OnInit { private readonly confirmationService = inject(ConfirmationService); private readonly dialogService = inject(DialogService); private readonly messageService = inject(MessageService); + private readonly personaService = inject(PersonaService); + private readonly platformId = inject(PLATFORM_ID); // Input signals public committee = input.required(); public members = input.required(); public membersLoading = input(true); + public groupBehavioralClass = input('other'); public readonly refresh = output(); @@ -60,7 +64,10 @@ export class CommitteeMembersComponent implements OnInit { public isDeleting: WritableSignal; public memberActionMenuItems: MenuItem[] = []; public committeeLabel = COMMITTEE_LABEL; + public isBoardMember: Signal; + public isMaintainer: Signal; public canManageMembers: Signal; + public isMembersVisible: Signal; // Filter-related variables public filterForm: FormGroup; @@ -78,7 +85,14 @@ export class CommitteeMembersComponent implements OnInit { this.selectedMember = signal(null); this.isDeleting = signal(false); // Initialize permission signals - this.canManageMembers = computed(() => !!this.committee()?.writer); + this.isBoardMember = computed(() => this.personaService.currentPersona() === 'board-member'); + this.isMaintainer = computed(() => this.personaService.currentPersona() === 'maintainer'); + this.canManageMembers = computed(() => !this.isBoardMember() && (!!this.committee()?.writer || this.isMaintainer())); + // Members visible when visibility is not 'hidden' OR user has management access + this.isMembersVisible = computed(() => { + const visibility = this.committee()?.member_visibility; + return visibility !== 'hidden' || this.canManageMembers(); + }); // Initialize filter form this.filterForm = this.initializeFilterForm(); this.searchTerm = this.initializeSearchTerm(); @@ -114,9 +128,9 @@ export class CommitteeMembersComponent implements OnInit { // Dialog will close itself }, }, - }) as DynamicDialogRef; + }); - dialogRef.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { + dialogRef?.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { if (result) { this.refreshMembers(); } @@ -140,9 +154,9 @@ export class CommitteeMembersComponent implements OnInit { // Dialog will close itself }, }, - }) as DynamicDialogRef; + }); - dialogRef.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { + dialogRef?.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { if (result) { this.refreshMembers(); } @@ -193,9 +207,8 @@ export class CommitteeMembersComponent implements OnInit { // Refresh members list by re-fetching this.refreshMembers(); }, - error: (error) => { + error: () => { this.isDeleting.set(false); - console.error('Failed to delete member:', error); this.messageService.add({ severity: 'error', @@ -246,7 +259,11 @@ export class CommitteeMembersComponent implements OnInit { { label: 'Send Message', icon: 'fa-light fa-envelope', - command: () => window.open(`mailto:${this.selectedMember()?.email}`, '_blank'), + command: () => { + if (isPlatformBrowser(this.platformId)) { + window.open(`mailto:${this.selectedMember()?.email}`, '_blank'); + } + }, }, { separator: true, diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-settings/committee-settings.component.html b/apps/lfx-one/src/app/modules/committees/components/committee-settings/committee-settings.component.html index 24c4c7618..aa8f82844 100644 --- a/apps/lfx-one/src/app/modules/committees/components/committee-settings/committee-settings.component.html +++ b/apps/lfx-one/src/app/modules/committees/components/committee-settings/committee-settings.component.html @@ -47,6 +47,34 @@

Member Visibility

+ +
+

How Can People Join the Group?

+
+
+
+
+ +
+
+ +

Control how new members can join this {{ committeeLabel.toLowerCase() }}

+
+
+ + +
+
+
+

{{ committeeLabel }} Features

diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-settings/committee-settings.component.ts b/apps/lfx-one/src/app/modules/committees/components/committee-settings/committee-settings.component.ts index 1af9406ce..9564ed012 100644 --- a/apps/lfx-one/src/app/modules/committees/components/committee-settings/committee-settings.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/committee-settings/committee-settings.component.ts @@ -6,7 +6,7 @@ import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MessageComponent } from '@components/message/message.component'; import { SelectComponent } from '@components/select/select.component'; import { ToggleComponent } from '@components/toggle/toggle.component'; -import { COMMITTEE_LABEL, COMMITTEE_SETTINGS_FEATURES, MEMBER_VISIBILITY_OPTIONS } from '@lfx-one/shared/constants'; +import { COMMITTEE_LABEL, COMMITTEE_SETTINGS_FEATURES, JOIN_MODE_OPTIONS, MEMBER_VISIBILITY_OPTIONS } from '@lfx-one/shared/constants'; @Component({ selector: 'lfx-committee-settings', @@ -21,4 +21,5 @@ export class CommitteeSettingsComponent { public readonly features = COMMITTEE_SETTINGS_FEATURES; public readonly committeeLabel = COMMITTEE_LABEL.singular; public readonly memberVisibilityOptions = MEMBER_VISIBILITY_OPTIONS; + public readonly joinModeOptions = JOIN_MODE_OPTIONS; } diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.html b/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.html new file mode 100644 index 000000000..3661dce41 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.html @@ -0,0 +1,50 @@ + + + +@if (!loading() && loadError()) { + +
+
+ +

Failed to load {{ surveyLabelPlural.toLowerCase() }}

+ +
+
+
+} @else if (!loading() && surveys().length === 0) { + +
+
+ +

No {{ surveyLabelPlural }} yet

+

Surveys created for this group will appear here.

+
+
+
+} @else { + + +} + + + diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.ts b/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.ts new file mode 100644 index 000000000..347c2ea52 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.ts @@ -0,0 +1,103 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, inject, input, signal, Signal } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { CardComponent } from '@components/card/card.component'; +import { SURVEY_LABEL } from '@lfx-one/shared/constants'; +import { Survey } from '@lfx-one/shared/interfaces'; +import { SurveyService } from '@services/survey.service'; +import { MessageService } from 'primeng/api'; +import { catchError, finalize, of, switchMap } from 'rxjs'; + +import { SurveyResultsDrawerComponent } from '@app/modules/surveys/components/survey-results-drawer/survey-results-drawer.component'; +import { SurveysTableComponent } from '@app/modules/surveys/components/surveys-table/surveys-table.component'; + +@Component({ + selector: 'lfx-committee-surveys-list', + imports: [CardComponent, SurveysTableComponent, SurveyResultsDrawerComponent], + templateUrl: './committee-surveys-list.component.html', +}) +export class CommitteeSurveysListComponent { + // -- Services -- + private readonly surveyService = inject(SurveyService); + private readonly messageService = inject(MessageService); + + // -- Constants -- + protected readonly surveyLabelPlural = SURVEY_LABEL.plural; + + // -- Inputs -- + public readonly committeeUid = input.required(); + public readonly hasPMOAccess = input(false); + + // -- Writable Signals -- + private readonly refreshTrigger = signal(0); + protected readonly loading = signal(true); + protected readonly loadError = signal(false); + protected readonly resultsDrawerVisible = signal(false); + protected readonly selectedSurveyId = signal(null); + protected readonly selectedListSurvey = signal(null); + + // -- Computed Signals -- + protected readonly surveys: Signal = this.initSurveys(); + + // -- Protected Methods -- + protected onViewResults(surveyId: string): void { + this.selectedSurveyId.set(surveyId); + this.selectedListSurvey.set(this.surveys().find((s) => s.uid === surveyId) ?? null); + this.resultsDrawerVisible.set(true); + } + + protected onRowClick(survey: Survey): void { + this.onViewResults(survey.uid); + } + + protected refreshSurveys(): void { + this.loading.set(true); + this.loadError.set(false); + this.refreshTrigger.update((v) => v + 1); + } + + protected onDuplicateSurvey(): void { + this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey duplication is not yet available' }); + } + + protected onCloseSurvey(): void { + this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey close is not yet available' }); + } + + // -- Private Initializers -- + private initSurveys(): Signal { + const committeeUid$ = toObservable(this.committeeUid); + + return toSignal( + committeeUid$.pipe( + switchMap((committeeUid) => { + if (!committeeUid) { + this.loading.set(false); + return of([]); + } + + this.loading.set(true); + this.loadError.set(false); + this.resultsDrawerVisible.set(false); + this.selectedSurveyId.set(null); + this.selectedListSurvey.set(null); + return toObservable(this.refreshTrigger).pipe( + switchMap(() => { + this.loading.set(true); + return this.surveyService.getSurveysByCommittee(committeeUid).pipe( + catchError(() => { + this.loadError.set(true); + return of([]); + }), + finalize(() => this.loading.set(false)) + ); + }) + ); + }) + ), + { initialValue: [] } + ); + } +} diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.html b/apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.html new file mode 100644 index 000000000..725d7b65d --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.html @@ -0,0 +1,38 @@ + + + +@if (!loading() && votes().length === 0 && !filters().search && !filters().status) { + +
+
+ +

No {{ voteLabelPlural }} yet

+

Votes created for this group will appear here.

+
+
+
+} @else { + + +} + + + diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.ts b/apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.ts new file mode 100644 index 000000000..3bd7bfb9a --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.ts @@ -0,0 +1,177 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, input, signal, Signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { CardComponent } from '@components/card/card.component'; +import { VOTE_LABEL } from '@lfx-one/shared'; +import { PaginatedResponse, Vote, VoteFilterState } from '@lfx-one/shared/interfaces'; +import { VoteService } from '@services/vote.service'; +import { catchError, combineLatest, map, of, switchMap, tap } from 'rxjs'; + +import { VoteResultsDrawerComponent } from '@app/modules/votes/components/vote-results-drawer/vote-results-drawer.component'; +import { VotesTableComponent } from '@app/modules/votes/components/votes-table/votes-table.component'; + +@Component({ + selector: 'lfx-committee-votes-list', + imports: [CardComponent, VotesTableComponent, VoteResultsDrawerComponent], + templateUrl: './committee-votes-list.component.html', +}) +export class CommitteeVotesListComponent { + // -- Services -- + private readonly voteService = inject(VoteService); + + // -- Constants -- + protected readonly voteLabelPlural = VOTE_LABEL.plural; + + // -- Inputs -- + public readonly projectUid = input.required(); + public readonly committeeName = input.required(); + public readonly hasPMOAccess = input(false); + + // -- Trigger signals -- + private readonly fetchTrigger = signal(0); + private readonly refreshTrigger = signal(0); + + // -- Writable Signals -- + protected readonly loading = signal(true); + protected readonly resultsDrawerVisible = signal(false); + protected readonly selectedVoteId = signal(null); + protected readonly rowsPerPage = signal(10); + protected readonly currentFirst = signal(0); + protected readonly totalRecords = signal(0); + + // -- Filter State -- + protected readonly filters = signal({ search: '', status: null, group: null }); + + // -- Page Tokens -- + private pageTokens: string[] = []; + + // -- Computed Signals -- + protected readonly votes: Signal = this.initVotes(); + protected readonly selectedListVote: Signal = this.initSelectedListVote(); + + public constructor() { + this.initTotalCountSubscription(); + } + + // -- Protected Methods -- + protected onViewVote(voteId: string): void { + this.selectedVoteId.set(voteId); + this.resultsDrawerVisible.set(true); + } + + protected refreshVotes(): void { + this.loading.set(true); + this.pageTokens = []; + this.currentFirst.set(0); + this.fetchTrigger.update((v) => v + 1); + this.refreshTrigger.update((v) => v + 1); + } + + protected onPageChange(event: { first: number; rows: number }): void { + if (event.rows !== this.rowsPerPage()) { + this.pageTokens = []; + this.rowsPerPage.set(event.rows); + this.currentFirst.set(0); + this.fetchTrigger.update((v) => v + 1); + return; + } + + this.currentFirst.set(event.first); + this.fetchTrigger.update((v) => v + 1); + } + + protected onFiltersChange(state: VoteFilterState): void { + this.pageTokens = []; + this.currentFirst.set(0); + this.filters.set(state); + } + + // -- Private Helpers -- + private buildFilters(): string[] { + const queryFilters: string[] = [`committee_name:${this.committeeName()}`]; + const { status } = this.filters(); + if (status) { + queryFilters.push(`status:${status}`); + } + return queryFilters; + } + + // -- Private Initializers -- + private initTotalCountSubscription(): void { + const filters$ = toObservable(this.filters); + const projectUid$ = toObservable(this.projectUid); + const committeeName$ = toObservable(this.committeeName); + const refresh$ = toObservable(this.refreshTrigger); + + combineLatest([projectUid$, committeeName$, filters$, refresh$]) + .pipe( + switchMap(([projectUid]) => { + if (!projectUid) return of(0); + const searchName = this.filters().search; + const queryFilters = this.buildFilters(); + return this.voteService.getVotesCountByProject(projectUid, searchName || undefined, queryFilters).pipe(catchError(() => of(0))); + }), + tap((count) => this.totalRecords.set(count)), + takeUntilDestroyed() + ) + .subscribe(); + } + + private initVotes(): Signal { + const filters$ = toObservable(this.filters); + const projectUid$ = toObservable(this.projectUid); + const committeeName$ = toObservable(this.committeeName); + const fetch$ = toObservable(this.fetchTrigger); + + return toSignal( + combineLatest([projectUid$, committeeName$, filters$, fetch$]).pipe( + tap(() => this.loading.set(true)), + switchMap(([projectUid]) => { + if (!projectUid) { + this.loading.set(false); + return of([]); + } + + const rows = this.rowsPerPage(); + const first = this.currentFirst(); + const pageIndex = first / rows; + const pageToken = pageIndex > 0 ? this.pageTokens[pageIndex - 1] : undefined; + + if (pageIndex > 0 && !pageToken) { + this.currentFirst.set(0); + this.loading.set(false); + return of([]); + } + + const searchName = this.filters().search; + const queryFilters = this.buildFilters(); + + return this.voteService.getVotesByProjectPaginated(projectUid, rows, pageToken, searchName || undefined, queryFilters).pipe( + tap((response: PaginatedResponse) => { + if (response.page_token) { + this.pageTokens[pageIndex] = response.page_token; + } + this.loading.set(false); + }), + map((response: PaginatedResponse) => response.data), + catchError(() => { + this.loading.set(false); + return of([]); + }) + ); + }) + ), + { initialValue: [] } + ); + } + + private initSelectedListVote(): Signal { + return computed(() => { + const id = this.selectedVoteId(); + if (!id) return null; + return this.votes().find((v) => v.uid === id) || null; + }); + } +} diff --git a/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.html b/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.html index 5fa2482c0..320445089 100644 --- a/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.html +++ b/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.html @@ -8,7 +8,14 @@
- + @if (form().get('first_name')?.errors?.['required'] && form().get('first_name')?.touched) {

First name is required

} @@ -17,7 +24,14 @@
- + @if (form().get('last_name')?.errors?.['required'] && form().get('last_name')?.touched) {

Last name is required

} @@ -27,7 +41,14 @@
- + @if (form().get('email')?.errors?.['required'] && form().get('email')?.touched) {

Email address is required

} @@ -42,7 +63,14 @@
- +
@@ -58,9 +86,19 @@ data-testid="member-form-linkedin-profile">
+ +
+ +
+
- + + @if (form().get('organization')?.errors?.['required'] && form().get('organization')?.touched && !form().get('is_individual')?.value) { +

Organization is required

+ }
+ @if (committee?.enable_voting) { -
- + + id="role" + data-testid="member-form-role"> + @if (form().get('role')?.errors?.['required'] && form().get('role')?.touched) { +

Role is required

+ }
-
- -
- - +
+
+ + @if (form().get('role_start')?.value || form().get('role_end')?.value) { + + }
- - -
- - +
+ +
+ @if (form().errors?.['role_start_after_role_end']) { +

Role end date must be after start date

+ }
- + + id="voting-status" + data-testid="member-form-voting-status"> + @if (form().get('voting_status')?.errors?.['required'] && form().get('voting_status')?.touched) { +

Voting status is required

+ }
-
- -
- - +
+
+ + @if (form().get('voting_status_start')?.value || form().get('voting_status_end')?.value) { + + }
- - -
- - +
+ +
+ @if (form().errors?.['voting_status_start_after_voting_status_end']) { +

Voting end date must be after start date

+ }
- + data-testid="member-form-appointed-by">
} diff --git a/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.ts b/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.ts index f894d0799..d9cd97a50 100644 --- a/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.ts @@ -1,15 +1,18 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import { HttpErrorResponse } from '@angular/common/http'; import { Component, inject, signal } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; import { CalendarComponent } from '@components/calendar/calendar.component'; +import { CheckboxComponent } from '@components/checkbox/checkbox.component'; import { InputTextComponent } from '@components/input-text/input-text.component'; import { OrganizationSearchComponent } from '@components/organization-search/organization-search.component'; import { SelectComponent } from '@components/select/select.component'; -import { LINKEDIN_PROFILE_PATTERN, MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; -import { Committee, CommitteeMember, CreateCommitteeMemberRequest } from '@lfx-one/shared/interfaces'; +import { APPOINTED_BY_OPTIONS, LINKEDIN_PROFILE_PATTERN, MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; +import { Committee, CommitteeMember, CreateCommitteeMemberRequest, MemberFormValue } from '@lfx-one/shared/interfaces'; import { formatDateToISOString, parseISODateString } from '@lfx-one/shared/utils'; import { CommitteeService } from '@services/committee.service'; import { MessageService } from 'primeng/api'; @@ -17,7 +20,7 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; @Component({ selector: 'lfx-member-form', - imports: [ReactiveFormsModule, ButtonComponent, SelectComponent, InputTextComponent, CalendarComponent, OrganizationSearchComponent], + imports: [ReactiveFormsModule, ButtonComponent, SelectComponent, InputTextComponent, CalendarComponent, OrganizationSearchComponent, CheckboxComponent], templateUrl: './member-form.component.html', styleUrl: './member-form.component.scss', }) @@ -45,6 +48,7 @@ export class MemberFormComponent { // Member options public roleOptions = MEMBER_ROLES; public votingStatusOptions = VOTING_STATUSES; + public appointedByOptions = APPOINTED_BY_OPTIONS; public constructor() { // Initialize config-based properties @@ -56,6 +60,47 @@ export class MemberFormComponent { // Initialize form with data when component is created this.initializeForm(); + + // Listen to individual toggle changes (fixes click-before-value-update timing) + this.form() + .get('is_individual') + ?.valueChanges.pipe(takeUntilDestroyed()) + .subscribe(() => this.onIndividualToggle()); + } + + public onIndividualToggle(): void { + const isIndividual = this.form().get('is_individual')?.value; + const orgControl = this.form().get('organization'); + const orgUrlControl = this.form().get('organization_url'); + + if (isIndividual) { + orgControl?.setValue(''); + orgUrlControl?.setValue(''); + orgControl?.disable(); + orgUrlControl?.disable(); + orgControl?.clearValidators(); + } else { + orgControl?.enable(); + orgUrlControl?.enable(); + orgControl?.setValidators([Validators.required]); + } + orgControl?.updateValueAndValidity(); + } + + public clearRoleDates(): void { + this.form().get('role_start')?.reset(); + this.form().get('role_end')?.reset(); + this.form().updateValueAndValidity(); + } + + public clearVotingDates(): void { + this.form().get('voting_status_start')?.reset(); + this.form().get('voting_status_end')?.reset(); + this.form().updateValueAndValidity(); + } + + public onDateChange(): void { + this.form().updateValueAndValidity(); } public onCancel(): void { @@ -66,7 +111,7 @@ export class MemberFormComponent { public onSubmit(): void { if (this.form().valid) { this.submitting.set(true); - const formValue = this.form().value; + const formValue = this.form().getRawValue() as MemberFormValue; // Prepare member data using form values, mapping to new structure const memberData: CreateCommitteeMemberRequest = { @@ -90,13 +135,7 @@ export class MemberFormComponent { end_date: formatDateToISOString(formValue.voting_status_end) || null, } : null, - organization: - formValue.organization || formValue.organization_url - ? { - name: formValue.organization || null, - website: formValue.organization_url || null, - } - : null, + organization: this.buildOrganizationPayload(formValue), }; // In wizard mode, return the data without calling API @@ -132,11 +171,9 @@ export class MemberFormComponent { }); this.dialogRef.close(true); }, - error: (error) => { + error: (err: HttpErrorResponse) => { this.submitting.set(false); - console.error('Failed to save member:', error); - - if (error.status === 409) { + if (err.status === 409) { this.messageService.add({ severity: 'error', summary: 'Error', @@ -152,22 +189,21 @@ export class MemberFormComponent { }, }); } else { - // Mark all fields as touched to show validation errors - Object.keys(this.form().controls).forEach((key) => { - this.form().get(key)?.markAsTouched(); - }); + this.form().markAllAsTouched(); } } private initializeForm(): void { if (this.isEditing && this.member) { const member = this.member; + const hasOrg = !!member.organization?.name || !!member.organization?.website; this.form().patchValue({ first_name: member.first_name, last_name: member.last_name, email: member.email, job_title: member.job_title, linkedin_profile: member.linkedin_profile, + is_individual: !hasOrg, organization: member.organization?.name, organization_url: member.organization?.website, role: member.role?.name, @@ -178,25 +214,63 @@ export class MemberFormComponent { voting_status_start: parseISODateString(member.voting?.start_date), voting_status_end: parseISODateString(member.voting?.end_date), }); + + // Apply individual toggle state after patching + if (!hasOrg) { + this.onIndividualToggle(); + } + } + } + + private buildOrganizationPayload(formValue: MemberFormValue): CreateCommitteeMemberRequest['organization'] { + if (formValue.is_individual) { + return null; } + if (formValue.organization || formValue.organization_url) { + return { + name: formValue.organization || null, + website: formValue.organization_url || null, + }; + } + return null; } private createMemberFormGroup(): FormGroup { - return new FormGroup({ - first_name: new FormControl('', [Validators.required]), - last_name: new FormControl('', [Validators.required]), - email: new FormControl('', [Validators.required, Validators.email]), - job_title: new FormControl(''), - linkedin_profile: new FormControl('', [Validators.pattern(LINKEDIN_PROFILE_PATTERN)]), - organization: new FormControl(''), - organization_url: new FormControl(''), - role: new FormControl(''), - voting_status: new FormControl(''), - appointed_by: new FormControl(''), - role_start: new FormControl(null), - role_end: new FormControl(null), - voting_status_start: new FormControl(null), - voting_status_end: new FormControl(null), - }); + return new FormGroup( + { + first_name: new FormControl('', [Validators.required]), + last_name: new FormControl('', [Validators.required]), + email: new FormControl('', [Validators.required, Validators.email]), + job_title: new FormControl(''), + linkedin_profile: new FormControl('', [Validators.pattern(LINKEDIN_PROFILE_PATTERN)]), + is_individual: new FormControl(false), + organization: new FormControl('', [Validators.required]), + organization_url: new FormControl(''), + role: new FormControl('', this.committee?.enable_voting ? [Validators.required] : []), + voting_status: new FormControl('', this.committee?.enable_voting ? [Validators.required] : []), + appointed_by: new FormControl(''), + role_start: new FormControl(null), + role_end: new FormControl(null), + voting_status_start: new FormControl(null), + voting_status_end: new FormControl(null), + }, + { + validators: [ + MemberFormComponent.dateRangeValidator('role_start', 'role_end'), + MemberFormComponent.dateRangeValidator('voting_status_start', 'voting_status_end'), + ], + } + ); + } + + private static dateRangeValidator(startKey: string, endKey: string) { + return (group: AbstractControl): ValidationErrors | null => { + const start = group.get(startKey)?.value; + const end = group.get(endKey)?.value; + if (start && end && new Date(start) > new Date(end)) { + return { [`${startKey}_after_${endKey}`]: true }; + } + return null; + }; } } diff --git a/apps/lfx-one/src/app/modules/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.ts b/apps/lfx-one/src/app/modules/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.ts index 8c656787c..e2e671f7c 100644 --- a/apps/lfx-one/src/app/modules/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.ts @@ -6,10 +6,10 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { RouterLink } from '@angular/router'; import { Meeting } from '@lfx-one/shared/interfaces'; import { MeetingTimePipe } from '@pipes/meeting-time.pipe'; -import { MeetingService } from '@services/meeting.service'; +import { CommitteeService } from '@services/committee.service'; import { ProjectService } from '@services/project.service'; import { TooltipModule } from 'primeng/tooltip'; -import { filter, map, of } from 'rxjs'; +import { map, of } from 'rxjs'; @Component({ selector: 'lfx-upcoming-committee-meeting', @@ -17,8 +17,8 @@ import { filter, map, of } from 'rxjs'; templateUrl: './upcoming-committee-meeting.component.html', }) export class UpcomingCommitteeMeetingComponent implements OnInit { + private readonly committeeService = inject(CommitteeService); private readonly projectService = inject(ProjectService); - private readonly meetingService = inject(MeetingService); private readonly injector = inject(Injector); public readonly committeeId = input(null); @@ -37,9 +37,21 @@ export class UpcomingCommitteeMeetingComponent implements OnInit { } private initializeUpcomingMeeting(): Signal { - return toSignal(this.project() ? this.getNextUpcomingCommitteeMeeting(this.project()!.uid, this.committeeId()) : of(null), { - initialValue: null, - }); + const committeeId = this.committeeId(); + if (!committeeId) return toSignal(of(null), { initialValue: null }); + + return toSignal( + this.committeeService.getCommitteeMeetings(committeeId).pipe( + map((meetings: Meeting[]) => { + const now = new Date().getTime(); + const upcoming = meetings + .filter((m) => m.start_time && new Date(m.start_time).getTime() > now) + .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()); + return upcoming[0] ?? null; + }) + ), + { initialValue: null } + ); } private initializeCommittees() { @@ -50,36 +62,4 @@ export class UpcomingCommitteeMeetingComponent implements OnInit { .join(', ') ?? '' ); } - - private getNextUpcomingCommitteeMeeting(uid: string, committeeId: string | null = null) { - return this.meetingService.getMeetingsByProject(uid).pipe( - filter((meetings: Meeting[]) => { - // Return only meetings that have a start time in the future and has a committee value regardless of the committee id - return ( - meetings.filter((meeting) => new Date(meeting.start_time).getTime() > new Date().getTime() && meeting.committees && meeting.committees?.length > 0) - .length > 0 - ); - }), - map((meetings: Meeting[]) => { - if (meetings.length > 0) { - if (committeeId) { - // Find the earliest upcoming meeting that has the committee id and return it - const committeeMeetings = meetings.filter( - (meeting) => - new Date(meeting.start_time).getTime() > new Date().getTime() && - meeting.committees && - meeting.committees?.length > 0 && - meeting.committees.some((c) => c.uid === committeeId) - ); - - return committeeMeetings.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime())[0]; - } - - // Return the next upcoming meeting by date in the future - return meetings.filter((meeting) => new Date(meeting.start_time).getTime() > new Date().getTime())[0]; - } - return null; - }) - ); - } } diff --git a/apps/lfx-one/src/app/modules/surveys/survey-manage/survey-manage.component.ts b/apps/lfx-one/src/app/modules/surveys/survey-manage/survey-manage.component.ts index c4bcdf9a5..24cc402bb 100644 --- a/apps/lfx-one/src/app/modules/surveys/survey-manage/survey-manage.component.ts +++ b/apps/lfx-one/src/app/modules/surveys/survey-manage/survey-manage.component.ts @@ -72,6 +72,10 @@ export class SurveyManageComponent { public currentStep: Signal = this.initCurrentStep(); public readonly submitButtonLabel: Signal = this.initSubmitButtonLabel(); + public constructor() { + this.preselectCommitteeFromQueryParams(); + } + public nextStep(): void { const next = this.currentStep() + 1; if (next <= this.totalSteps && this.canNavigateToStep(next)) { @@ -384,6 +388,17 @@ Thank you, } } + private preselectCommitteeFromQueryParams(): void { + const params = this.route.snapshot.queryParams; + const uid = params['committee_uid']; + const name = params['committee_name']; + if (uid && name) { + this.form() + .get('committees') + ?.setValue([{ uid, name, allowed_voting_statuses: [] }]); + } + } + private markAllFormControlsAsTouched(): void { markFormControlsAsTouched(this.form()); } diff --git a/apps/lfx-one/src/app/modules/votes/components/votes-table/votes-table.component.html b/apps/lfx-one/src/app/modules/votes/components/votes-table/votes-table.component.html index fa8a9f09f..cae0a1184 100644 --- a/apps/lfx-one/src/app/modules/votes/components/votes-table/votes-table.component.html +++ b/apps/lfx-one/src/app/modules/votes/components/votes-table/votes-table.component.html @@ -15,18 +15,20 @@ data-testid="votes-search-input">
-
- -
+ @if (!hideGroupFilter()) { +
+ +
+ }
(0); public readonly lazy = input(false); public readonly groupOptions = input<{ label: string; value: string | null }[]>([{ label: 'All Groups', value: null }]); + public readonly hideGroupFilter = input(false); // === Outputs === public readonly viewVote = output(); diff --git a/apps/lfx-one/src/app/shared/services/committee.service.ts b/apps/lfx-one/src/app/shared/services/committee.service.ts index 1378a7714..a1920af5d 100644 --- a/apps/lfx-one/src/app/shared/services/committee.service.ts +++ b/apps/lfx-one/src/app/shared/services/committee.service.ts @@ -3,7 +3,16 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable, signal, WritableSignal } from '@angular/core'; -import { Committee, CommitteeMember, CreateCommitteeMemberRequest, MyCommittee, QueryServiceCountResponse } from '@lfx-one/shared/interfaces'; +import { + Committee, + CommitteeMember, + CommitteeVote, + CreateCommitteeMemberRequest, + Meeting, + MyCommittee, + PaginatedResponse, + QueryServiceCountResponse, +} from '@lfx-one/shared/interfaces'; import { catchError, map, Observable, of, take, tap, throwError } from 'rxjs'; @Injectable({ @@ -79,6 +88,15 @@ export class CommitteeService { return this.http.delete(`/api/committees/${committeeId}/members/${memberId}`).pipe(take(1)); } + public getCommitteeMeetings(committeeId: string): Observable { + return this.http.get>(`/api/committees/${committeeId}/meetings`).pipe(map((response) => response.data)); + } + + // Dashboard sub-resource methods — error handling is done at the component layer + public getCommitteeVotes(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/votes`); + } + // ── Join / Leave Methods ────────────────────────────────────────────────── /** Self-join an open group */ diff --git a/apps/lfx-one/src/app/shared/services/survey.service.ts b/apps/lfx-one/src/app/shared/services/survey.service.ts index ad7ddadb5..3d9a99d16 100644 --- a/apps/lfx-one/src/app/shared/services/survey.service.ts +++ b/apps/lfx-one/src/app/shared/services/survey.service.ts @@ -35,6 +35,10 @@ export class SurveyService { return this.getSurveys(params); } + public getSurveysByCommittee(committeeUid: string): Observable { + return this.http.get(`/api/committees/${committeeUid}/surveys`); + } + public getSurvey(surveyUid: string, projectId?: string): Observable { let params = new HttpParams(); if (projectId) { diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index 8ce14968b..19d04c18c 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -7,12 +7,27 @@ import { NextFunction, Request, Response } from 'express'; import { ServiceValidationError } from '../errors'; import { logger } from '../services/logger.service'; import { CommitteeService } from '../services/committee.service'; +import { SurveyService } from '../services/survey.service'; /** * Controller for handling committee HTTP requests */ export class CommitteeController { private committeeService: CommitteeService = new CommitteeService(); + // Cross-domain: surveys are accessed via committee context for the surveys tab + private readonly surveyService = new SurveyService(); + + // ── Dashboard Sub-Resource Handlers (via factory) ───────────────────────── + + /** GET /committees/:id/votes */ + public getCommitteeVotes = this.subResourceHandler('get_committee_votes', (req, id) => this.committeeService.getCommitteeVotes(req, id), 'vote_count'); + + /** GET /committees/:id/meetings */ + public getCommitteeMeetings = this.subResourceHandler( + 'get_committee_meetings', + (req, id) => this.committeeService.getCommitteeMeetings(req, id, req.query as Record), + 'meeting_count' + ); /** * GET /committees @@ -521,4 +536,76 @@ export class CommitteeController { next(error); } } + + /** + * GET /committees/:id/surveys + * Manual handler (not using subResourceHandler) because this endpoint needs req.query passthrough for survey filtering + */ + public async getCommitteeSurveys(req: Request, res: Response, next: NextFunction): Promise { + const { id } = req.params; + const startTime = logger.startOperation(req, 'get_committee_surveys', { + committee_id: id, + query_params: logger.sanitize(req.query as Record), + }); + + try { + if (!id) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_surveys', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + + const surveys = await this.surveyService.getCommitteeSurveys(req, id, req.query as Record); + + logger.success(req, 'get_committee_surveys', startTime, { + committee_id: id, + survey_count: surveys.length, + }); + + res.json(surveys); + } catch (error) { + logger.error(req, 'get_committee_surveys', startTime, error, { committee_id: id }); + next(error); + } + } + + // ── Private helpers ────────────────────────────────────────────────────── + + /** + * Factory that produces a standard sub-resource handler. + * Validates the committee ID, starts an operation, calls the service, + * logs success, and delegates errors to Express error middleware. + */ + private subResourceHandler(operation: string, serviceFn: (req: Request, id: string) => Promise, countKey: string) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const committeeId = req.params['id']; + if (!committeeId) { + next( + ServiceValidationError.forField('id', 'Committee ID is required', { + operation, + service: 'committee_controller', + path: req.path, + }) + ); + return; + } + + const startTime = logger.startOperation(req, operation, { committee_id: committeeId }); + try { + const result = await serviceFn(req, committeeId); + logger.success(req, operation, startTime, { + committee_id: committeeId, + [countKey]: Array.isArray(result) ? result.length : !!result, + }); + res.json(result); + } catch (error) { + logger.error(req, operation, startTime, error, { committee_id: committeeId }); + next(error); + } + }; + } } diff --git a/apps/lfx-one/src/server/routes/committees.route.ts b/apps/lfx-one/src/server/routes/committees.route.ts index 2887494be..681645f82 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -25,8 +25,15 @@ router.post('/:id/members', (req, res, next) => committeeController.createCommit router.put('/:id/members/:memberId', (req, res, next) => committeeController.updateCommitteeMember(req, res, next)); router.delete('/:id/members/:memberId', (req, res, next) => committeeController.deleteCommitteeMember(req, res, next)); +// Meeting routes +router.get('/:id/meetings', (req, res, next) => committeeController.getCommitteeMeetings(req, res, next)); + +// Dashboard sub-resource routes +router.get('/:id/votes', (req, res, next) => committeeController.getCommitteeVotes(req, res, next)); // ── Join / Leave routes ──────────────────────────────────────────────────── router.post('/:id/join', (req, res, next) => committeeController.joinCommittee(req, res, next)); router.delete('/:id/leave', (req, res, next) => committeeController.leaveCommittee(req, res, next)); +// Survey routes +router.get('/:id/surveys', (req, res, next) => committeeController.getCommitteeSurveys(req, res, next)); export default router; diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index d975e873f..66ba071f7 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -7,7 +7,10 @@ import { CommitteeMember, CommitteeSettingsData, CommitteeUpdateData, + CommitteeVote, CreateCommitteeMemberRequest, + Meeting, + PaginatedResponse, MyCommittee, QueryServiceCountResponse, QueryServiceResponse, @@ -17,11 +20,15 @@ import { Request } from 'express'; import { getUsernameFromAuth } from '../utils/auth-helper'; import { ResourceNotFoundError } from '../errors'; -import { logger } from '../services/logger.service'; +import { logger } from './logger.service'; import { AccessCheckService } from './access-check.service'; import { ETagService } from './etag.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; +// MeetingService is dynamically imported to avoid circular dependency. +// Use import('...') type to reference the class without a static import. +type MeetingServiceType = InstanceType; + /** * Service for handling committee business logic */ @@ -29,6 +36,8 @@ export class CommitteeService { private accessCheckService: AccessCheckService; private etagService: ETagService; private microserviceProxy: MicroserviceProxyService; + // Promise-based lazy initializer to avoid concurrent imports creating duplicate instances + private meetingServicePromise?: Promise; public constructor() { this.accessCheckService = new AccessCheckService(); @@ -138,12 +147,11 @@ export class CommitteeService { * Updates an existing committee using ETag for concurrency control */ public async updateCommittee(req: Request, committeeId: string, data: CommitteeUpdateData): Promise { - // Extract settings and channel fields from core committee data - const { business_email_required, is_audit_enabled, show_meeting_attendees, member_visibility, mailing_list, chat_channel, ...committeeData } = data; + // Extract settings fields from core committee data + const { business_email_required, is_audit_enabled, show_meeting_attendees, member_visibility, ...committeeData } = data; const hasSettingsUpdate = business_email_required !== undefined || is_audit_enabled !== undefined || show_meeting_attendees !== undefined || member_visibility !== undefined; - const hasChannelsUpdate = mailing_list !== undefined || chat_channel !== undefined; const hasCoreUpdate = Object.keys(committeeData).length > 0; let updatedCommittee: Committee; @@ -166,30 +174,7 @@ export class CommitteeService { updatedCommittee = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}`, 'GET'); } - // Step 3: Update channels via PATCH (mailing_list/chat_channel are not accepted by PUT) - if (hasChannelsUpdate) { - try { - const channelsPayload: Record = {}; - if (mailing_list !== undefined) channelsPayload['mailing_list'] = mailing_list; - if (chat_channel !== undefined) channelsPayload['chat_channel'] = chat_channel; - - logger.debug(req, 'update_committee_channels', 'Updating committee channels via PATCH', { - committee_uid: committeeId, - fields: Object.keys(channelsPayload), - }); - - const patched = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}`, 'PATCH', {}, channelsPayload); - - updatedCommittee = { ...updatedCommittee, ...patched }; - } catch (error) { - logger.warning(req, 'update_committee_channels', 'PATCH failed for channels, returning current committee data', { - committee_uid: committeeId, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - - // Step 5: Update settings if provided + // Step 3: Update settings if provided if (hasSettingsUpdate) { try { await this.updateCommitteeSettings(req, committeeId, { @@ -390,6 +375,128 @@ export class CommitteeService { return userMemberships; } + // ── Dashboard Sub-Resource Methods ────────────────────────────────────────── + + public async getCommitteeVotes(req: Request, committeeId: string): Promise { + try { + const { resources: committeeVoteResources } = await this.microserviceProxy.proxyRequest>( + req, + 'LFX_V2_SERVICE', + '/query/resources', + 'GET', + { + type: 'committee_vote', + tags: `committee_uid:${committeeId}`, + } + ); + + if (committeeVoteResources.length > 0) { + return committeeVoteResources.map((r) => r.data); + } + + const committee = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}`, 'GET'); + const projectUid: string | undefined = committee?.project_uid; + + if (!projectUid) { + return []; + } + + const { resources: voteResources } = await this.microserviceProxy.proxyRequest< + QueryServiceResponse<{ + uid: string; + name: string; + status: string; + end_time: string; + committee_uid: string; + num_response_received?: number; + total_voting_request_invitations?: number; + }> + >(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + type: 'vote', + parent: `project:${projectUid}`, + // TODO(LFXV2-1218): Implement cursor-based pagination for complete vote results + page_size: 500, + }); + + if (voteResources.length >= 500) { + logger.warning(req, 'get_committee_votes', 'Vote results may be truncated — page_size limit reached', { + committee_uid: committeeId, + project_uid: projectUid, + fetched_count: voteResources.length, + }); + } + + return voteResources + .filter((r) => r.data.committee_uid === committeeId) + .map((r) => { + const totalResponses = r.data.num_response_received ?? 0; + return { + uid: r.data.uid, + title: r.data.name, + status: CommitteeService.getVoteStatus(r.data.status), + deadline: r.data.end_time, + // Per-option breakdown is not available from the vote resource. + // Assign all responses to votes_for so UI progress bars reflect the + // real response count instead of showing misleading zeros. + votes_for: totalResponses, + votes_against: 0, + votes_abstain: 0, + total_responses: totalResponses, + total_eligible: r.data.total_voting_request_invitations ?? 0, + created_by: '', + }; + }); + } catch (error) { + logger.warning(req, 'get_committee_votes', 'Failed to fetch committee votes, returning empty', { + committee_uid: committeeId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return []; + } + } + + /** + * Fetches meetings associated with a committee. + */ + public async getCommitteeMeetings(req: Request, committeeId: string, query: Record = {}): Promise> { + try { + // Whitelist allowed query params to prevent unexpected parameters from reaching downstream + const allowedParams = ['page_size', 'page_token', 'order_by', 'committee_uid']; + const sanitizedQuery: Record = {}; + for (const key of allowedParams) { + if (query[key]) sanitizedQuery[key] = String(query[key]); + } + + const params = { + ...sanitizedQuery, + committee_uid: committeeId, + }; + + logger.debug(req, 'get_committee_meetings', 'Fetching meetings for committee', { + committee_uid: committeeId, + }); + + // Lazy import to avoid circular dependency — Promise ensures only one instance is created + if (!this.meetingServicePromise) { + this.meetingServicePromise = import('./meeting.service').then((m) => new m.MeetingService()); + } + const meetingService = await this.meetingServicePromise; + const result = await meetingService.getMeetings(req, params); + + logger.debug(req, 'get_committee_meetings', 'Fetched committee meetings', { + committee_uid: committeeId, + count: result.data.length, + }); + + return result; + } catch { + logger.warning(req, 'get_committee_meetings', 'Failed to fetch committee meetings, returning empty', { + committee_uid: committeeId, + }); + return { data: [], page_token: undefined }; + } + } + // ── My Committees ───────────────────────────────────────────────────────── public async getMyCommittees(req: Request, projectUid?: string): Promise { @@ -521,4 +628,13 @@ export class CommitteeService { settings_data: settingsData, }); } + + /** + * Maps upstream vote status strings to the normalized status used by the UI. + */ + private static getVoteStatus(status: string): 'open' | 'closed' | 'cancelled' { + if (status === 'ended') return 'closed'; + if (status === 'cancelled') return 'cancelled'; + return 'open'; + } } diff --git a/apps/lfx-one/src/server/services/survey.service.ts b/apps/lfx-one/src/server/services/survey.service.ts index 89195c2d9..9a666d651 100644 --- a/apps/lfx-one/src/server/services/survey.service.ts +++ b/apps/lfx-one/src/server/services/survey.service.ts @@ -89,6 +89,33 @@ export class SurveyService { return survey; } + /** + * Fetches surveys for a specific committee by committee_uid + */ + public async getCommitteeSurveys(req: Request, committeeId: string, query: Record = {}): Promise { + logger.debug(req, 'get_committee_surveys', 'Fetching surveys for committee', { + committee_uid: committeeId, + }); + + // Use tags parameter for server-side filtering — committee_uid is not a supported query param + const params = { + ...query, + type: 'survey', + tags: `committee_uid:${committeeId}`, + }; + + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); + + const surveys: Survey[] = (resources ?? []).map((resource) => resource.data); + + logger.debug(req, 'get_committee_surveys', 'Completed committee survey fetch', { + committee_uid: committeeId, + count: surveys.length, + }); + + return surveys; + } + /** * Deletes a survey using ETag for concurrency control */ diff --git a/packages/shared/src/interfaces/committee-application.interface.ts b/packages/shared/src/interfaces/committee-application.interface.ts deleted file mode 100644 index 3ef9536ee..000000000 --- a/packages/shared/src/interfaces/committee-application.interface.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -/** - * Status of a committee join application - */ -export type CommitteeJoinApplicationStatus = 'pending' | 'approved' | 'rejected'; - -/** - * Represents a join application for a committee - */ -export interface CommitteeJoinApplication { - uid: string; - committee_uid: string; - applicant_email: string; - applicant_name?: string; - applicant_uid?: string; - status: CommitteeJoinApplicationStatus; - reason?: string; - created_at: string; - updated_at?: string; -} - -/** - * Request payload to create a committee join application - */ -export interface CreateCommitteeJoinApplicationRequest { - reason?: string; -} diff --git a/packages/shared/src/interfaces/committee.interface.ts b/packages/shared/src/interfaces/committee.interface.ts index c398d3f86..932ef456b 100644 --- a/packages/shared/src/interfaces/committee.interface.ts +++ b/packages/shared/src/interfaces/committee.interface.ts @@ -98,6 +98,29 @@ export interface GroupEligibility { voting_tier?: 'platinum' | 'gold'; } +/** + * Leadership role type for committee chair / co-chair assignment + */ +export type LeadershipRole = 'chair' | 'co_chair'; + +/** + * Leadership information for a committee chair or co-chair + */ +export interface CommitteeLeadership { + /** Unique identifier for the leader (member UID) */ + uid: string; + /** Leader's first name */ + first_name: string; + /** Leader's last name */ + last_name: string; + /** Leader's email address */ + email: string; + /** Date when the leader was elected/appointed (ISO 8601 date string) */ + elected_date?: string; + /** Organization the leader belongs to (may not be returned by all API versions) */ + organization?: string; +} + /** * Lightweight committee reference for cross-module use * @description Minimal committee data with voting status eligibility @@ -175,6 +198,8 @@ export interface Committee { oversight_sub_type?: OversightSubType; /** Membership-tier eligibility thresholds for participation */ eligibility?: GroupEligibility; + /** Key focus areas or topics for the committee (pending API support) */ + key_topics?: string[]; // ── Join & Invite fields ── /** How users can join this group (default: 'invite_only') */ @@ -186,9 +211,11 @@ export interface Committee { /** Chat channel URL or identifier associated with the group (plain string from upstream) */ chat_channel?: string; - // NOTE: chair/co_chair are NOT returned by GET /committees/{uid}. - // Leadership is derived from committee members with role.name === "Chair" / "Vice Chair". - // Server-side enrichment will be added in a follow-up PR. + // ── Leadership ── + /** Committee chair (derived from member with role "Chair") */ + chair?: CommitteeLeadership | null; + /** Committee co-chair (derived from member with role "Vice Chair") */ + co_chair?: CommitteeLeadership | null; } /** @@ -246,6 +273,10 @@ export interface CommitteeCreateData { * @description Partial update payload allowing any field from create data to be modified */ export interface CommitteeUpdateData extends Partial { + /** Assign or remove chair */ + chair?: CommitteeLeadership | null; + /** Assign or remove co-chair */ + co_chair?: CommitteeLeadership | null; /** Update or clear mailing list email */ mailing_list?: string | null; /** Update or clear chat channel */ @@ -287,6 +318,8 @@ export interface CommitteeVote { votes_for: number; votes_against: number; votes_abstain: number; + /** Total responses received (used when per-option breakdown is unavailable) */ + total_responses?: number; total_eligible: number; created_by: string; } diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 9739d17bc..c755e13ca 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -86,8 +86,5 @@ export * from './filter.interface'; // Lens interfaces export * from './lens.interface'; -// Committee application interfaces -export * from './committee-application.interface'; - // Public committee interfaces export * from './public-committee.interface'; diff --git a/packages/shared/src/interfaces/member.interface.ts b/packages/shared/src/interfaces/member.interface.ts index 33d9c23f5..d3ebdabc4 100644 --- a/packages/shared/src/interfaces/member.interface.ts +++ b/packages/shared/src/interfaces/member.interface.ts @@ -119,6 +119,28 @@ export interface CreateCommitteeMemberRequest { } | null; } +/** + * Raw form values from the member form dialog + * @description Typed shape of the FormGroup.getRawValue() output in MemberFormComponent + */ +export interface MemberFormValue { + first_name: string; + last_name: string; + email: string; + job_title: string; + linkedin_profile: string; + is_individual: boolean; + organization: string; + organization_url: string; + role: CommitteeMemberRole | ''; + voting_status: CommitteeMemberVotingStatus | ''; + appointed_by: CommitteeMemberAppointedBy | ''; + role_start: Date | null; + role_end: Date | null; + voting_status_start: Date | null; + voting_status_end: Date | null; +} + /** * State types for tracking member changes * @description Tracks the lifecycle state of a committee member during editing