From ada825d89cba11fa2bc52bd6d8a92c4c2ab30861 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 11 Mar 2026 22:59:06 -0700 Subject: [PATCH 01/48] feat(committees): add committee detail shell with core overview tab Rewrite committee-view component with PrimeNG tab shell and overview tab using only getCommitteeById data. No sub-resource API calls. - Add 6-tab layout: Overview, Members, Votes, Meetings, Surveys, Documents - Overview tab: stats row, channels card, configurations card, leadership card - Tab visibility: Members hidden when member_visibility=hidden, Votes hidden when voting disabled - Loading skeleton, error state with back navigation - Placeholder content for tabs coming in future PRs - Computed signals for categorySeverity, breadcrumbs, joinModeLabel, leadership dates LFXV2-1255 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 500 +++++++++++++++--- .../committee-view.component.scss | 51 -- .../committee-view.component.ts | 154 +++--- 3 files changed, 524 insertions(+), 181 deletions(-) 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 a9bd0a31e..19b5bd1c7 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 @@ -2,99 +2,463 @@
- + @if (loading()) { -
-
- -

Loading group details...

+
+ +
+
+
+
+
+
+
+
+
+ +
+ @for (_ of [1, 2, 3, 4]; track _) { +
+
+
+
+
+
+
+
+
+ } +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- } - - - @if (error()) { + } @else if (error()) { +
-
- -

Group Not Found

-

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

- +
+
+ +
+

Group Not Found

+

The group you're looking for doesn't exist, has been removed, or you may not have permission to view it.

+
- } - - - @if (committee()?.uid && !loading() && !error()) { + } @else if (committee()?.uid) { +
-
+
-
+

{{ committee()?.name }}

-

{{ committee()?.description }}

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

{{ committee()?.description }}

+ } +
@if (committee()?.category) { } - Created {{ formattedCreatedDate() }} + @if (committee()?.enable_voting) { + + Voting Enabled + + } + Created {{ formattedCreatedDate() }} @if (committee()?.updated_at) { - Last updated {{ formattedUpdatedDate() }} + · Updated {{ formattedUpdatedDate() }} }
- + @if (!isBoardMember()) { -
- +
+
}
- - -

Configurations

-
-
- Public - -
-
- Voting - -
-
- Business Email Required - -
-
- SSO Group - -
-
- Audit - -
-
-
- - - @if (committee()?.uid) { - - } + + + + Overview + @if (isMembersTabVisible()) { + Members + } + @if (isVotesTabVisible()) { + Votes + } + Meetings + Surveys + Documents + + + + + +
+ +
+ + +
+
+ +
+
+

{{ committee()?.total_members || 0 }}

+

Members

+
+
+
+ + + @if (committee()?.enable_voting) { + +
+
+ +
+
+

{{ committee()?.total_voting_reps || 0 }}

+

Voting Reps

+
+
+
+ } + + + +
+
+ +
+
+

{{ committee()?.public ? 'Public' : 'Private' }}

+

Visibility

+
+
+
+ + + +
+
+ +
+
+

{{ joinModeLabel() }}

+

Join Mode

+
+
+
+
+ + +
+ +
+ + +

Channels

+
+ + @if (committee()?.mailing_list; as mailingList) { +
+

Mailing List

+
+
+ +
+
+ @if (mailingList.url) { + + {{ mailingList.name }} + + + } @else { +

{{ mailingList.name }}

+ } + @if (mailingList.subscriber_count) { +

{{ mailingList.subscriber_count }} subscribers

+ } +
+
+
+ } @else { +
+

Mailing List

+
+
+ +
+

No mailing list connected

+
+
+ } + + + @if (committee()?.chat_channel; as chatChannel) { +
+

+ {{ chatChannel.platform === 'slack' ? 'Slack' : 'Discord' }} Channel +

+
+
+ +
+
+ @if (chatChannel.url) { + + {{ chatChannel.name }} + + + } @else { +

{{ chatChannel.name }}

+ } +

{{ chatChannel.platform }}

+
+
+
+ } @else { +
+

Chat Channel

+
+
+ +
+

No chat channel connected

+
+
+ } + + + @if (committee()?.website) { +
+

Website

+ +
+ } +
+
+ + + @if (canManageConfigurations()) { + +

Configurations

+
+
+ Public + +
+
+ Voting + +
+
+ Business Email + +
+
+ Audit + +
+
+ Join Mode + +
+
+ SSO Group + +
+
+
+ } +
+ + +
+ + +

Leadership

+
+ +
+

Chair

+ @if (chair(); as c) { +
+
+ {{ c.first_name?.charAt(0) }}{{ c.last_name?.charAt(0) }} +
+
+

{{ c.first_name }} {{ c.last_name }}

+ @if (c.organization) { +

{{ c.organization }}

+ } + @if (chairElectedDate()) { +

Since {{ chairElectedDate() }}

+ } +
+
+ } @else { +
+
+ +
+

Vacant

+
+ } +
+ + +
+

Co-Chair

+ @if (coChair(); as cc) { +
+
+ {{ cc.first_name?.charAt(0) }}{{ cc.last_name?.charAt(0) }} +
+
+

{{ cc.first_name }} {{ cc.last_name }}

+ @if (cc.organization) { +

{{ cc.organization }}

+ } + @if (coChairElectedDate()) { +

Since {{ coChairElectedDate() }}

+ } +
+
+ } @else { +
+
+ +
+

Vacant

+
+ } +
+
+
+
+
+
+
+ + + @if (isMembersTabVisible()) { + +
+
+ +

Members

+

Coming in a future PR

+
+
+
+ } + + + @if (isVotesTabVisible()) { + +
+
+ +

Votes

+

Coming in a future PR

+
+
+
+ } + + + +
+
+ +

Meetings

+

Coming in a future PR

+
+
+
+ + + +
+
+ +

Surveys

+

Coming in a future PR

+
+
+
+ + + +
+
+ +

Documents

+

Coming in a future PR

+
+
+
+
+
} diff --git a/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.scss b/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.scss index 8361530ab..df18259a7 100644 --- a/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.scss +++ b/apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.scss @@ -1,57 +1,6 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -// Committee View Component Styles -// Minimal styles as we're using Tailwind CSS for most styling - :host { display: block; } - -// Ensure proper spacing for the loading spinner -.fa-spinner-third { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -// Custom table styling for subcommittees if needed -.subcommittees-table { - .p-datatable-tbody > tr > td { - @apply py-3; - } -} - -// Settings status indicators -.settings-grid { - .status-indicator { - @apply inline-flex items-center; - - i { - @apply text-sm mr-1; - } - - &.enabled { - @apply text-emerald-600; - - i { - @apply text-emerald-500; - } - } - - &.disabled { - @apply text-red-600; - - i { - @apply text-red-500; - } - } - } -} 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 42f2bed2b..42711e676 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 @@ -1,72 +1,102 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; +import { Component, computed, inject, signal, Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; import { ButtonComponent } from '@components/button/button.component'; import { CardComponent } from '@components/card/card.component'; import { TagComponent } from '@components/tag/tag.component'; -import { Committee, CommitteeMember, getCommitteeCategorySeverity, TagSeverity } from '@lfx-one/shared'; +import { COMMITTEE_LABEL } from '@lfx-one/shared/constants'; +import { Committee, getCommitteeCategorySeverity, TagSeverity } from '@lfx-one/shared'; import { CommitteeService } from '@services/committee.service'; import { PersonaService } from '@services/persona.service'; import { MenuItem, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; -import { BehaviorSubject, catchError, combineLatest, of, switchMap, throwError } from 'rxjs'; - -import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; +import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; +import { BehaviorSubject, catchError, combineLatest, finalize, of, switchMap } from 'rxjs'; @Component({ selector: 'lfx-committee-view', - imports: [BreadcrumbComponent, CardComponent, ButtonComponent, TagComponent, CommitteeMembersComponent, ConfirmDialogModule], + imports: [BreadcrumbComponent, CardComponent, ButtonComponent, TagComponent, ConfirmDialogModule, RouterLink, Tabs, TabList, Tab, TabPanels, TabPanel], templateUrl: './committee-view.component.html', styleUrl: './committee-view.component.scss', }) export class CommitteeViewComponent { + // -- Injections -- private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly committeeService = inject(CommitteeService); private readonly messageService = inject(MessageService); private readonly personaService = inject(PersonaService); - public committee: Signal; - public members: WritableSignal; - public membersLoading: WritableSignal; - public loading: WritableSignal; - public error: WritableSignal; - public formattedCreatedDate: Signal; - public formattedUpdatedDate: Signal; - public refresh: BehaviorSubject; - public categorySeverity: Signal; - public breadcrumbItems: Signal; - public isBoardMember: Signal; - - public constructor() { - this.error = signal(false); - this.refresh = new BehaviorSubject(undefined); - this.members = signal([]); - this.membersLoading = signal(true); - this.loading = signal(true); - this.committee = this.initializeCommittee(); - this.formattedCreatedDate = this.initializeFormattedCreatedDate(); - this.formattedUpdatedDate = this.initializeFormattedUpdatedDate(); - this.categorySeverity = computed(() => { - const category = this.committee()?.category; - return getCommitteeCategorySeverity(category || ''); - }); - this.breadcrumbItems = computed(() => [{ label: 'Groups', routerLink: ['/groups'] }, { label: this.committee()?.name || '' }]); - this.isBoardMember = computed(() => this.personaService.currentPersona() === 'board-member'); - } + // -- Label constants -- + protected readonly committeeLabel = COMMITTEE_LABEL; + + // -- Tab state -- + public activeTab = signal('overview'); + + // -- Writable signals -- + public loading = signal(true); + public error = signal(false); + public refresh = new BehaviorSubject(undefined); + + // -- Computed / toSignal -- + public committee: Signal = this.initializeCommittee(); + public formattedCreatedDate: Signal = this.initializeFormattedCreatedDate(); + public formattedUpdatedDate: Signal = this.initializeFormattedUpdatedDate(); + + public categorySeverity: Signal = computed(() => { + const category = this.committee()?.category; + return getCommitteeCategorySeverity(category || ''); + }); + public breadcrumbItems: Signal = computed(() => [{ label: 'Groups', routerLink: ['/groups'] }, { label: this.committee()?.name || '' }]); + + public isBoardMember: Signal = computed(() => this.personaService.currentPersona() === 'board-member'); + public isMaintainer: Signal = computed(() => this.personaService.currentPersona() === 'maintainer'); + public canManageConfigurations: Signal = computed(() => this.isMaintainer() || (!!this.committee()?.writer && !this.isBoardMember())); + + // -- Tab visibility signals -- + public isMembersTabVisible: Signal = computed(() => this.committee()?.member_visibility !== 'hidden' || this.canManageConfigurations()); + public isVotesTabVisible: Signal = computed(() => !!this.committee()?.enable_voting); + + // -- Leadership signals -- + public chair: Signal = computed(() => this.committee()?.chair || null); + public coChair: Signal = computed(() => this.committee()?.co_chair || null); + public hasChair: Signal = computed(() => !!this.chair()); + public hasCoChair: Signal = computed(() => !!this.coChair()); + public chairElectedDate: Signal = this.initializeChairElectedDate(); + public coChairElectedDate: Signal = this.initializeCoChairElectedDate(); + + // -- Configuration label signals -- + public joinModeLabel: Signal = computed(() => { + switch (this.committee()?.join_mode) { + case 'open': + return 'Open'; + case 'invite-only': + return 'Invite Only'; + case 'apply': + return 'Apply to Join'; + case 'closed': + return 'Closed'; + default: + return 'Closed'; + } + }); + + // -- Public methods -- public goBack(): void { this.router.navigate(['/', 'groups']); } - public refreshMembers(): void { + public refreshCommittee(): void { + this.loading.set(true); this.refresh.next(); } + // -- Private initializer functions -- private initializeCommittee(): Signal { return toSignal( combineLatest([this.route.paramMap, this.refresh]).pipe( @@ -74,36 +104,24 @@ export class CommitteeViewComponent { const committeeId = params?.get('id'); if (!committeeId) { this.error.set(true); + this.loading.set(false); return of(null); } - const committeeQuery = this.committeeService.getCommittee(committeeId).pipe( + this.error.set(false); + this.loading.set(true); + + return this.committeeService.getCommittee(committeeId).pipe( catchError(() => { - console.error('Failed to load committee'); + this.error.set(true); this.messageService.add({ severity: 'error', summary: 'Error', - detail: 'Failed to load committee', + detail: 'Failed to load group details', }); - this.router.navigate(['/', 'groups']); - return throwError(() => new Error('Failed to load committee')); - }) - ); - - const membersQuery = this.committeeService.getCommitteeMembers(committeeId).pipe( - catchError(() => { - console.error('Failed to load committee members'); - return of([]); - }) - ); - - return combineLatest([committeeQuery, membersQuery]).pipe( - switchMap(([committee, members]) => { - this.members.set(members); - this.loading.set(false); - this.membersLoading.set(false); - return of(committee); - }) + return of(null); + }), + finalize(() => this.loading.set(false)) ); }) ), @@ -120,8 +138,6 @@ export class CommitteeViewComponent { year: 'numeric', month: 'short', day: 'numeric', - hour: '2-digit', - minute: '2-digit', }); }); } @@ -135,9 +151,23 @@ export class CommitteeViewComponent { year: 'numeric', month: 'short', day: 'numeric', - hour: '2-digit', - minute: '2-digit', }); }); } + + private initializeChairElectedDate(): Signal { + return computed(() => { + const c = this.chair(); + if (!c?.elected_date) return ''; + return new Date(c.elected_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); + }); + } + + private initializeCoChairElectedDate(): Signal { + return computed(() => { + const c = this.coChair(); + if (!c?.elected_date) return ''; + return new Date(c.elected_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); + }); + } } From f2ed216b04d766c5c894f003a39169171aa2773f Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 11 Mar 2026 23:46:27 -0700 Subject: [PATCH 02/48] feat(committees): add overview tab type-specific cards and BFF sub-resource endpoints - Add 10 BFF sub-resource endpoints (votes, resolutions, activity, contributors, deliverables, discussions, events, campaigns, engagement, budget) with controller, routes, and service methods - Add frontend committee service methods for all sub-resource endpoints - Extend overview tab with behavioral-class-specific dashboard cards: - Governance: open votes, budget summary, resolutions - Oversight/Working Group: activity feed, top contributors - Working Group: deliverables & milestones - Special Interest Group: discussion threads, upcoming events - Ambassador Program: outreach campaigns, engagement metrics - Add sidebar cards: org breakdown, role breakdown - Add leadership edit UI with assign/remove dialog component - Wire loadGroupTypeData into observable chain for proper cancellation - Use real member data for stats (totalMembers, activeVoters, orgCount) Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 523 +++++++++++++++++- .../committee-view.component.ts | 271 ++++++++- .../assign-leadership-dialog.component.html | 79 +++ .../assign-leadership-dialog.component.ts | 154 ++++++ .../app/shared/services/committee.service.ts | 58 +- .../controllers/committee.controller.ts | 240 ++++++++ .../src/server/routes/committees.route.ts | 12 + .../src/server/services/committee.service.ts | 192 +++++++ 8 files changed, 1498 insertions(+), 31 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.html create mode 100644 apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.ts 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 19b5bd1c7..ea83c2436 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 @@ -4,7 +4,7 @@
@if (loading()) { -
+
@@ -53,14 +53,20 @@
} @else if (error()) { -
+

Group Not Found

The group you're looking for doesn't exist, has been removed, or you may not have permission to view it.

- +
} @else if (committee()?.uid) { @@ -98,7 +104,12 @@

{{ committee()?.name }}

@if (!isBoardMember()) {
- +
}
@@ -131,7 +142,7 @@

{{ committee()?.name }}

-

{{ committee()?.total_members || 0 }}

+

{{ totalMembers() }}

Members

@@ -145,22 +156,22 @@

{{ committee()?.name }}

-

{{ committee()?.total_voting_reps || 0 }}

+

{{ activeVoters() }}

Voting Reps

} - +
- +
-

{{ committee()?.public ? 'Public' : 'Private' }}

-

Visibility

+

{{ orgCount() }}

+

Organizations

@@ -181,9 +192,398 @@

{{ committee()?.name }}

- +
- + + @if (isGovernanceClass()) { + + @if (openVotes().length > 0) { + +
+

Open Votes

+ {{ openVotes().length }} pending +
+
+ @for (vote of openVotes(); track vote.uid) { +
+
+
+

{{ vote.title }}

+

Proposed by {{ vote.created_by }}

+
+ +
+ +
+
+ {{ vote.votes_for + vote.votes_against + vote.votes_abstain }} of {{ vote.total_eligible }} votes cast + Deadline: {{ vote.deadline | date: 'MMM d, y' }} +
+
+
+
+
+
+
+ {{ vote.votes_for }} for + {{ vote.votes_against }} against + {{ vote.votes_abstain }} abstain +
+
+
+ } +
+
+ } @else { + + +
+ +

No Open Votes

+

All votes have been resolved. Check back before your next board meeting.

+
+
+ } + + + @if (budgetSummary(); as budget) { + +
+

Budget Summary — FY{{ budget.fiscal_year }}

+ @if (!isBoardMember()) { + + } +
+ +
+
+ {{ budget.total_budget ? ((budget.spent / budget.total_budget) * 100 | number: '1.0-0') : 0 }}% spent + ${{ budget.total_budget / 1000000 | number: '1.1-1' }}M total +
+
+
+
+
+
+ + + Spent: ${{ budget.spent / 1000 | number: '1.0-0' }}K + + + + Committed: ${{ budget.committed / 1000 | number: '1.0-0' }}K + + + + Remaining: ${{ budget.remaining / 1000 | number: '1.0-0' }}K + +
+
+ +
+ @for (cat of budget.categories; track cat.name) { +
+ {{ cat.name }} +
+
+
+
+ + ${{ cat.spent / 1000 | number: '1.0-0' }}K / ${{ cat.allocated / 1000 | number: '1.0-0' }}K + +
+
+ } +
+
+ } + + + @if (recentResolutions().length > 0) { + +

Recent Resolutions

+
+ @for (res of recentResolutions(); track res.uid) { +
+
+

{{ res.title }}

+

{{ res.date | date: 'MMM d, y' }} · {{ res.votes_for }}-{{ res.votes_against }}

+
+ +
+ } +
+
+ } + } + + + @if (isWorkingGroup() || isOversightCommittee()) { + + @if (recentActivity().length > 0) { + +
+

Recent Activity

+ Last 48 hours +
+
+ @for (activity of recentActivity(); track activity.uid) { +
+
+ +
+
+

{{ activity.title }}

+
+ {{ activity.author }} + · + {{ activity.repo }} + · + {{ activity.timestamp | date: 'MMM d, h:mm a' }} +
+
+
+ } +
+
+ } @else { + + +
+ +

No Recent Activity

+

Here is how to get involved: check open issues, join a meeting, or submit a PR.

+
+
+ } + + + @if (topContributors().length > 0) { + +
+

Top Contributors

+ Last 30 days +
+
+ @for (contrib of topContributors(); track contrib.name; let i = $index) { +
+ #{{ i + 1 }} +
+ + {{ contrib.name.split(' ')[0]?.charAt(0) }}{{ contrib.name.split(' ')[1]?.charAt(0) }} + +
+
+

{{ contrib.name }}

+

{{ contrib.org }}

+
+
+ {{ contrib.commits }} + {{ contrib.prs }} + {{ contrib.reviews }} +
+
+ } +
+
+ } + } + + + @if (isWorkingGroup() && deliverables().length > 0) { + +
+

Deliverables & Milestones

+ {{ deliverables().length }} items +
+
+ @for (item of deliverables(); track item.uid) { +
+
+
+

{{ item.title }}

+

Owner: {{ item.owner }} · Due {{ item.due_date | date: 'MMM d, y' }}

+
+ +
+ +
+
+
+
+ {{ item.progress }}% +
+
+ } +
+
+ } + + + @if (isSpecialInterestGroup() && discussionThreads().length > 0) { + +
+

Active Discussions

+ {{ discussionThreads().length }} threads +
+
+ @for (thread of discussionThreads(); track thread.uid) { +
+
+ +
+
+

{{ thread.title }}

+
+ {{ thread.author }} + · + {{ thread.replies }} replies + · + {{ thread.last_activity | date: 'MMM d, h:mm a' }} +
+ @if (thread.tags?.length) { +
+ @for (tag of thread.tags; track tag) { + {{ tag }} + } +
+ } +
+
+ } +
+
+ } + + + @if (isSpecialInterestGroup() && upcomingEvents().length > 0) { + +
+

Upcoming Events

+ {{ upcomingEvents().length }} upcoming +
+
+ @for (evt of upcomingEvents(); track evt.uid) { +
+
+
+

{{ evt.title }}

+

{{ evt.date | date: 'EEE, MMM d · h:mm a' }}

+

{{ evt.speaker }}

+
+
+ + {{ evt.attendees }} registered +
+
+
+ } +
+
+ } + + + @if (isAmbassadorProgram() && outreachCampaigns().length > 0) { + +
+

Outreach Campaigns

+ {{ outreachCampaigns().length }} campaigns +
+
+ @for (campaign of outreachCampaigns(); track campaign.uid) { +
+
+ +
+
+

{{ campaign.title }}

+
+ @if (campaign.status === 'active') { + + {{ campaign.reach | number }} reached · {{ campaign.conversions }} conversions ({{ campaign.conversion_rate }}%) + + } @else { + Upcoming + } +
+
+ +
+ } +
+
+ } + + + @if (isAmbassadorProgram() && engagementMetrics(); as metrics) { + +

Engagement Metrics

+
+
+

{{ metrics.total_reach | number }}

+

Total Reach

+
+
+

+{{ metrics.new_members_30d }}

+

New Members (30d)

+
+
+

{{ metrics.event_attendance | number }}

+

Event Attendance

+
+
+

{{ metrics.newsletter_open_rate }}%

+

Newsletter Open Rate

+
+
+
+
+ + {{ metrics.social_impressions | number }} social impressions +
+
+ + {{ metrics.ambassador_count }} ambassadors +
+
+
+ } + +

Channels

@@ -331,11 +731,26 @@

-

Leadership

+
+

Leadership

+ @if (canManageConfigurations() && !hasChair() && !hasCoChair()) { + + + } +
@@ -354,13 +769,33 @@

Since {{ chairElectedDate() }}

}

+ @if (canManageConfigurations()) { + + }
} @else {
-

Vacant

+
+

Vacant

+ @if (canManageConfigurations()) { + + } +
}

@@ -382,18 +817,74 @@

Since {{ coChairElectedDate() }}

}

+ @if (canManageConfigurations()) { + + }
} @else {
-

Vacant

+
+

Vacant

+ @if (canManageConfigurations()) { + + } +
}
+ + + @if (uniqueOrganizations().length > 0) { + +

Organizations

+
+ @for (org of uniqueOrganizations().slice(0, 8); track org) { +
+ {{ org }} + + {{ getMembersCountByOrg(org) }} + {{ getMembersCountByOrg(org) === 1 ? 'member' : 'members' }} + +
+ } + @if (uniqueOrganizations().length > 8) { +

+ {{ uniqueOrganizations().length - 8 }} more organizations

+ } +
+
+ } + + + @if (committee()?.enable_voting && roleBreakdown().length > 0) { + +

Roles

+
+ @for (role of roleBreakdown(); track role.name) { +
+ {{ role.name }} + {{ role.count }} +
+ } +
+
+ }
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 42711e676..e0811d24b 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 @@ -1,25 +1,77 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject, signal, Signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; import { ButtonComponent } from '@components/button/button.component'; import { CardComponent } from '@components/card/card.component'; import { TagComponent } from '@components/tag/tag.component'; import { COMMITTEE_LABEL } from '@lfx-one/shared/constants'; -import { Committee, getCommitteeCategorySeverity, TagSeverity } from '@lfx-one/shared'; +import { + getGroupBehavioralClass, + isGovernanceClass, + isCollaborationClass, + isGoverningBoard, + isOversightCommittee, + isWorkingGroup, + isSpecialInterestGroup, + isAmbassadorProgram, + isOtherClass, +} from '@lfx-one/shared/constants'; +import { + Committee, + CommitteeActivity, + CommitteeBudgetSummary, + CommitteeContributor, + CommitteeDeliverable, + CommitteeDiscussionThread, + CommitteeEngagementMetrics, + CommitteeEvent, + CommitteeLeadership, + CommitteeMember, + CommitteeOutreachCampaign, + CommitteeResolution, + CommitteeVote, + getCommitteeCategorySeverity, + GroupBehavioralClass, + LeadershipRole, + TagSeverity, +} from '@lfx-one/shared'; +import { CommitteeMemberVotingStatus } from '@lfx-one/shared/enums'; import { CommitteeService } from '@services/committee.service'; import { PersonaService } from '@services/persona.service'; -import { MenuItem, MessageService } from 'primeng/api'; +import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; -import { BehaviorSubject, catchError, combineLatest, finalize, of, switchMap } from 'rxjs'; +import { BehaviorSubject, catchError, combineLatest, EMPTY, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; + +import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; @Component({ selector: 'lfx-committee-view', - imports: [BreadcrumbComponent, CardComponent, ButtonComponent, TagComponent, ConfirmDialogModule, RouterLink, Tabs, TabList, Tab, TabPanels, TabPanel], + imports: [ + BreadcrumbComponent, + CardComponent, + ButtonComponent, + TagComponent, + RouterLink, + ConfirmDialogModule, + DynamicDialogModule, + TooltipModule, + Tabs, + TabList, + Tab, + TabPanels, + TabPanel, + DatePipe, + DecimalPipe, + ], + providers: [ConfirmationService, DialogService], templateUrl: './committee-view.component.html', styleUrl: './committee-view.component.scss', }) @@ -28,6 +80,7 @@ export class CommitteeViewComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly committeeService = inject(CommitteeService); + private readonly dialogService = inject(DialogService); private readonly messageService = inject(MessageService); private readonly personaService = inject(PersonaService); @@ -42,8 +95,24 @@ export class CommitteeViewComponent { public error = signal(false); public refresh = new BehaviorSubject(undefined); + // Sub-resource writable signals + public members: WritableSignal = signal([]); + public openVotes: WritableSignal = signal([]); + public budgetSummary: WritableSignal = signal(null); + public recentResolutions: WritableSignal = signal([]); + public recentActivity: WritableSignal = signal([]); + public topContributors: WritableSignal = signal([]); + public deliverables: WritableSignal = signal([]); + public discussionThreads: WritableSignal = signal([]); + public upcomingEvents: WritableSignal = signal([]); + public outreachCampaigns: WritableSignal = signal([]); + public engagementMetrics: WritableSignal = signal(null); + + // -- Committee (writable so leadership updates apply instantly) -- + public committeeSignal: WritableSignal = signal(null); + public committee: Signal = this.committeeSignal.asReadonly(); + // -- Computed / toSignal -- - public committee: Signal = this.initializeCommittee(); public formattedCreatedDate: Signal = this.initializeFormattedCreatedDate(); public formattedUpdatedDate: Signal = this.initializeFormattedUpdatedDate(); @@ -62,6 +131,43 @@ export class CommitteeViewComponent { public isMembersTabVisible: Signal = computed(() => this.committee()?.member_visibility !== 'hidden' || this.canManageConfigurations()); public isVotesTabVisible: Signal = computed(() => !!this.committee()?.enable_voting); + // -- Behavioral class signals -- + public behavioralClass: Signal = computed(() => getGroupBehavioralClass(this.committee()?.category)); + public isGovernanceClass: Signal = computed(() => isGovernanceClass(this.committee()?.category)); + public isCollaborationClass: Signal = computed(() => isCollaborationClass(this.committee()?.category)); + public isGoverningBoard: Signal = computed(() => isGoverningBoard(this.committee()?.category)); + public isOversightCommittee: Signal = computed(() => isOversightCommittee(this.committee()?.category)); + public isWorkingGroup: Signal = computed(() => isWorkingGroup(this.committee()?.category)); + public isSpecialInterestGroup: Signal = computed(() => isSpecialInterestGroup(this.committee()?.category)); + public isAmbassadorProgram: Signal = computed(() => isAmbassadorProgram(this.committee()?.category)); + public isOtherClass: Signal = computed(() => isOtherClass(this.committee()?.category)); + + // -- Dashboard stat signals -- + public totalMembers: Signal = computed(() => this.members().length); + public activeVoters: Signal = computed( + () => + this.members().filter( + (m) => m.voting?.status === CommitteeMemberVotingStatus.VOTING_REP || m.voting?.status === CommitteeMemberVotingStatus.ALTERNATE_VOTING_REP + ).length + ); + public uniqueOrganizations: Signal = computed(() => { + const orgs = this.members() + .map((m) => m.organization?.name) + .filter((name): name is string => !!name); + return [...new Set(orgs)]; + }); + public orgCount: Signal = computed(() => this.uniqueOrganizations().length); + public roleBreakdown: Signal<{ name: string; count: number }[]> = computed(() => { + const roleCounts: Record = {}; + this.members().forEach((m) => { + const role = m.role?.name || 'Member'; + roleCounts[role] = (roleCounts[role] || 0) + 1; + }); + return Object.entries(roleCounts) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count); + }); + // -- Leadership signals -- public chair: Signal = computed(() => this.committee()?.chair || null); public coChair: Signal = computed(() => this.committee()?.co_chair || null); @@ -86,6 +192,10 @@ export class CommitteeViewComponent { } }); + public constructor() { + this.initializeCommittee(); + } + // -- Public methods -- public goBack(): void { this.router.navigate(['/', 'groups']); @@ -96,10 +206,50 @@ export class CommitteeViewComponent { this.refresh.next(); } + public getMembersCountByOrg(org: string): number { + return this.members().filter((m) => m.organization?.name === org).length; + } + + public openAssignLeadership(role: LeadershipRole): void { + const committee = this.committee(); + if (!committee) return; + + const currentLeader = role === 'chair' ? this.chair() : this.coChair(); + const roleLabel = role === 'chair' ? 'Assign Chair' : 'Assign Co-Chair'; + + const dialogRef = this.dialogService.open(AssignLeadershipDialogComponent, { + header: roleLabel, + width: '500px', + modal: true, + closable: true, + data: { + role, + committee, + members: this.members(), + currentLeader: currentLeader ?? null, + }, + }) as DynamicDialogRef; + + dialogRef.onClose.pipe(take(1)).subscribe((result: { role: LeadershipRole; leadership: CommitteeLeadership | null } | undefined) => { + if (result) { + const current = this.committee(); + if (current) { + const updated = { ...current }; + if (result.role === 'chair') { + updated.chair = result.leadership; + } else { + updated.co_chair = result.leadership; + } + this.committeeSignal.set(updated); + } + } + }); + } + // -- Private initializer functions -- - private initializeCommittee(): Signal { - return toSignal( - combineLatest([this.route.paramMap, this.refresh]).pipe( + private initializeCommittee(): void { + combineLatest([this.route.paramMap, this.refresh]) + .pipe( switchMap(([params]) => { const committeeId = params?.get('id'); if (!committeeId) { @@ -111,7 +261,7 @@ export class CommitteeViewComponent { this.error.set(false); this.loading.set(true); - return this.committeeService.getCommittee(committeeId).pipe( + const committeeQuery = this.committeeService.getCommittee(committeeId).pipe( catchError(() => { this.error.set(true); this.messageService.add({ @@ -120,13 +270,106 @@ export class CommitteeViewComponent { detail: 'Failed to load group details', }); return of(null); + }) + ); + + const membersQuery = this.committeeService.getCommitteeMembers(committeeId).pipe(catchError(() => of([]))); + + return combineLatest([committeeQuery, membersQuery]).pipe( + switchMap(([committee, members]) => { + this.members.set(Array.isArray(members) ? members : []); + + if (committee) { + return this.loadGroupTypeData$(committeeId, committee).pipe( + tap(() => this.committeeSignal.set(committee)) + ); + } + + this.committeeSignal.set(committee); + return of(null); }), finalize(() => this.loading.set(false)) ); + }), + takeUntilDestroyed() + ) + .subscribe(); + } + + private loadGroupTypeData$(committeeId: string, committee: Committee): Observable { + const cls = getGroupBehavioralClass(committee.category); + + if (cls === 'governing-board') { + return forkJoin([ + this.committeeService.getCommitteeVotes(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeResolutions(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeBudget(committeeId).pipe(catchError(() => of(null))), + ]).pipe( + tap(([votes, resolutions, budget]) => { + this.openVotes.set(votes); + this.recentResolutions.set(resolutions); + this.budgetSummary.set(budget); }) - ), - { initialValue: null } - ); + ); + } + + if (cls === 'oversight-committee') { + return forkJoin([ + this.committeeService.getCommitteeVotes(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeResolutions(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeActivity(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeContributors(committeeId).pipe(catchError(() => of([]))), + ]).pipe( + tap(([votes, resolutions, activity, contributors]) => { + this.openVotes.set(votes); + this.recentResolutions.set(resolutions); + this.recentActivity.set(activity); + this.topContributors.set(contributors); + }) + ); + } + + if (cls === 'working-group') { + return forkJoin([ + this.committeeService.getCommitteeVotes(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeActivity(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeContributors(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeDeliverables(committeeId).pipe(catchError(() => of([]))), + ]).pipe( + tap(([votes, activity, contributors, dels]) => { + this.openVotes.set(votes); + this.recentActivity.set(activity); + this.topContributors.set(contributors); + this.deliverables.set(dels); + }) + ); + } + + if (cls === 'special-interest-group') { + return forkJoin([ + this.committeeService.getCommitteeDiscussions(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeEvents(committeeId).pipe(catchError(() => of([]))), + ]).pipe( + tap(([discussions, events]) => { + this.discussionThreads.set(discussions); + this.upcomingEvents.set(events); + }) + ); + } + + if (cls === 'ambassador-program') { + return forkJoin([ + this.committeeService.getCommitteeCampaigns(committeeId).pipe(catchError(() => of([]))), + this.committeeService.getCommitteeEngagement(committeeId).pipe(catchError(() => of(null))), + ]).pipe( + tap(([campaigns, engagement]) => { + this.outreachCampaigns.set(campaigns); + this.engagementMetrics.set(engagement); + }) + ); + } + + return EMPTY; } private initializeFormattedCreatedDate(): Signal { 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..040bfed91 --- /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..377fb0174 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.ts @@ -0,0 +1,154 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, signal, 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 { 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: Signal<{ 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; + + 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 }, + }); + } + 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()}`, + }); + }, + }); + } + + 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(): Signal<{ label: string; value: string }[]> { + return computed(() => + 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/shared/services/committee.service.ts b/apps/lfx-one/src/app/shared/services/committee.service.ts index 0f360e655..203282a1c 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,22 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable, signal, WritableSignal } from '@angular/core'; -import { Committee, CommitteeMember, CreateCommitteeMemberRequest, QueryServiceCountResponse } from '@lfx-one/shared/interfaces'; +import { + Committee, + CommitteeActivity, + CommitteeBudgetSummary, + CommitteeContributor, + CommitteeDeliverable, + CommitteeDiscussionThread, + CommitteeEngagementMetrics, + CommitteeEvent, + CommitteeMember, + CommitteeOutreachCampaign, + CommitteeResolution, + CommitteeVote, + CreateCommitteeMemberRequest, + QueryServiceCountResponse, +} from '@lfx-one/shared/interfaces'; import { catchError, map, Observable, of, take, tap, throwError } from 'rxjs'; @Injectable({ @@ -88,4 +103,45 @@ export class CommitteeService { public deleteCommitteeMember(committeeId: string, memberId: string): Observable { return this.http.delete(`/api/committees/${committeeId}/members/${memberId}`).pipe(take(1)); } + + // Dashboard sub-resource methods + public getCommitteeVotes(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/votes`).pipe(catchError(() => of([]))); + } + + public getCommitteeResolutions(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/resolutions`).pipe(catchError(() => of([]))); + } + + public getCommitteeActivity(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/activity`).pipe(catchError(() => of([]))); + } + + public getCommitteeContributors(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/contributors`).pipe(catchError(() => of([]))); + } + + public getCommitteeDeliverables(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/deliverables`).pipe(catchError(() => of([]))); + } + + public getCommitteeDiscussions(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/discussions`).pipe(catchError(() => of([]))); + } + + public getCommitteeEvents(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/events`).pipe(catchError(() => of([]))); + } + + public getCommitteeCampaigns(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/campaigns`).pipe(catchError(() => of([]))); + } + + public getCommitteeEngagement(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/engagement`).pipe(catchError(() => of({} as CommitteeEngagementMetrics))); + } + + public getCommitteeBudget(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/budget`).pipe(catchError(() => of(null))); + } } diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index ee0ea0018..1c39dc2a5 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -463,4 +463,244 @@ export class CommitteeController { next(error); } } + + /** + * GET /committees/:id/votes + */ + public async getCommitteeVotes(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_votes', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_votes', { committee_id: committeeId }); + try { + const votes = await this.committeeService.getCommitteeVotes(req, committeeId); + logger.success(req, 'get_committee_votes', startTime, { vote_count: votes.length }); + res.json(votes); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/resolutions + */ + public async getCommitteeResolutions(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_resolutions', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_resolutions', { committee_id: committeeId }); + try { + const resolutions = await this.committeeService.getCommitteeResolutions(req, committeeId); + logger.success(req, 'get_committee_resolutions', startTime, { resolution_count: resolutions.length }); + res.json(resolutions); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/activity + */ + public async getCommitteeActivity(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_activity', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_activity', { committee_id: committeeId }); + try { + const activity = await this.committeeService.getCommitteeActivity(req, committeeId); + logger.success(req, 'get_committee_activity', startTime, { activity_count: activity.length }); + res.json(activity); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/contributors + */ + public async getCommitteeContributors(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_contributors', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_contributors', { committee_id: committeeId }); + try { + const contributors = await this.committeeService.getCommitteeContributors(req, committeeId); + logger.success(req, 'get_committee_contributors', startTime, { contributor_count: contributors.length }); + res.json(contributors); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/deliverables + */ + public async getCommitteeDeliverables(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_deliverables', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_deliverables', { committee_id: committeeId }); + try { + const deliverables = await this.committeeService.getCommitteeDeliverables(req, committeeId); + logger.success(req, 'get_committee_deliverables', startTime, { deliverable_count: deliverables.length }); + res.json(deliverables); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/discussions + */ + public async getCommitteeDiscussions(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_discussions', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_discussions', { committee_id: committeeId }); + try { + const discussions = await this.committeeService.getCommitteeDiscussions(req, committeeId); + logger.success(req, 'get_committee_discussions', startTime, { discussion_count: discussions.length }); + res.json(discussions); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/events + */ + public async getCommitteeEvents(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_events', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_events', { committee_id: committeeId }); + try { + const events = await this.committeeService.getCommitteeEvents(req, committeeId); + logger.success(req, 'get_committee_events', startTime, { event_count: events.length }); + res.json(events); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/campaigns + */ + public async getCommitteeCampaigns(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_campaigns', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_campaigns', { committee_id: committeeId }); + try { + const campaigns = await this.committeeService.getCommitteeCampaigns(req, committeeId); + logger.success(req, 'get_committee_campaigns', startTime, { campaign_count: campaigns.length }); + res.json(campaigns); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/engagement + */ + public async getCommitteeEngagement(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_engagement', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_engagement', { committee_id: committeeId }); + try { + const engagement = await this.committeeService.getCommitteeEngagement(req, committeeId); + logger.success(req, 'get_committee_engagement', startTime, { has_engagement: !!engagement }); + res.json(engagement); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/budget + */ + public async getCommitteeBudget(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_budget', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + const startTime = logger.startOperation(req, 'get_committee_budget', { committee_id: committeeId }); + try { + const budget = await this.committeeService.getCommitteeBudget(req, committeeId); + logger.success(req, 'get_committee_budget', startTime, { has_budget: !!budget }); + res.json(budget); + } catch (error) { + 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 85985877d..f2359c1b1 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -24,4 +24,16 @@ 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)); +// Dashboard sub-resource routes +router.get('/:id/votes', (req, res, next) => committeeController.getCommitteeVotes(req, res, next)); +router.get('/:id/resolutions', (req, res, next) => committeeController.getCommitteeResolutions(req, res, next)); +router.get('/:id/activity', (req, res, next) => committeeController.getCommitteeActivity(req, res, next)); +router.get('/:id/contributors', (req, res, next) => committeeController.getCommitteeContributors(req, res, next)); +router.get('/:id/deliverables', (req, res, next) => committeeController.getCommitteeDeliverables(req, res, next)); +router.get('/:id/discussions', (req, res, next) => committeeController.getCommitteeDiscussions(req, res, next)); +router.get('/:id/events', (req, res, next) => committeeController.getCommitteeEvents(req, res, next)); +router.get('/:id/campaigns', (req, res, next) => committeeController.getCommitteeCampaigns(req, res, next)); +router.get('/:id/engagement', (req, res, next) => committeeController.getCommitteeEngagement(req, res, next)); +router.get('/:id/budget', (req, res, next) => committeeController.getCommitteeBudget(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 75d14b5da..58efe0e39 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -19,6 +19,12 @@ import { AccessCheckService } from './access-check.service'; import { ETagService } from './etag.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; +function getVoteStatus(status: string): 'open' | 'closed' | 'cancelled' { + if (status === 'ended') return 'closed'; + if (status === 'cancelled') return 'cancelled'; + return 'open'; +} + /** * Service for handling committee business logic */ @@ -355,6 +361,192 @@ 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>( + req, + 'LFX_V2_SERVICE', + '/query/resources', + 'GET', + { + type: 'vote', + parent: `project:${projectUid}`, + page_size: 100, + } + ); + + return voteResources + .filter((r) => r.data.committee_uid === committeeId) + .map((r) => ({ + uid: r.data.uid, + title: r.data.name, + status: getVoteStatus(r.data.status), + deadline: r.data.end_time, + votesFor: r.data.num_response_received ?? 0, + votesAgainst: 0, + votesAbstain: 0, + totalEligible: r.data.total_voting_request_invitations ?? 0, + created_by: '', + })); + } catch { + logger.warning(req, 'get_committee_votes', 'Failed to fetch committee votes, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + public async getCommitteeResolutions(req: Request, committeeId: string): Promise { + try { + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + type: 'committee_resolution', + tags: `committee_uid:${committeeId}`, + }); + return resources.map((r) => r.data); + } catch { + logger.warning(req, 'get_committee_resolutions', 'Failed to fetch committee resolutions, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + public async getCommitteeActivity(req: Request, committeeId: string): Promise { + try { + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + type: 'committee_activity', + tags: `committee_uid:${committeeId}`, + }); + return resources.map((r) => r.data); + } catch { + logger.warning(req, 'get_committee_activity', 'Failed to fetch committee activity, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + public async getCommitteeContributors(req: Request, committeeId: string): Promise { + try { + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + type: 'committee_contributor', + tags: `committee_uid:${committeeId}`, + }); + return resources.map((r) => r.data); + } catch { + logger.warning(req, 'get_committee_contributors', 'Failed to fetch committee contributors, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + public async getCommitteeDeliverables(req: Request, committeeId: string): Promise { + try { + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + type: 'committee_deliverable', + tags: `committee_uid:${committeeId}`, + }); + return resources.map((r) => r.data); + } catch { + logger.warning(req, 'get_committee_deliverables', 'Failed to fetch committee deliverables, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + public async getCommitteeDiscussions(req: Request, committeeId: string): Promise { + try { + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + type: 'committee_discussion', + tags: `committee_uid:${committeeId}`, + }); + return resources.map((r) => r.data); + } catch { + logger.warning(req, 'get_committee_discussions', 'Failed to fetch committee discussions, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + public async getCommitteeEvents(req: Request, committeeId: string): Promise { + try { + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + type: 'committee_event', + tags: `committee_uid:${committeeId}`, + }); + return resources.map((r) => r.data); + } catch { + logger.warning(req, 'get_committee_events', 'Failed to fetch committee events, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + public async getCommitteeCampaigns(req: Request, committeeId: string): Promise { + try { + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + type: 'committee_campaign', + tags: `committee_uid:${committeeId}`, + }); + return resources.map((r) => r.data); + } catch { + logger.warning(req, 'get_committee_campaigns', 'Failed to fetch committee campaigns, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + public async getCommitteeEngagement(req: Request, committeeId: string): Promise { + try { + return await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/engagement`, 'GET'); + } catch { + logger.warning(req, 'get_committee_engagement', 'Failed to fetch committee engagement, returning empty', { + committee_uid: committeeId, + }); + return {}; + } + } + + public async getCommitteeBudget(req: Request, committeeId: string): Promise { + try { + return await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/budget`, 'GET'); + } catch { + logger.warning(req, 'get_committee_budget', 'Failed to fetch committee budget, returning null', { + committee_uid: committeeId, + }); + return null; + } + } + /** * Fetches committee settings by ID * @returns Committee settings or empty object if not found/error From da55ff5c15e0074d9f9cb637319fe303340d9e34 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 11 Mar 2026 23:56:39 -0700 Subject: [PATCH 03/48] fix(committees): address Copilot review findings on PR #295 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix committeeSignal not set for 'other' behavioral class (EMPTY → of(null)) - Fix vote DTO camelCase keys to match snake_case CommitteeVote interface - Fix getCommitteeEngagement returning {} instead of null on failure - Fix activeTab two-way binding: use explicit [value]/valueChange pattern - Set committeeSignal before loading sub-resources to prevent stale state Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view/committee-view.component.html | 2 +- .../committee-view/committee-view.component.ts | 11 +++++------ .../src/app/shared/services/committee.service.ts | 4 ++-- .../lfx-one/src/server/services/committee.service.ts | 12 ++++++------ 4 files changed, 14 insertions(+), 15 deletions(-) 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 ea83c2436..05308bb58 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 @@ -115,7 +115,7 @@

{{ committee()?.name }}

- + Overview @if (isMembersTabVisible()) { 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 e0811d24b..b7692974a 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 @@ -48,7 +48,7 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; -import { BehaviorSubject, catchError, combineLatest, EMPTY, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; +import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; @@ -279,13 +279,12 @@ export class CommitteeViewComponent { switchMap(([committee, members]) => { this.members.set(Array.isArray(members) ? members : []); + this.committeeSignal.set(committee); + if (committee) { - return this.loadGroupTypeData$(committeeId, committee).pipe( - tap(() => this.committeeSignal.set(committee)) - ); + return this.loadGroupTypeData$(committeeId, committee); } - this.committeeSignal.set(committee); return of(null); }), finalize(() => this.loading.set(false)) @@ -369,7 +368,7 @@ export class CommitteeViewComponent { ); } - return EMPTY; + return of(null); } private initializeFormattedCreatedDate(): Signal { 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 203282a1c..e0bb97fda 100644 --- a/apps/lfx-one/src/app/shared/services/committee.service.ts +++ b/apps/lfx-one/src/app/shared/services/committee.service.ts @@ -137,8 +137,8 @@ export class CommitteeService { return this.http.get(`/api/committees/${committeeId}/campaigns`).pipe(catchError(() => of([]))); } - public getCommitteeEngagement(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/engagement`).pipe(catchError(() => of({} as CommitteeEngagementMetrics))); + public getCommitteeEngagement(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/engagement`).pipe(catchError(() => of(null))); } public getCommitteeBudget(committeeId: string): Observable { diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index 58efe0e39..273d7897b 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -406,10 +406,10 @@ export class CommitteeService { title: r.data.name, status: getVoteStatus(r.data.status), deadline: r.data.end_time, - votesFor: r.data.num_response_received ?? 0, - votesAgainst: 0, - votesAbstain: 0, - totalEligible: r.data.total_voting_request_invitations ?? 0, + votes_for: r.data.num_response_received ?? 0, + votes_against: 0, + votes_abstain: 0, + total_eligible: r.data.total_voting_request_invitations ?? 0, created_by: '', })); } catch { @@ -529,10 +529,10 @@ export class CommitteeService { try { return await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/engagement`, 'GET'); } catch { - logger.warning(req, 'get_committee_engagement', 'Failed to fetch committee engagement, returning empty', { + logger.warning(req, 'get_committee_engagement', 'Failed to fetch committee engagement, returning null', { committee_uid: committeeId, }); - return {}; + return null; } } From 8bf9c434ea50860c8c6c94cca0e2dd5f93b628ca Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Thu, 12 Mar 2026 00:34:31 -0700 Subject: [PATCH 04/48] feat(committees): wire members tab with full CRUD and governance fields Port missing logic from reference branch into member-form and committee-members components, then wire CommitteeMembersComponent into the committee-view Members tab replacing the placeholder. Member form: individual toggle, date range validators, clear buttons, appointed-by select, required validators, buildOrganizationPayload, getRawValue, SSR-safe patterns, data-testid attributes. Committee members: isPlatformBrowser SSR guard, GroupBehavioralClass input, isMaintainer/isMembersVisible signals, type-specific empty states, skeleton loading, visibility gate. InviteMemberDialog deferred to PR 5. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 16 +- .../committee-view.component.ts | 5 + .../committee-members.component.html | 332 ++++++++++-------- .../committee-members.component.ts | 39 +- .../member-form/member-form.component.html | 225 ++++++++---- .../member-form/member-form.component.ts | 145 ++++++-- 6 files changed, 493 insertions(+), 269 deletions(-) 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 05308bb58..d28fc13db 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 @@ -890,16 +890,16 @@

-
-
- -

Members

-

Coming in a future PR

-
-
+ + } 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 b7692974a..f93f188c8 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 @@ -51,6 +51,7 @@ import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; +import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; @Component({ selector: 'lfx-committee-view', @@ -70,6 +71,7 @@ import { AssignLeadershipDialogComponent } from '../components/assign-leadership TabPanel, DatePipe, DecimalPipe, + CommitteeMembersComponent, ], providers: [ConfirmationService, DialogService], templateUrl: './committee-view.component.html', @@ -96,6 +98,7 @@ export class CommitteeViewComponent { public refresh = new BehaviorSubject(undefined); // Sub-resource writable signals + public membersLoading = signal(true); public members: WritableSignal = signal([]); public openVotes: WritableSignal = signal([]); public budgetSummary: WritableSignal = signal(null); @@ -260,6 +263,7 @@ export class CommitteeViewComponent { this.error.set(false); this.loading.set(true); + this.membersLoading.set(true); const committeeQuery = this.committeeService.getCommittee(committeeId).pipe( catchError(() => { @@ -278,6 +282,7 @@ export class CommitteeViewComponent { return combineLatest([committeeQuery, membersQuery]).pipe( switchMap(([committee, members]) => { this.members.set(Array.isArray(members) ? members : []); + this.membersLoading.set(false); this.committeeSignal.set(committee); 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 9f3d2242e..41cd07993 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.first_name || '') + ' ' + (member.last_name || '') | 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.first_name || '') + ' ' + (member.last_name || '') | 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 b3ee5d397..16285b72f 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,8 +1,8 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { TitleCasePipe } from '@angular/common'; -import { Component, computed, inject, input, OnInit, output, signal, Signal, WritableSignal } from '@angular/core'; +import { isPlatformBrowser, TitleCasePipe } from '@angular/common'; +import { Component, computed, inject, input, OnInit, output, PLATFORM_ID, 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'; @@ -12,12 +12,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 +47,13 @@ export class CommitteeMembersComponent implements OnInit { 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(); @@ -61,7 +63,9 @@ export class CommitteeMembersComponent implements OnInit { 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; @@ -80,7 +84,13 @@ export class CommitteeMembersComponent implements OnInit { this.isDeleting = signal(false); // Initialize permission signals this.isBoardMember = computed(() => this.personaService.currentPersona() === 'board-member'); - this.canManageMembers = computed(() => !this.isBoardMember() && !!this.committee()?.writer); + 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(); @@ -109,6 +119,7 @@ export class CommitteeMembersComponent implements OnInit { width: '700px', modal: true, closable: true, + duplicate: true, data: { isEditing: false, committee: this.committee(), @@ -116,9 +127,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(); } @@ -133,6 +144,7 @@ export class CommitteeMembersComponent implements OnInit { width: '700px', modal: true, closable: true, + duplicate: true, data: { isEditing: true, memberId: member.uid, @@ -142,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(); } @@ -195,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', @@ -243,7 +254,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/member-form/member-form.component.html b/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.html index 5fa2482c0..5afb1e92e 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) { - -
-
- -
- - -
- - -
- -
- - -
+ +
+
+ +
+ + + @if (form().get('role')?.errors?.['required'] && form().get('role')?.touched) { +

Role is required

+ } +
- -
- - -
+ +
+
+ + @if (form().get('role_start')?.value || form().get('role_end')?.value) { + + }
- - -
- - + -
- - -
- -
- - -
- - -
- - -
+ (onSelect)="onDateChange()" + data-testid="member-form-role-start"> +
+ @if (form().errors?.['role_start_after_role_end']) { +

Role end date must be after start date

+ }
- +
- - Voting Status * + + control="voting_status" + [options]="votingStatusOptions" + placeholder="Select voting status" + styleClass="w-full" + appendTo="body" + 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

+ }
- } + + +
+ + +
+
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..c0ba4117c 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,14 +1,16 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, inject, signal } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ChangeDetectorRef, Component, inject, signal } from '@angular/core'; +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 { APPOINTED_BY_OPTIONS, LINKEDIN_PROFILE_PATTERN, MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; import { Committee, CommitteeMember, CreateCommitteeMemberRequest } from '@lfx-one/shared/interfaces'; import { formatDateToISOString, parseISODateString } from '@lfx-one/shared/utils'; import { CommitteeService } from '@services/committee.service'; @@ -17,7 +19,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', }) @@ -26,6 +28,7 @@ export class MemberFormComponent { private readonly dialogRef = inject(DynamicDialogRef); private readonly committeeService = inject(CommitteeService); private readonly messageService = inject(MessageService); + private readonly cdr = inject(ChangeDetectorRef); // Loading state for form submissions public submitting = signal(false); @@ -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,50 @@ 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(); + this.cdr.detectChanges(); + } + + public clearVotingDates(): void { + this.form().get('voting_status_start')?.reset(); + this.form().get('voting_status_end')?.reset(); + this.form().updateValueAndValidity(); + this.cdr.detectChanges(); + } + + public onDateChange(): void { + this.form().updateValueAndValidity(); + this.cdr.detectChanges(); } public onCancel(): void { @@ -66,7 +114,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(); // Prepare member data using form values, mapping to new structure const memberData: CreateCommitteeMemberRequest = { @@ -90,13 +138,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 @@ -134,7 +176,6 @@ export class MemberFormComponent { }, error: (error) => { this.submitting.set(false); - console.error('Failed to save member:', error); if (error.status === 409) { this.messageService.add({ @@ -152,22 +193,22 @@ 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(); + this.cdr.detectChanges(); } } private initializeForm(): void { if (this.isEditing && this.member) { const member = this.member; + const hasOrg = !!member.organization?.name; 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 +219,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: Record): 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('', [Validators.required]), + voting_status: new FormControl('', [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; + }; } } From ab51db63a9dad31b277dda7936da136c29bd3a0b Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Thu, 12 Mar 2026 07:30:04 -0700 Subject: [PATCH 05/48] feat(committees): add invite, join and application review dialogs Add member action dialog components ported from the reference branch: - InviteMemberDialogComponent: email invite with parsing and dedup - JoinApplicationDialogComponent: join request form with reason field - ApplicationReviewComponent: inline pending-application review card Wire invite button into committee-members and application-review into the committee-view Members tab. Add full BFF layer (routes, controller with param validation, typed service methods) for invite and application endpoints proxying to lfx-v2-committee-service. LFXV2-committees Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 24 ++- .../committee-view.component.ts | 6 + .../application-review.component.html | 101 ++++++++++ .../application-review.component.ts | 134 +++++++++++++ .../committee-members.component.html | 10 + .../committee-members.component.ts | 28 +++ .../invite-member-dialog.component.html | 55 ++++++ .../invite-member-dialog.component.ts | 92 +++++++++ .../join-application-dialog.component.html | 58 ++++++ .../join-application-dialog.component.ts | 77 ++++++++ .../app/shared/services/committee.service.ts | 26 +++ .../controllers/committee.controller.ts | 176 +++++++++++++++++- .../src/server/routes/committees.route.ts | 10 + .../src/server/services/committee.service.ts | 76 ++++++++ .../committee-application.interface.ts | 10 + 15 files changed, 875 insertions(+), 8 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html create mode 100644 apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts create mode 100644 apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html create mode 100644 apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts create mode 100644 apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.html create mode 100644 apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts 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 d28fc13db..4255b0b95 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 @@ -893,13 +893,23 @@

- - +
+ + @if (committee()?.uid) { + + } + + + @if (committee()?.uid) { + + + } +
} 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 f93f188c8..849d6ae8f 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 @@ -50,6 +50,7 @@ import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; +import { ApplicationReviewComponent } from '../components/application-review/application-review.component'; import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; @@ -72,6 +73,7 @@ import { CommitteeMembersComponent } from '../components/committee-members/commi DatePipe, DecimalPipe, CommitteeMembersComponent, + ApplicationReviewComponent, ], providers: [ConfirmationService, DialogService], templateUrl: './committee-view.component.html', @@ -209,6 +211,10 @@ export class CommitteeViewComponent { this.refresh.next(); } + public refreshMembers(): void { + this.refresh.next(); + } + public getMembersCountByOrg(org: string): number { return this.members().filter((m) => m.organization?.name === org).length; } diff --git a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html new file mode 100644 index 000000000..20f755f56 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html @@ -0,0 +1,101 @@ + + + +@if (isApplyMode() && canReview()) { + +
+
+

Join Requests

+ @if (pendingCount() > 0) { + + {{ pendingCount() }} + + } +
+
+ + @if (loading()) { + +
+ @for (_ of [1, 2]; track _) { +
+
+
+
+
+
+
+
+
+
+
+
+
+ } +
+ } @else if (pendingCount() === 0) { + +
+ +

No pending requests

+

New join requests will appear here for review.

+
+ } @else { + +
+ @for (app of pendingApplications(); track app.uid) { +
+
+ +
+
+
+ {{ (app.applicant_name || app.applicant_email || '?')[0] | uppercase }} +
+
+

+ {{ app.applicant_name || app.applicant_email }} +

+ @if (app.applicant_name && app.applicant_email) { +

{{ app.applicant_email }}

+ } +
+
+ + @if (app.reason) { +
+

"{{ app.reason }}"

+
+ } + +

Applied {{ app.created_at | date: 'MMM d, yyyy' }}

+
+ + +
+ + + + +
+
+
+ } +
+ } +
+} diff --git a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts new file mode 100644 index 000000000..050e28777 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts @@ -0,0 +1,134 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { DatePipe, UpperCasePipe } from '@angular/common'; +import { Component, computed, effect, inject, input, output, signal, Signal } from '@angular/core'; +import { ButtonComponent } from '@components/button/button.component'; +import { CardComponent } from '@components/card/card.component'; +import { Committee, GroupJoinApplication } from '@lfx-one/shared/interfaces'; +import { CommitteeService } from '@services/committee.service'; +import { MessageService } from 'primeng/api'; + +@Component({ + selector: 'lfx-application-review', + imports: [DatePipe, UpperCasePipe, CardComponent, ButtonComponent], + templateUrl: './application-review.component.html', +}) +export class ApplicationReviewComponent { + private readonly committeeService = inject(CommitteeService); + private readonly messageService = inject(MessageService); + + // Inputs + public committee = input.required(); + + // Outputs + public readonly memberAdded = output(); + + // State + public applications = signal([]); + public loading = signal(true); + public processingId = signal(null); + + // Permissions — only writers can review applications + public canReview: Signal = computed(() => !!this.committee()?.writer); + + // Only show this section if the group uses the 'apply' join mode + public isApplyMode: Signal = computed(() => { + return this.committee()?.join_mode === 'apply'; + }); + + public pendingApplications: Signal = computed(() => { + return this.applications().filter((a) => a.status === 'pending'); + }); + + public pendingCount: Signal = computed(() => { + return this.pendingApplications().length; + }); + + public constructor() { + effect(() => { + const c = this.committee(); + if (!c?.uid) { + this.applications.set([]); + this.loading.set(false); + return; + } + this.loadApplications(c.uid); + }); + } + + public loadApplications(committeeUid: string): void { + this.applications.set([]); + this.loading.set(true); + this.committeeService.getApplications(committeeUid).subscribe({ + next: (apps) => { + if (this.committee()?.uid !== committeeUid) return; + this.applications.set(apps); + this.loading.set(false); + }, + error: () => { + if (this.committee()?.uid !== committeeUid) return; + this.applications.set([]); + this.loading.set(false); + }, + }); + } + + public approve(application: GroupJoinApplication): void { + const c = this.committee(); + if (!c?.uid) return; + + this.processingId.set(application.uid); + + this.committeeService.approveApplication(c.uid, application.uid).subscribe({ + next: () => { + this.processingId.set(null); + this.messageService.add({ + severity: 'success', + summary: 'Application Approved', + detail: `${application.applicant_name || application.applicant_email} has been added to the group.`, + }); + // Remove from list + this.applications.update((apps) => apps.filter((a) => a.uid !== application.uid)); + // Notify parent to refresh members + this.memberAdded.emit(); + }, + error: () => { + this.processingId.set(null); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to approve application. Please try again.', + }); + }, + }); + } + + public reject(application: GroupJoinApplication): void { + const c = this.committee(); + if (!c?.uid) return; + + this.processingId.set(application.uid); + + this.committeeService.rejectApplication(c.uid, application.uid).subscribe({ + next: () => { + this.processingId.set(null); + this.messageService.add({ + severity: 'info', + summary: 'Application Declined', + detail: `Request from ${application.applicant_name || application.applicant_email} has been declined.`, + }); + // Remove from list + this.applications.update((apps) => apps.filter((a) => a.uid !== application.uid)); + }, + error: () => { + this.processingId.set(null); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to decline application. Please try again.', + }); + }, + }); + } +} 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 41cd07993..b250bd2a2 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 @@ -19,6 +19,16 @@

{{ committeeLabel.singular }} Memb data-testid="add-member-btn"> } + @if (canInviteMembers()) { + + + }

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 16285b72f..ea8184fd8 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 @@ -20,6 +20,7 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog'; import { debounceTime, distinctUntilChanged, startWith, take } from 'rxjs'; +import { InviteMemberDialogComponent } from '../invite-member-dialog/invite-member-dialog.component'; import { MemberFormComponent } from '../member-form/member-form.component'; @Component({ @@ -65,6 +66,7 @@ export class CommitteeMembersComponent implements OnInit { public isBoardMember: Signal; public isMaintainer: Signal; public canManageMembers: Signal; + public canInviteMembers: Signal; public isMembersVisible: Signal; // Filter-related variables @@ -91,6 +93,13 @@ export class CommitteeMembersComponent implements OnInit { const visibility = this.committee()?.member_visibility; return visibility !== 'hidden' || this.canManageMembers(); }); + // Invite requires both a compatible join_mode and management permission (writer or maintainer) + this.canInviteMembers = computed(() => { + const committee = this.committee(); + const joinMode = committee?.join_mode; + const hasInviteMode = joinMode === 'invite-only' || joinMode === 'open'; + return hasInviteMode && (!!committee?.writer || this.canManageMembers()); + }); // Initialize filter form this.filterForm = this.initializeFilterForm(); this.searchTerm = this.initializeSearchTerm(); @@ -136,6 +145,25 @@ export class CommitteeMembersComponent implements OnInit { }); } + public openInviteMemberDialog(): void { + const dialogRef = this.dialogService.open(InviteMemberDialogComponent, { + header: 'Invite Members', + width: '550px', + modal: true, + closable: true, + duplicate: true, + data: { + committee: this.committee(), + }, + }); + + dialogRef?.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { + if (result) { + this.refreshMembers(); + } + }); + } + private editMember(): void { const member = this.selectedMember(); if (member) { diff --git a/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html new file mode 100644 index 000000000..309ce0bb8 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html @@ -0,0 +1,55 @@ + + + +
+ +

+ Invite colleagues to join {{ committee?.display_name || committee?.name }}. They will receive an email with a one-click link to accept. +

+ + +
+ + + + @if (form.get('emails')?.touched && form.get('emails')?.hasError('required')) { + At least one email address is required + } +
+ + +
+ + + +
+ + +
+ + + +
+
diff --git a/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts new file mode 100644 index 000000000..be5b6f7f6 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts @@ -0,0 +1,92 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, inject, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ButtonComponent } from '@components/button/button.component'; +import { TextareaComponent } from '@components/textarea/textarea.component'; +import { Committee, CreateGroupInviteRequest } from '@lfx-one/shared/interfaces'; +import { CommitteeService } from '@services/committee.service'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +@Component({ + selector: 'lfx-invite-member-dialog', + imports: [ReactiveFormsModule, ButtonComponent, TextareaComponent], + templateUrl: './invite-member-dialog.component.html', +}) +export class InviteMemberDialogComponent { + private readonly config = inject(DynamicDialogConfig); + private readonly dialogRef = inject(DynamicDialogRef); + private readonly committeeService = inject(CommitteeService); + private readonly messageService = inject(MessageService); + + public readonly committee: Committee | undefined = this.config.data?.committee; + public submitting = signal(false); + + public form = new FormGroup({ + emails: new FormControl('', [Validators.required]), + message: new FormControl(''), + }); + + public onCancel(): void { + this.dialogRef.close(); + } + + public onSubmit(): void { + if (!this.form.valid || !this.committee) { + Object.keys(this.form.controls).forEach((key) => { + this.form.get(key)?.markAsTouched(); + }); + return; + } + + this.submitting.set(true); + + const rawEmails = this.form.value.emails || ''; + // Split by comma, semicolon, newline, or space — then trim and de-dup + const emails = [ + ...new Set( + rawEmails + .split(/[,;\n\s]+/) + .map((e: string) => e.trim().toLowerCase()) + .filter((e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) + ), + ]; + + if (emails.length === 0) { + this.messageService.add({ + severity: 'warn', + summary: 'No valid emails', + detail: 'Please enter at least one valid email address.', + }); + this.submitting.set(false); + return; + } + + const payload: CreateGroupInviteRequest = { + emails, + message: this.form.value.message || undefined, + }; + + this.committeeService.createInvites(this.committee.uid, payload).subscribe({ + next: (invites) => { + this.submitting.set(false); + this.messageService.add({ + severity: 'success', + summary: 'Invites Sent', + detail: `${invites.length} invite(s) sent successfully`, + }); + this.dialogRef.close(true); + }, + error: () => { + this.submitting.set(false); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to send invites. Please try again.', + }); + }, + }); + } +} diff --git a/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.html b/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.html new file mode 100644 index 000000000..6d0976774 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.html @@ -0,0 +1,58 @@ + + + +
+ +
+

You are requesting to join:

+

{{ committee?.name }}

+ @if (committee?.description) { +

{{ committee?.description }}

+ } +
+ + +
+ +

This group requires admin approval. Your request will be reviewed by a group maintainer.

+
+ + +
+ +
+ + + +
+ @if (form.controls.reason.touched && form.controls.reason.errors) { + + @if (form.controls.reason.errors['required']) { + Please provide a reason for joining. + } @else if (form.controls.reason.errors['minlength']) { + Please provide at least 10 characters. + } + + } @else { + + } + {{ reasonLength }}/500 +
+
+ + +
+ + + +
+
+
diff --git a/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts b/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts new file mode 100644 index 000000000..f258c5910 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts @@ -0,0 +1,77 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, inject, signal } from '@angular/core'; +import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms'; +import { ButtonComponent } from '@components/button/button.component'; +import { TextareaComponent } from '@components/textarea/textarea.component'; +import { Committee, GroupJoinApplicationRequest } from '@lfx-one/shared/interfaces'; +import { CommitteeService } from '@services/committee.service'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +@Component({ + selector: 'lfx-join-application-dialog', + imports: [ReactiveFormsModule, ButtonComponent, TextareaComponent], + templateUrl: './join-application-dialog.component.html', +}) +export class JoinApplicationDialogComponent { + private readonly config = inject(DynamicDialogConfig); + private readonly dialogRef = inject(DynamicDialogRef); + private readonly committeeService = inject(CommitteeService); + private readonly messageService = inject(MessageService); + + public readonly committee: Committee | undefined = this.config.data?.committee; + public submitting = signal(false); + + public form = new FormGroup({ + reason: new FormControl('', [JoinApplicationDialogComponent.trimmedRequired, Validators.minLength(10), Validators.maxLength(500)]), + }); + + public get reasonLength(): number { + return this.form.value.reason?.length || 0; + } + + public onCancel(): void { + this.dialogRef.close(); + } + + public onSubmit(): void { + if (!this.form.valid || !this.committee) { + Object.keys(this.form.controls).forEach((key) => { + this.form.get(key)?.markAsTouched(); + }); + return; + } + + this.submitting.set(true); + + const payload: GroupJoinApplicationRequest = { + reason: this.form.value.reason?.trim() || undefined, + }; + + this.committeeService.applyToJoin(this.committee.uid, payload).subscribe({ + next: () => { + this.submitting.set(false); + this.messageService.add({ + severity: 'success', + summary: 'Application Submitted', + detail: `Your request to join "${this.committee?.name}" has been submitted for review.`, + }); + this.dialogRef.close(true); + }, + error: () => { + this.submitting.set(false); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to submit your application. Please try again.', + }); + }, + }); + } + + private static trimmedRequired(control: AbstractControl): ValidationErrors | null { + return (control.value as string)?.trim().length ? null : { required: true }; + } +} 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 e0bb97fda..6b139a425 100644 --- a/apps/lfx-one/src/app/shared/services/committee.service.ts +++ b/apps/lfx-one/src/app/shared/services/committee.service.ts @@ -17,6 +17,10 @@ import { CommitteeResolution, CommitteeVote, CreateCommitteeMemberRequest, + CreateGroupInviteRequest, + GroupInvite, + GroupJoinApplication, + GroupJoinApplicationRequest, QueryServiceCountResponse, } from '@lfx-one/shared/interfaces'; import { catchError, map, Observable, of, take, tap, throwError } from 'rxjs'; @@ -144,4 +148,26 @@ export class CommitteeService { public getCommitteeBudget(committeeId: string): Observable { return this.http.get(`/api/committees/${committeeId}/budget`).pipe(catchError(() => of(null))); } + + // Invite methods + public createInvites(committeeId: string, payload: CreateGroupInviteRequest): Observable { + return this.http.post(`/api/committees/${committeeId}/invites`, payload).pipe(take(1)); + } + + // Application methods + public applyToJoin(committeeId: string, payload: GroupJoinApplicationRequest): Observable { + return this.http.post(`/api/committees/${committeeId}/applications`, payload).pipe(take(1)); + } + + public getApplications(committeeId: string): Observable { + return this.http.get(`/api/committees/${committeeId}/applications`).pipe(catchError(() => of([]))); + } + + public approveApplication(committeeId: string, applicationId: string): Observable { + return this.http.post(`/api/committees/${committeeId}/applications/${applicationId}/approve`, {}).pipe(take(1)); + } + + public rejectApplication(committeeId: string, applicationId: string): Observable { + return this.http.post(`/api/committees/${committeeId}/applications/${applicationId}/reject`, {}).pipe(take(1)); + } } diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index 1c39dc2a5..0462caf45 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -1,7 +1,13 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { CommitteeCreateData, CommitteeUpdateData, CreateCommitteeMemberRequest } from '@lfx-one/shared/interfaces'; +import { + CommitteeCreateData, + CommitteeUpdateData, + CreateCommitteeMemberRequest, + CreateGroupInviteRequest, + GroupJoinApplicationRequest, +} from '@lfx-one/shared/interfaces'; import { NextFunction, Request, Response } from 'express'; import { ServiceValidationError } from '../errors'; @@ -703,4 +709,172 @@ export class CommitteeController { next(error); } } + + /** + * POST /committees/:id/invites + */ + public async createInvites(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + next(ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'create_invites', service: 'committee_controller', path: req.path })); + return; + } + const startTime = logger.startOperation(req, 'create_invites', { committee_id: committeeId }); + + try { + const payload: CreateGroupInviteRequest = req.body; + const invites = await this.committeeService.createInvites(req, committeeId, payload); + + logger.success(req, 'create_invites', startTime, { + committee_id: committeeId, + invite_count: Array.isArray(invites) ? invites.length : 1, + }); + + res.status(201).json(invites); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/invites + */ + public async getInvites(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + next(ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'get_invites', service: 'committee_controller', path: req.path })); + return; + } + const startTime = logger.startOperation(req, 'get_invites', { committee_id: committeeId }); + + try { + const invites = await this.committeeService.getInvites(req, committeeId); + + logger.success(req, 'get_invites', startTime, { + committee_id: committeeId, + invite_count: invites.length, + }); + + res.json(invites); + } catch (error) { + next(error); + } + } + + /** + * POST /committees/:id/applications + */ + public async applyToJoin(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + next(ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'apply_to_join', service: 'committee_controller', path: req.path })); + return; + } + const startTime = logger.startOperation(req, 'apply_to_join', { committee_id: committeeId }); + + try { + const payload: GroupJoinApplicationRequest = req.body; + const application = await this.committeeService.applyToJoin(req, committeeId, payload); + + logger.success(req, 'apply_to_join', startTime, { committee_id: committeeId }); + res.status(201).json(application); + } catch (error) { + next(error); + } + } + + /** + * GET /committees/:id/applications + */ + public async getApplications(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + if (!committeeId) { + next( + ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'get_applications', service: 'committee_controller', path: req.path }) + ); + return; + } + const startTime = logger.startOperation(req, 'get_applications', { committee_id: committeeId }); + + try { + const applications = await this.committeeService.getApplications(req, committeeId); + + logger.success(req, 'get_applications', startTime, { + committee_id: committeeId, + application_count: applications.length, + }); + + res.json(applications); + } catch (error) { + next(error); + } + } + + /** + * POST /committees/:id/applications/:applicationId/approve + */ + public async approveApplication(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + const applicationId = req.params['applicationId']; + if (!committeeId) { + next( + ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'approve_application', service: 'committee_controller', path: req.path }) + ); + return; + } + if (!applicationId) { + next( + ServiceValidationError.forField('applicationId', 'Application ID is required', { + operation: 'approve_application', + service: 'committee_controller', + path: req.path, + }) + ); + return; + } + const startTime = logger.startOperation(req, 'approve_application', { committee_id: committeeId, application_id: applicationId }); + + try { + const application = await this.committeeService.approveApplication(req, committeeId, applicationId); + + logger.success(req, 'approve_application', startTime, { committee_id: committeeId, application_id: applicationId }); + res.json(application); + } catch (error) { + next(error); + } + } + + /** + * POST /committees/:id/applications/:applicationId/reject + */ + public async rejectApplication(req: Request, res: Response, next: NextFunction): Promise { + const committeeId = req.params['id']; + const applicationId = req.params['applicationId']; + if (!committeeId) { + next( + ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'reject_application', service: 'committee_controller', path: req.path }) + ); + return; + } + if (!applicationId) { + next( + ServiceValidationError.forField('applicationId', 'Application ID is required', { + operation: 'reject_application', + service: 'committee_controller', + path: req.path, + }) + ); + return; + } + const startTime = logger.startOperation(req, 'reject_application', { committee_id: committeeId, application_id: applicationId }); + + try { + const application = await this.committeeService.rejectApplication(req, committeeId, applicationId); + + logger.success(req, 'reject_application', startTime, { committee_id: committeeId, application_id: applicationId }); + res.json(application); + } catch (error) { + 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 f2359c1b1..f27a2a735 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -36,4 +36,14 @@ router.get('/:id/campaigns', (req, res, next) => committeeController.getCommitte router.get('/:id/engagement', (req, res, next) => committeeController.getCommitteeEngagement(req, res, next)); router.get('/:id/budget', (req, res, next) => committeeController.getCommitteeBudget(req, res, next)); +// Invite routes +router.post('/:id/invites', (req, res, next) => committeeController.createInvites(req, res, next)); +router.get('/:id/invites', (req, res, next) => committeeController.getInvites(req, res, next)); + +// Application routes +router.post('/:id/applications', (req, res, next) => committeeController.applyToJoin(req, res, next)); +router.get('/:id/applications', (req, res, next) => committeeController.getApplications(req, res, next)); +router.post('/:id/applications/:applicationId/approve', (req, res, next) => committeeController.approveApplication(req, res, next)); +router.post('/:id/applications/:applicationId/reject', (req, res, next) => committeeController.rejectApplication(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 273d7897b..57a109247 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -8,6 +8,10 @@ import { CommitteeSettingsData, CommitteeUpdateData, CreateCommitteeMemberRequest, + CreateGroupInviteRequest, + GroupInvite, + GroupJoinApplication, + GroupJoinApplicationRequest, QueryServiceCountResponse, QueryServiceResponse, } from '@lfx-one/shared/interfaces'; @@ -547,6 +551,78 @@ export class CommitteeService { } } + // ── Invite Methods ────────────────────────────────────────────────────────── + + public async createInvites(req: Request, committeeId: string, payload: CreateGroupInviteRequest): Promise { + const result = await this.microserviceProxy.proxyRequest( + req, + 'LFX_V2_SERVICE', + `/committees/${committeeId}/invites`, + 'POST', + {}, + payload + ); + return Array.isArray(result) ? result : [result]; + } + + public async getInvites(req: Request, committeeId: string): Promise { + try { + const result = await this.microserviceProxy.proxyRequest>( + req, + 'LFX_V2_SERVICE', + `/committees/${committeeId}/invites`, + 'GET' + ); + return Array.isArray(result) ? result : result?.resources?.map((r) => r.data) || []; + } catch { + logger.warning(req, 'get_invites', 'Failed to fetch invites, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + // ── Application Methods ───────────────────────────────────────────────────── + + public async applyToJoin(req: Request, committeeId: string, payload: GroupJoinApplicationRequest): Promise { + return this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/applications`, 'POST', {}, payload); + } + + public async getApplications(req: Request, committeeId: string): Promise { + try { + const result = await this.microserviceProxy.proxyRequest>( + req, + 'LFX_V2_SERVICE', + `/committees/${committeeId}/applications`, + 'GET' + ); + return Array.isArray(result) ? result : result?.resources?.map((r) => r.data) || []; + } catch { + logger.warning(req, 'get_applications', 'Failed to fetch applications, returning empty', { + committee_uid: committeeId, + }); + return []; + } + } + + public async approveApplication(req: Request, committeeId: string, applicationId: string): Promise { + return this.microserviceProxy.proxyRequest( + req, + 'LFX_V2_SERVICE', + `/committees/${committeeId}/applications/${applicationId}/approve`, + 'POST' + ); + } + + public async rejectApplication(req: Request, committeeId: string, applicationId: string): Promise { + return this.microserviceProxy.proxyRequest( + req, + 'LFX_V2_SERVICE', + `/committees/${committeeId}/applications/${applicationId}/reject`, + 'POST' + ); + } + /** * Fetches committee settings by ID * @returns Committee settings or empty object if not found/error diff --git a/packages/shared/src/interfaces/committee-application.interface.ts b/packages/shared/src/interfaces/committee-application.interface.ts index 3ef9536ee..7e49f2dac 100644 --- a/packages/shared/src/interfaces/committee-application.interface.ts +++ b/packages/shared/src/interfaces/committee-application.interface.ts @@ -27,3 +27,13 @@ export interface CommitteeJoinApplication { export interface CreateCommitteeJoinApplicationRequest { reason?: string; } + +/** + * Alias for CreateCommitteeJoinApplicationRequest used in group-context components + */ +export type GroupJoinApplicationRequest = CreateCommitteeJoinApplicationRequest; + +/** + * Alias for CommitteeJoinApplication used in group-context components + */ +export type GroupJoinApplication = CommitteeJoinApplication; From 565ab1caa773b9a88d57818232fd42d7836b5769 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Thu, 12 Mar 2026 08:23:29 -0700 Subject: [PATCH 06/48] fix(committees): address CodeRabbit and Copilot review findings on PR #297 - Remove permission bypass in canInviteMembers (use canManageMembers only) - Show invalid email addresses instead of silently dropping them - Preserve line breaks in application reason with whitespace-pre-wrap - Extract inline ServiceValidationError to variables in controller - Add trimmedMinLength validator for join-application reason field - Guard effect() to skip API calls when join_mode !== 'apply' or no writer Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../application-review.component.html | 2 +- .../application-review.component.ts | 10 +++ .../committee-members.component.ts | 7 +- .../invite-member-dialog.component.html | 10 +++ .../invite-member-dialog.component.ts | 26 +++++-- .../join-application-dialog.component.ts | 15 +++- .../controllers/committee.controller.ts | 74 ++++++++++++------- 7 files changed, 104 insertions(+), 40 deletions(-) diff --git a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html index 20f755f56..1cd672c70 100644 --- a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html +++ b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html @@ -64,7 +64,7 @@

-

"{{ app.reason }}"

+

"{{ app.reason }}"

} diff --git a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts index 050e28777..0b65a43da 100644 --- a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts @@ -53,6 +53,16 @@ export class ApplicationReviewComponent { this.loading.set(false); return; } + if (c.join_mode !== 'apply') { + this.applications.set([]); + this.loading.set(false); + return; + } + if (!c.writer) { + this.applications.set([]); + this.loading.set(false); + return; + } this.loadApplications(c.uid); }); } 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 ea8184fd8..da5ad7d3a 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 @@ -93,12 +93,11 @@ export class CommitteeMembersComponent implements OnInit { const visibility = this.committee()?.member_visibility; return visibility !== 'hidden' || this.canManageMembers(); }); - // Invite requires both a compatible join_mode and management permission (writer or maintainer) + // Invite requires both a compatible join_mode and management permission this.canInviteMembers = computed(() => { const committee = this.committee(); - const joinMode = committee?.join_mode; - const hasInviteMode = joinMode === 'invite-only' || joinMode === 'open'; - return hasInviteMode && (!!committee?.writer || this.canManageMembers()); + const hasInviteMode = committee?.join_mode === 'invite-only' || committee?.join_mode === 'open'; + return hasInviteMode && this.canManageMembers(); }); // Initialize filter form this.filterForm = this.initializeFilterForm(); diff --git a/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html index 309ce0bb8..a9c73be5e 100644 --- a/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html +++ b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html @@ -23,6 +23,16 @@ @if (form.get('emails')?.touched && form.get('emails')?.hasError('required')) { At least one email address is required } + @if (invalidAddresses().length > 0) { +
+ The following addresses are invalid and must be corrected before sending: +
    + @for (addr of invalidAddresses(); track addr) { +
  • {{ addr }}
  • + } +
+
+ }
diff --git a/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts index be5b6f7f6..f82c96735 100644 --- a/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts @@ -10,6 +10,8 @@ import { CommitteeService } from '@services/committee.service'; import { MessageService } from 'primeng/api'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + @Component({ selector: 'lfx-invite-member-dialog', imports: [ReactiveFormsModule, ButtonComponent, TextareaComponent], @@ -23,6 +25,7 @@ export class InviteMemberDialogComponent { public readonly committee: Committee | undefined = this.config.data?.committee; public submitting = signal(false); + public invalidAddresses = signal([]); public form = new FormGroup({ emails: new FormControl('', [Validators.required]), @@ -41,31 +44,40 @@ export class InviteMemberDialogComponent { return; } - this.submitting.set(true); - const rawEmails = this.form.value.emails || ''; // Split by comma, semicolon, newline, or space — then trim and de-dup - const emails = [ + const parsed = [ ...new Set( rawEmails .split(/[,;\n\s]+/) .map((e: string) => e.trim().toLowerCase()) - .filter((e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) + .filter((e: string) => e.length > 0) ), ]; - if (emails.length === 0) { + const validEmails = parsed.filter((e) => EMAIL_REGEX.test(e)); + const invalidEmails = parsed.filter((e) => !EMAIL_REGEX.test(e)); + + if (invalidEmails.length > 0) { + this.invalidAddresses.set(invalidEmails); + return; + } + + this.invalidAddresses.set([]); + + if (validEmails.length === 0) { this.messageService.add({ severity: 'warn', summary: 'No valid emails', detail: 'Please enter at least one valid email address.', }); - this.submitting.set(false); return; } + this.submitting.set(true); + const payload: CreateGroupInviteRequest = { - emails, + emails: validEmails, message: this.form.value.message || undefined, }; diff --git a/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts b/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts index f258c5910..3a82472b4 100644 --- a/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { Component, inject, signal } from '@angular/core'; -import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms'; +import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; import { TextareaComponent } from '@components/textarea/textarea.component'; import { Committee, GroupJoinApplicationRequest } from '@lfx-one/shared/interfaces'; @@ -25,7 +25,11 @@ export class JoinApplicationDialogComponent { public submitting = signal(false); public form = new FormGroup({ - reason: new FormControl('', [JoinApplicationDialogComponent.trimmedRequired, Validators.minLength(10), Validators.maxLength(500)]), + reason: new FormControl('', [ + JoinApplicationDialogComponent.trimmedRequired, + JoinApplicationDialogComponent.trimmedMinLength(10), + Validators.maxLength(500), + ]), }); public get reasonLength(): number { @@ -74,4 +78,11 @@ export class JoinApplicationDialogComponent { private static trimmedRequired(control: AbstractControl): ValidationErrors | null { return (control.value as string)?.trim().length ? null : { required: true }; } + + private static trimmedMinLength(min: number): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const trimmed = (control.value as string)?.trim() ?? ''; + return trimmed.length >= min ? null : { minlength: { requiredLength: min, actualLength: trimmed.length } }; + }; + } } diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index 0462caf45..cbba62d4b 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -716,7 +716,12 @@ export class CommitteeController { public async createInvites(req: Request, res: Response, next: NextFunction): Promise { const committeeId = req.params['id']; if (!committeeId) { - next(ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'create_invites', service: 'committee_controller', path: req.path })); + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'create_invites', + service: 'committee_controller', + path: req.path, + }); + next(validationError); return; } const startTime = logger.startOperation(req, 'create_invites', { committee_id: committeeId }); @@ -742,7 +747,12 @@ export class CommitteeController { public async getInvites(req: Request, res: Response, next: NextFunction): Promise { const committeeId = req.params['id']; if (!committeeId) { - next(ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'get_invites', service: 'committee_controller', path: req.path })); + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_invites', + service: 'committee_controller', + path: req.path, + }); + next(validationError); return; } const startTime = logger.startOperation(req, 'get_invites', { committee_id: committeeId }); @@ -767,7 +777,12 @@ export class CommitteeController { public async applyToJoin(req: Request, res: Response, next: NextFunction): Promise { const committeeId = req.params['id']; if (!committeeId) { - next(ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'apply_to_join', service: 'committee_controller', path: req.path })); + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'apply_to_join', + service: 'committee_controller', + path: req.path, + }); + next(validationError); return; } const startTime = logger.startOperation(req, 'apply_to_join', { committee_id: committeeId }); @@ -789,9 +804,12 @@ export class CommitteeController { public async getApplications(req: Request, res: Response, next: NextFunction): Promise { const committeeId = req.params['id']; if (!committeeId) { - next( - ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'get_applications', service: 'committee_controller', path: req.path }) - ); + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_applications', + service: 'committee_controller', + path: req.path, + }); + next(validationError); return; } const startTime = logger.startOperation(req, 'get_applications', { committee_id: committeeId }); @@ -817,19 +835,21 @@ export class CommitteeController { const committeeId = req.params['id']; const applicationId = req.params['applicationId']; if (!committeeId) { - next( - ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'approve_application', service: 'committee_controller', path: req.path }) - ); + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'approve_application', + service: 'committee_controller', + path: req.path, + }); + next(validationError); return; } if (!applicationId) { - next( - ServiceValidationError.forField('applicationId', 'Application ID is required', { - operation: 'approve_application', - service: 'committee_controller', - path: req.path, - }) - ); + const validationError = ServiceValidationError.forField('applicationId', 'Application ID is required', { + operation: 'approve_application', + service: 'committee_controller', + path: req.path, + }); + next(validationError); return; } const startTime = logger.startOperation(req, 'approve_application', { committee_id: committeeId, application_id: applicationId }); @@ -851,19 +871,21 @@ export class CommitteeController { const committeeId = req.params['id']; const applicationId = req.params['applicationId']; if (!committeeId) { - next( - ServiceValidationError.forField('id', 'Committee ID is required', { operation: 'reject_application', service: 'committee_controller', path: req.path }) - ); + const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'reject_application', + service: 'committee_controller', + path: req.path, + }); + next(validationError); return; } if (!applicationId) { - next( - ServiceValidationError.forField('applicationId', 'Application ID is required', { - operation: 'reject_application', - service: 'committee_controller', - path: req.path, - }) - ); + const validationError = ServiceValidationError.forField('applicationId', 'Application ID is required', { + operation: 'reject_application', + service: 'committee_controller', + path: req.path, + }); + next(validationError); return; } const startTime = logger.startOperation(req, 'reject_application', { committee_id: committeeId, application_id: applicationId }); From 30fffb70510274f9949202416868e5232819e8d5 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Thu, 12 Mar 2026 10:07:56 -0700 Subject: [PATCH 07/48] feat(committees): add committee settings tab Wire the CommitteeSettingsComponent into the committee detail view as a writer-guarded Settings tab. Admins can manage visibility, voting mode, join mode, SSO group, meeting attendees, and other feature toggles. - Add join mode dropdown to CommitteeSettingsComponent (was missing) - Create settings form group in committee-view with save functionality - Populate settings form from committee data on load - Guard settings tab behind canManageConfigurations check LFXV2-1255 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 21 ++++++ .../committee-view.component.ts | 70 +++++++++++++++++++ .../committee-settings.component.html | 28 ++++++++ .../committee-settings.component.ts | 3 +- 4 files changed, 121 insertions(+), 1 deletion(-) 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 4255b0b95..27140a39c 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 @@ -127,6 +127,9 @@

{{ committee()?.name }}

Meetings Surveys Documents + @if (canManageConfigurations()) { + Settings + } @@ -958,6 +961,24 @@

+
+ +
+ + +
+
+ + }

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 849d6ae8f..36ec247f7 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 @@ -3,6 +3,7 @@ import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; import { DatePipe, DecimalPipe } from '@angular/common'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; @@ -53,6 +54,7 @@ import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observa import { ApplicationReviewComponent } from '../components/application-review/application-review.component'; import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; +import { CommitteeSettingsComponent } from '../components/committee-settings/committee-settings.component'; @Component({ selector: 'lfx-committee-view', @@ -73,7 +75,9 @@ import { CommitteeMembersComponent } from '../components/committee-members/commi DatePipe, DecimalPipe, CommitteeMembersComponent, + CommitteeSettingsComponent, ApplicationReviewComponent, + ReactiveFormsModule, ], providers: [ConfirmationService, DialogService], templateUrl: './committee-view.component.html', @@ -181,6 +185,10 @@ export class CommitteeViewComponent { public chairElectedDate: Signal = this.initializeChairElectedDate(); public coChairElectedDate: Signal = this.initializeCoChairElectedDate(); + // -- Settings form -- + public settingsForm: FormGroup = this.createSettingsForm(); + public settingsSaving = signal(false); + // -- Configuration label signals -- public joinModeLabel: Signal = computed(() => { switch (this.committee()?.join_mode) { @@ -219,6 +227,38 @@ export class CommitteeViewComponent { return this.members().filter((m) => m.organization?.name === org).length; } + public saveSettings(): void { + const committee = this.committee(); + if (!committee) return; + + this.settingsSaving.set(true); + const payload = this.settingsForm.value; + + this.committeeService + .updateCommittee(committee.uid, payload) + .pipe( + take(1), + finalize(() => this.settingsSaving.set(false)) + ) + .subscribe({ + next: (updated) => { + this.committeeSignal.set(updated); + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Settings saved successfully', + }); + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to save settings. Please try again.', + }); + }, + }); + } + public openAssignLeadership(role: LeadershipRole): void { const committee = this.committee(); if (!committee) return; @@ -293,6 +333,7 @@ export class CommitteeViewComponent { this.committeeSignal.set(committee); if (committee) { + this.populateSettingsForm(committee); return this.loadGroupTypeData$(committeeId, committee); } @@ -423,4 +464,33 @@ export class CommitteeViewComponent { return new Date(c.elected_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); }); } + + private createSettingsForm(): FormGroup { + return new FormGroup({ + business_email_required: new FormControl(false), + enable_voting: new FormControl(false), + is_audit_enabled: new FormControl(false), + public: new FormControl(false), + sso_group_enabled: new FormControl(false), + // TODO(LFXV2-1255): Remove joinable once join_mode is fully wired backend-side. + joinable: new FormControl(false), + join_mode: new FormControl('closed'), + member_visibility: new FormControl('hidden'), + show_meeting_attendees: new FormControl(false), + }); + } + + private populateSettingsForm(committee: Committee): void { + this.settingsForm.patchValue({ + business_email_required: committee.business_email_required || false, + enable_voting: committee.enable_voting || false, + is_audit_enabled: committee.is_audit_enabled || false, + public: committee.public || false, + sso_group_enabled: committee.sso_group_enabled || false, + joinable: false, + join_mode: committee.join_mode || 'closed', + member_visibility: committee.member_visibility || 'hidden', + show_meeting_attendees: committee.show_meeting_attendees || false, + }); + } } 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; } From 54509429c9c5ee3089d037c7bdbc36d1dbff8a98 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Thu, 12 Mar 2026 11:01:08 -0700 Subject: [PATCH 08/48] feat(committees): add upcoming meetings tab to committee detail Wire the Meetings tab in committee detail view with real meeting data, replacing the placeholder. Fetches meetings via MeetingService using the committee's project_uid and filters by committee uid. - Add upcoming/past toggle with MeetingCardComponent rendering - Fetch meetings during committee load via getMeetingsByProject - Add createMeeting navigation with committee context - Handle empty states for both upcoming and past views - Fix meeting time comparison to use current time (CodeRabbit finding) LFXV2-1255 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 80 +++++++++++++++++-- .../committee-view.component.ts | 60 +++++++++++++- 2 files changed, 131 insertions(+), 9 deletions(-) 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 27140a39c..be5012a4a 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 @@ -929,14 +929,82 @@

-
-
- -

Meetings

-

Coming in a future PR

+
+ +
+

Meetings

+
+
+ + +
+ @if (!isBoardMember()) { + + + } +
+ + + @if (meetingViewFilter() === 'upcoming' && upcomingMeetings().length > 0) { +
+ @for (meeting of upcomingMeetings(); track meeting.id) { + + + } +
+ } @else if (meetingViewFilter() === 'past' && pastCommitteeMeetings().length > 0) { +
+ @for (meeting of pastCommitteeMeetings(); track meeting.id) { + + + } +
+ } @else { + +
+ + @if (meetingViewFilter() === 'upcoming') { +

No Upcoming Meetings

+

There are no upcoming meetings scheduled for this group.

+ } @else { +

No Past Meetings

+

No past meetings found for this group.

+ } +
+
+ }
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 36ec247f7..4124a576e 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 @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; -import { DatePipe, DecimalPipe } from '@angular/common'; +import { DatePipe, DecimalPipe, NgClass } from '@angular/common'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -41,9 +41,13 @@ import { LeadershipRole, TagSeverity, } from '@lfx-one/shared'; +import { Meeting } from '@lfx-one/shared/interfaces'; +import { MeetingCardComponent } from '@app/modules/meetings/components/meeting-card/meeting-card.component'; import { CommitteeMemberVotingStatus } from '@lfx-one/shared/enums'; import { CommitteeService } from '@services/committee.service'; +import { MeetingService } from '@services/meeting.service'; import { PersonaService } from '@services/persona.service'; +import { ProjectService } from '@services/project.service'; import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; @@ -77,7 +81,9 @@ import { CommitteeSettingsComponent } from '../components/committee-settings/com CommitteeMembersComponent, CommitteeSettingsComponent, ApplicationReviewComponent, + MeetingCardComponent, ReactiveFormsModule, + NgClass, ], providers: [ConfirmationService, DialogService], templateUrl: './committee-view.component.html', @@ -88,6 +94,8 @@ export class CommitteeViewComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly committeeService = inject(CommitteeService); + private readonly meetingService = inject(MeetingService); + private readonly projectService = inject(ProjectService); private readonly dialogService = inject(DialogService); private readonly messageService = inject(MessageService); private readonly personaService = inject(PersonaService); @@ -116,6 +124,12 @@ export class CommitteeViewComponent { public upcomingEvents: WritableSignal = signal([]); public outreachCampaigns: WritableSignal = signal([]); public engagementMetrics: WritableSignal = signal(null); + public committeeMeetings: WritableSignal = signal([]); + + // -- Meeting computed signals -- + public meetingViewFilter = signal<'upcoming' | 'past'>('upcoming'); + public upcomingMeetings: Signal = this.initializeUpcomingMeetings(); + public pastCommitteeMeetings: Signal = this.initializePastMeetings(); // -- Committee (writable so leadership updates apply instantly) -- public committeeSignal: WritableSignal = signal(null); @@ -259,6 +273,14 @@ export class CommitteeViewComponent { }); } + public createMeeting(): void { + const committee = this.committee(); + if (!committee) return; + this.router.navigate(['/meetings/create'], { + queryParams: { committee_uid: committee.uid, committee_name: committee.name, project_uid: committee.project_uid }, + }); + } + public openAssignLeadership(role: LeadershipRole): void { const committee = this.committee(); if (!committee) return; @@ -325,9 +347,15 @@ export class CommitteeViewComponent { const membersQuery = this.committeeService.getCommitteeMembers(committeeId).pipe(catchError(() => of([]))); - return combineLatest([committeeQuery, membersQuery]).pipe( - switchMap(([committee, members]) => { + return committeeQuery.pipe( + switchMap((committee) => { + const projectUid = committee?.project_uid || this.projectService.project()?.uid; + const meetingsQuery = projectUid ? this.meetingService.getMeetingsByProject(projectUid).pipe(catchError(() => of([]))) : of([]); + return combineLatest([of(committee), membersQuery, meetingsQuery]); + }), + switchMap(([committee, members, meetings]) => { this.members.set(Array.isArray(members) ? members : []); + this.committeeMeetings.set(Array.isArray(meetings) ? meetings : []); this.membersLoading.set(false); this.committeeSignal.set(committee); @@ -465,6 +493,32 @@ export class CommitteeViewComponent { }); } + private initializeUpcomingMeetings(): Signal { + return computed(() => { + const committeeId = this.committee()?.uid; + if (!committeeId) return []; + const meetings = this.committeeMeetings(); + if (!Array.isArray(meetings)) return []; + const now = new Date().getTime(); + return meetings + .filter((m) => m.start_time && new Date(m.start_time).getTime() >= now && m.committees?.some((c) => c.uid === committeeId)) + .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()); + }); + } + + private initializePastMeetings(): Signal { + return computed(() => { + const committeeId = this.committee()?.uid; + if (!committeeId) return []; + const meetings = this.committeeMeetings(); + if (!Array.isArray(meetings)) return []; + const now = new Date().getTime(); + return meetings + .filter((m) => m.start_time && new Date(m.start_time).getTime() < now && m.committees?.some((c) => c.uid === committeeId)) + .sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime()); + }); + } + private createSettingsForm(): FormGroup { return new FormGroup({ business_email_required: new FormControl(false), From b52ed226da09958eb7384977644b415f76fb29c1 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sun, 15 Mar 2026 18:30:54 -0700 Subject: [PATCH 09/48] fix(committees): wire meetings endpoint and align JoinMode enum values Add dedicated GET /committees/:id/meetings BFF endpoint that filters by committee_uid instead of relying on project-level meeting queries. Update JoinMode type to match upstream service values (invite_only, application) and remove client-side committees?.some() filtering that always returned empty results. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.ts | 15 ++--- .../application-review.component.ts | 6 +- .../committee-members.component.ts | 2 +- .../upcoming-committee-meeting.component.ts | 56 ++++++------------- .../app/shared/services/committee.service.ts | 10 ++++ .../controllers/committee.controller.ts | 35 ++++++++++++ .../src/server/routes/committees.route.ts | 3 + .../src/server/services/committee.service.ts | 35 ++++++++++++ 8 files changed, 110 insertions(+), 52 deletions(-) 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 4124a576e..2e358ef8c 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 @@ -45,9 +45,7 @@ import { Meeting } from '@lfx-one/shared/interfaces'; import { MeetingCardComponent } from '@app/modules/meetings/components/meeting-card/meeting-card.component'; import { CommitteeMemberVotingStatus } from '@lfx-one/shared/enums'; import { CommitteeService } from '@services/committee.service'; -import { MeetingService } from '@services/meeting.service'; import { PersonaService } from '@services/persona.service'; -import { ProjectService } from '@services/project.service'; import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; @@ -94,8 +92,6 @@ export class CommitteeViewComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly committeeService = inject(CommitteeService); - private readonly meetingService = inject(MeetingService); - private readonly projectService = inject(ProjectService); private readonly dialogService = inject(DialogService); private readonly messageService = inject(MessageService); private readonly personaService = inject(PersonaService); @@ -208,9 +204,9 @@ export class CommitteeViewComponent { switch (this.committee()?.join_mode) { case 'open': return 'Open'; - case 'invite-only': + case 'invite_only': return 'Invite Only'; - case 'apply': + case 'application': return 'Apply to Join'; case 'closed': return 'Closed'; @@ -349,8 +345,7 @@ export class CommitteeViewComponent { return committeeQuery.pipe( switchMap((committee) => { - const projectUid = committee?.project_uid || this.projectService.project()?.uid; - const meetingsQuery = projectUid ? this.meetingService.getMeetingsByProject(projectUid).pipe(catchError(() => of([]))) : of([]); + const meetingsQuery = this.committeeService.getCommitteeMeetings(committeeId).pipe(catchError(() => of([]))); return combineLatest([of(committee), membersQuery, meetingsQuery]); }), switchMap(([committee, members, meetings]) => { @@ -501,7 +496,7 @@ export class CommitteeViewComponent { if (!Array.isArray(meetings)) return []; const now = new Date().getTime(); return meetings - .filter((m) => m.start_time && new Date(m.start_time).getTime() >= now && m.committees?.some((c) => c.uid === committeeId)) + .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()); }); } @@ -514,7 +509,7 @@ export class CommitteeViewComponent { if (!Array.isArray(meetings)) return []; const now = new Date().getTime(); return meetings - .filter((m) => m.start_time && new Date(m.start_time).getTime() < now && m.committees?.some((c) => c.uid === committeeId)) + .filter((m) => m.start_time && new Date(m.start_time).getTime() < now) .sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime()); }); } diff --git a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts index 0b65a43da..2ff0ed37a 100644 --- a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts @@ -32,9 +32,9 @@ export class ApplicationReviewComponent { // Permissions — only writers can review applications public canReview: Signal = computed(() => !!this.committee()?.writer); - // Only show this section if the group uses the 'apply' join mode + // Only show this section if the group uses the 'application' join mode public isApplyMode: Signal = computed(() => { - return this.committee()?.join_mode === 'apply'; + return this.committee()?.join_mode === 'application'; }); public pendingApplications: Signal = computed(() => { @@ -53,7 +53,7 @@ export class ApplicationReviewComponent { this.loading.set(false); return; } - if (c.join_mode !== 'apply') { + if (c.join_mode !== 'application') { this.applications.set([]); this.loading.set(false); return; 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 da5ad7d3a..6e68dcbbf 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 @@ -96,7 +96,7 @@ export class CommitteeMembersComponent implements OnInit { // Invite requires both a compatible join_mode and management permission this.canInviteMembers = computed(() => { const committee = this.committee(); - const hasInviteMode = committee?.join_mode === 'invite-only' || committee?.join_mode === 'open'; + const hasInviteMode = committee?.join_mode === 'invite_only' || committee?.join_mode === 'open'; return hasInviteMode && this.canManageMembers(); }); // Initialize filter form 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/shared/services/committee.service.ts b/apps/lfx-one/src/app/shared/services/committee.service.ts index 6b139a425..7920290e0 100644 --- a/apps/lfx-one/src/app/shared/services/committee.service.ts +++ b/apps/lfx-one/src/app/shared/services/committee.service.ts @@ -17,6 +17,8 @@ import { CommitteeResolution, CommitteeVote, CreateCommitteeMemberRequest, + Meeting, + PaginatedResponse, CreateGroupInviteRequest, GroupInvite, GroupJoinApplication, @@ -108,6 +110,14 @@ 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), + catchError(() => of([])), + take(1) + ); + } + // Dashboard sub-resource methods public getCommitteeVotes(committeeId: string): Observable { return this.http.get(`/api/committees/${committeeId}/votes`).pipe(catchError(() => of([]))); diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index cbba62d4b..51df61294 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -899,4 +899,39 @@ export class CommitteeController { next(error); } } + + /** + * GET /committees/:id/meetings + */ + public async getCommitteeMeetings(req: Request, res: Response, next: NextFunction): Promise { + const { id } = req.params; + const startTime = logger.startOperation(req, 'get_committee_meetings', { + 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_meetings', + service: 'committee_controller', + path: req.path, + }); + next(validationError); + return; + } + + const result = await this.committeeService.getCommitteeMeetings(req, id, req.query as Record); + + logger.success(req, 'get_committee_meetings', startTime, { + committee_id: id, + meeting_count: result.data.length, + }); + + res.json(result); + } catch (error) { + logger.error(req, 'get_committee_meetings', startTime, error, { committee_id: id }); + 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 f27a2a735..c29e24475 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -24,6 +24,9 @@ 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)); router.get('/:id/resolutions', (req, res, next) => committeeController.getCommitteeResolutions(req, res, next)); diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index 57a109247..10804060b 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -12,6 +12,8 @@ import { GroupInvite, GroupJoinApplication, GroupJoinApplicationRequest, + Meeting, + PaginatedResponse, QueryServiceCountResponse, QueryServiceResponse, } from '@lfx-one/shared/interfaces'; @@ -623,6 +625,39 @@ export class CommitteeService { ); } + /** + * Fetches meetings associated with a committee. + */ + public async getCommitteeMeetings(req: Request, committeeId: string, query: Record = {}): Promise> { + try { + const params = { + ...query, + committee_uid: committeeId, + }; + + logger.debug(req, 'get_committee_meetings', 'Fetching meetings for committee', { + committee_uid: committeeId, + }); + + // Lazy import to avoid circular dependency (MeetingService imports CommitteeService) + const { MeetingService } = await import('./meeting.service'); + const meetingService = new MeetingService(); + 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 }; + } + } + /** * Fetches committee settings by ID * @returns Committee settings or empty object if not found/error From 4ac0a8ec7ddde176f4114b27e1c114ab5f3838d6 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Mon, 16 Mar 2026 13:33:04 -0700 Subject: [PATCH 10/48] fix(committees): fix meetings tab data source, permissions, and accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix pastCommitteeMeetings type mismatch: pass pastMeeting=false since committee meetings are Meeting[] not PastMeeting[] (prevents 404s) - Fix Create Meeting CTA to use canManageConfigurations instead of !isBoardMember - Decouple meetings fetch from committee render path with independent meetingsLoading signal and loading skeleton - Add aria-pressed to Upcoming/Past toggle buttons and role="group" wrapper - Add TODO for stale upcoming/past split recomputation - Fix JoinMode doc comment: 'apply' → 'application' to match type - Derive joinable from committee.join_mode instead of hardcoding false Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 18 +++++++++--- .../committee-view.component.ts | 29 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) 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 be5012a4a..a55f5fe6d 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 @@ -936,11 +936,12 @@

Meetings

-
+
- @if (!isBoardMember()) { + @if (canManageConfigurations()) { + @for (i of [1, 2, 3]; track i) { +
+ } +
+ } @else if (meetingViewFilter() === 'upcoming' && upcomingMeetings().length > 0) {
@for (meeting of upcomingMeetings(); track meeting.id) { @for (meeting of pastCommitteeMeetings(); track meeting.id) { @@ -987,7 +997,7 @@

+ [pastMeeting]="false"> }

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 2e358ef8c..7ce0be47d 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 @@ -121,8 +121,11 @@ export class CommitteeViewComponent { public outreachCampaigns: WritableSignal = signal([]); public engagementMetrics: WritableSignal = signal(null); public committeeMeetings: WritableSignal = signal([]); + public meetingsLoading = signal(false); // -- Meeting computed signals -- + // TODO: upcoming/past split uses new Date() at compute-time and won't recompute as time passes. + // Add a 60s interval tick signal to force recomputation when a meeting crosses the now boundary. public meetingViewFilter = signal<'upcoming' | 'past'>('upcoming'); public upcomingMeetings: Signal = this.initializeUpcomingMeetings(); public pastCommitteeMeetings: Signal = this.initializePastMeetings(); @@ -343,20 +346,16 @@ export class CommitteeViewComponent { const membersQuery = this.committeeService.getCommitteeMembers(committeeId).pipe(catchError(() => of([]))); - return committeeQuery.pipe( - switchMap((committee) => { - const meetingsQuery = this.committeeService.getCommitteeMeetings(committeeId).pipe(catchError(() => of([]))); - return combineLatest([of(committee), membersQuery, meetingsQuery]); - }), - switchMap(([committee, members, meetings]) => { + return combineLatest([committeeQuery, membersQuery]).pipe( + switchMap(([committee, members]) => { this.members.set(Array.isArray(members) ? members : []); - this.committeeMeetings.set(Array.isArray(meetings) ? meetings : []); this.membersLoading.set(false); this.committeeSignal.set(committee); if (committee) { this.populateSettingsForm(committee); + this.loadMeetings(committeeId); return this.loadGroupTypeData$(committeeId, committee); } @@ -370,6 +369,20 @@ export class CommitteeViewComponent { .subscribe(); } + private loadMeetings(committeeId: string): void { + this.meetingsLoading.set(true); + this.committeeService + .getCommitteeMeetings(committeeId) + .pipe( + take(1), + catchError(() => of([])) + ) + .subscribe((meetings) => { + this.committeeMeetings.set(Array.isArray(meetings) ? meetings : []); + this.meetingsLoading.set(false); + }); + } + private loadGroupTypeData$(committeeId: string, committee: Committee): Observable { const cls = getGroupBehavioralClass(committee.category); @@ -536,7 +549,7 @@ export class CommitteeViewComponent { is_audit_enabled: committee.is_audit_enabled || false, public: committee.public || false, sso_group_enabled: committee.sso_group_enabled || false, - joinable: false, + joinable: committee.join_mode === 'open' || committee.join_mode === 'application', join_mode: committee.join_mode || 'closed', member_visibility: committee.member_visibility || 'hidden', show_meeting_attendees: committee.show_meeting_attendees || false, From 4c5f35c520ac1ca2f881df6988676d1cc73fe30c Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Thu, 12 Mar 2026 11:26:54 -0700 Subject: [PATCH 11/48] feat(committees): add committee create and edit form Align committee-manage component with reference branch: - Add missing join_mode form control and populate logic for edit mode - Remove console.error calls (anti-pattern per project rules) - All sub-components, routes, BFF endpoints, and entry points already existed from prior PRs in the stack LFXV2-1255 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-manage/committee-manage.component.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) 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..b12535055 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 @@ -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,8 +453,7 @@ export class CommitteeManageComponent { } } - private handleCommitteeError(error: unknown, operation: 'create' | 'update'): void { - console.error(`Error ${operation} committee:`, error); + private handleCommitteeError(_: unknown, operation: 'create' | 'update'): void { this.submitting.set(false); this.messageService.add({ @@ -570,10 +567,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 })) ); } From 05c7e4512f577b9eaecae7b5bc0fb3e0204e2cfa Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sat, 14 Mar 2026 17:01:34 -0700 Subject: [PATCH 12/48] feat(committees): add votes tab with open/closed votes and resolutions Replace the placeholder votes tab panel with a full implementation showing open votes with progress bars, past/closed votes list, and recent resolutions. Add computed signals to filter votes by status and an effect to redirect to overview when voting is disabled. LFXV2-XXX Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 106 +++++++++++++++++- .../committee-view.component.ts | 13 ++- 2 files changed, 112 insertions(+), 7 deletions(-) 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 a55f5fe6d..089a9e147 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 @@ -916,15 +916,109 @@

-
-
- -

Votes

-

Coming in a future PR

+
+ +
+
+

Open Votes

+ @if (activeVotesList().length > 0) { + {{ activeVotesList().length }} pending + } +
+ + @if (activeVotesList().length > 0) { +
+ @for (vote of activeVotesList(); track vote.uid) { + +
+
+

{{ vote.title }}

+ @if (vote.created_by) { +

Proposed by {{ vote.created_by }}

+ } +
+ +
+ +
+
+ {{ vote.votes_for + vote.votes_against + vote.votes_abstain }} of {{ vote.total_eligible }} votes cast + Deadline: {{ vote.deadline | date: 'MMM d, y' }} +
+
+
+
+
+
+
+ {{ vote.votes_for }} for + {{ vote.votes_against }} against + {{ vote.votes_abstain }} abstain +
+
+
+ } +
+ } @else { + +
+ +

No Open Votes

+

All votes have been resolved.

+
+
+ }
+ + + @if (closedVotesList().length > 0) { +
+

Past Votes

+ +
+ @for (vote of closedVotesList(); track vote.uid) { +
+
+

{{ vote.title }}

+

+ {{ vote.deadline | date: 'MMM d, y' }} + · {{ vote.votes_for + vote.votes_against + vote.votes_abstain }} of {{ vote.total_eligible }} voted +

+
+ +
+ } +
+
+
+ } + + + @if (recentResolutions().length > 0) { +
+

Recent Resolutions

+ +
+ @for (res of recentResolutions(); track res.uid) { +
+
+

{{ res.title }}

+

{{ res.date | date: 'MMM d, y' }} · {{ res.votes_for }}-{{ res.votes_against }}

+
+ +
+ } +
+
+
+ }
} 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 7ce0be47d..907d99137 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 @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; +import { Component, computed, effect, inject, signal, Signal, WritableSignal } from '@angular/core'; import { DatePipe, DecimalPipe, NgClass } from '@angular/common'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -153,6 +153,10 @@ export class CommitteeViewComponent { public isMembersTabVisible: Signal = computed(() => this.committee()?.member_visibility !== 'hidden' || this.canManageConfigurations()); public isVotesTabVisible: Signal = computed(() => !!this.committee()?.enable_voting); + // -- Votes tab computed signals -- + public activeVotesList: Signal = computed(() => this.openVotes().filter((v) => v.status === 'open')); + public closedVotesList: Signal = computed(() => this.openVotes().filter((v) => v.status !== 'open')); + // -- Behavioral class signals -- public behavioralClass: Signal = computed(() => getGroupBehavioralClass(this.committee()?.category)); public isGovernanceClass: Signal = computed(() => isGovernanceClass(this.committee()?.category)); @@ -220,6 +224,13 @@ export class CommitteeViewComponent { public constructor() { this.initializeCommittee(); + + // Redirect away from votes tab when voting is disabled + effect(() => { + if (!this.isVotesTabVisible() && this.activeTab() === 'votes') { + this.activeTab.set('overview'); + } + }); } // -- Public methods -- From 0bee94780f9a00cdc9132bc945a6622d12610555 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sat, 14 Mar 2026 17:08:00 -0700 Subject: [PATCH 13/48] feat(committees): integrate vote results drawer into committee votes tab Reuse the existing VoteResultsDrawerComponent so clicking any vote card (open or closed) in the committee view opens the same side drawer used by the main Votes page. Also makes overview tab vote cards clickable. LFXV2-XXX Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view/committee-view.component.html | 11 ++++++++--- .../committee-view/committee-view.component.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) 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 089a9e147..78687cdae 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 @@ -208,7 +208,7 @@

@for (vote of openVotes(); track vote.uid) { -
+

{{ vote.title }}

@@ -932,7 +932,7 @@

@for (vote of activeVotesList(); track vote.uid) { - +

{{ vote.title }}

@@ -982,7 +982,9 @@

@for (vote of closedVotesList(); track vote.uid) { -
+

{{ vote.title }}

@@ -1158,4 +1160,7 @@

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 907d99137..2d87abc84 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 @@ -53,6 +53,8 @@ import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; +import { VoteResultsDrawerComponent } from '@app/modules/votes/components/vote-results-drawer/vote-results-drawer.component'; + import { ApplicationReviewComponent } from '../components/application-review/application-review.component'; import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; @@ -82,6 +84,7 @@ import { CommitteeSettingsComponent } from '../components/committee-settings/com MeetingCardComponent, ReactiveFormsModule, NgClass, + VoteResultsDrawerComponent, ], providers: [ConfirmationService, DialogService], templateUrl: './committee-view.component.html', @@ -157,6 +160,10 @@ export class CommitteeViewComponent { public activeVotesList: Signal = computed(() => this.openVotes().filter((v) => v.status === 'open')); public closedVotesList: Signal = computed(() => this.openVotes().filter((v) => v.status !== 'open')); + // -- Vote drawer state -- + public voteDrawerVisible = signal(false); + public selectedVoteId = signal(null); + // -- Behavioral class signals -- public behavioralClass: Signal = computed(() => getGroupBehavioralClass(this.committee()?.category)); public isGovernanceClass: Signal = computed(() => isGovernanceClass(this.committee()?.category)); @@ -291,6 +298,11 @@ export class CommitteeViewComponent { }); } + public onViewVote(voteId: string): void { + this.selectedVoteId.set(voteId); + this.voteDrawerVisible.set(true); + } + public openAssignLeadership(role: LeadershipRole): void { const committee = this.committee(); if (!committee) return; From 0a7344e0c640d04b468dc6bd91b596d0276e9481 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sat, 14 Mar 2026 17:14:51 -0700 Subject: [PATCH 14/48] refactor(committees): replace card-based votes tab with reusable table UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract votes tab into CommitteeVotesListComponent that wraps VotesTableComponent and VoteResultsDrawerComponent — same paginated table, search, status filter, and side-drawer experience as the main Votes page, scoped to the committee via committee_name filter. Remove card-based layout and inline drawer from committee-view. LFXV2-XXX Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 110 +---------- .../committee-view.component.ts | 18 +- .../committee-votes-list.component.html | 38 ++++ .../committee-votes-list.component.ts | 172 ++++++++++++++++++ 4 files changed, 217 insertions(+), 121 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.html create mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.ts 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 78687cdae..42980a562 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 @@ -208,7 +208,7 @@

@for (vote of openVotes(); track vote.uid) { -
+

{{ vote.title }}

@@ -919,107 +919,10 @@

-
- -
-
-

Open Votes

- @if (activeVotesList().length > 0) { - {{ activeVotesList().length }} pending - } -
- - @if (activeVotesList().length > 0) { -
- @for (vote of activeVotesList(); track vote.uid) { - -
-
-

{{ vote.title }}

- @if (vote.created_by) { -

Proposed by {{ vote.created_by }}

- } -
- -
- -
-
- {{ vote.votes_for + vote.votes_against + vote.votes_abstain }} of {{ vote.total_eligible }} votes cast - Deadline: {{ vote.deadline | date: 'MMM d, y' }} -
-
-
-
-
-
-
- {{ vote.votes_for }} for - {{ vote.votes_against }} against - {{ vote.votes_abstain }} abstain -
-
-
- } -
- } @else { - -
- -

No Open Votes

-

All votes have been resolved.

-
-
- } -
- - - @if (closedVotesList().length > 0) { -
-

Past Votes

- -
- @for (vote of closedVotesList(); track vote.uid) { -
-
-

{{ vote.title }}

-

- {{ vote.deadline | date: 'MMM d, y' }} - · {{ vote.votes_for + vote.votes_against + vote.votes_abstain }} of {{ vote.total_eligible }} voted -

-
- -
- } -
-
-
- } - - - @if (recentResolutions().length > 0) { -
-

Recent Resolutions

- -
- @for (res of recentResolutions(); track res.uid) { -
-
-

{{ res.title }}

-

{{ res.date | date: 'MMM d, y' }} · {{ res.votes_for }}-{{ res.votes_against }}

-
- -
- } -
-
-
+
+ @if (committee(); as c) { + + }
@@ -1160,7 +1063,4 @@

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 2d87abc84..670f058b5 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 @@ -53,12 +53,11 @@ import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; -import { VoteResultsDrawerComponent } from '@app/modules/votes/components/vote-results-drawer/vote-results-drawer.component'; - import { ApplicationReviewComponent } from '../components/application-review/application-review.component'; import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; import { CommitteeSettingsComponent } from '../components/committee-settings/committee-settings.component'; +import { CommitteeVotesListComponent } from '../components/committee-votes-list/committee-votes-list.component'; @Component({ selector: 'lfx-committee-view', @@ -84,7 +83,7 @@ import { CommitteeSettingsComponent } from '../components/committee-settings/com MeetingCardComponent, ReactiveFormsModule, NgClass, - VoteResultsDrawerComponent, + CommitteeVotesListComponent, ], providers: [ConfirmationService, DialogService], templateUrl: './committee-view.component.html', @@ -156,14 +155,6 @@ export class CommitteeViewComponent { public isMembersTabVisible: Signal = computed(() => this.committee()?.member_visibility !== 'hidden' || this.canManageConfigurations()); public isVotesTabVisible: Signal = computed(() => !!this.committee()?.enable_voting); - // -- Votes tab computed signals -- - public activeVotesList: Signal = computed(() => this.openVotes().filter((v) => v.status === 'open')); - public closedVotesList: Signal = computed(() => this.openVotes().filter((v) => v.status !== 'open')); - - // -- Vote drawer state -- - public voteDrawerVisible = signal(false); - public selectedVoteId = signal(null); - // -- Behavioral class signals -- public behavioralClass: Signal = computed(() => getGroupBehavioralClass(this.committee()?.category)); public isGovernanceClass: Signal = computed(() => isGovernanceClass(this.committee()?.category)); @@ -298,11 +289,6 @@ export class CommitteeViewComponent { }); } - public onViewVote(voteId: string): void { - this.selectedVoteId.set(voteId); - this.voteDrawerVisible.set(true); - } - public openAssignLeadership(role: LeadershipRole): void { const committee = this.committee(); if (!committee) return; 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..08cacc21a --- /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..b1c836ad8 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.ts @@ -0,0 +1,172 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, input, signal, Signal } from '@angular/core'; +import { 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 { BehaviorSubject, 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); + + // === Subjects === + private readonly fetch$ = new BehaviorSubject(undefined); + private readonly refresh$ = new BehaviorSubject(undefined); + + // === 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(); + protected readonly totalCount: Signal = this.initTotalCount(); + + // === 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.fetch$.next(); + this.refresh$.next(); + } + + 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.fetch$.next(); + return; + } + + this.currentFirst.set(event.first); + this.fetch$.next(); + } + + 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 initTotalCount(): Signal { + const filters$ = toObservable(this.filters); + const projectUid$ = toObservable(this.projectUid); + + return toSignal( + combineLatest([projectUid$, filters$, this.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( + tap((count) => this.totalRecords.set(count)), + catchError(() => of(0)) + ); + }) + ), + { initialValue: 0 } + ); + } + + private initVotes(): Signal { + const filters$ = toObservable(this.filters); + const projectUid$ = toObservable(this.projectUid); + + return toSignal( + combineLatest([projectUid$, filters$, this.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; + }); + } +} From 5c3f38646b10e586fc186e112cc5f8db8a752b60 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sat, 14 Mar 2026 21:44:34 -0700 Subject: [PATCH 15/48] fix(committees): address Copilot review findings - Track committeeName input in both combineLatest pipelines so votes refetch when the committee changes without destroying the component. - Add hideGroupFilter input to VotesTableComponent and use it from the committee votes list to properly hide the group dropdown instead of passing an empty array. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-votes-list.component.html | 2 +- .../committee-votes-list.component.ts | 6 +++-- .../votes-table/votes-table.component.html | 26 ++++++++++--------- .../votes-table/votes-table.component.ts | 1 + 4 files changed, 20 insertions(+), 15 deletions(-) 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 index 08cacc21a..725d7b65d 100644 --- 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 @@ -20,7 +20,7 @@ [rowsPerPage]="rowsPerPage()" [first]="currentFirst()" [lazy]="true" - [groupOptions]="[]" + [hideGroupFilter]="true" (viewVote)="onViewVote($event)" (viewResults)="onViewVote($event)" (refresh)="refreshVotes()" 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 index b1c836ad8..8825adbae 100644 --- 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 @@ -99,9 +99,10 @@ export class CommitteeVotesListComponent { private initTotalCount(): Signal { const filters$ = toObservable(this.filters); const projectUid$ = toObservable(this.projectUid); + const committeeName$ = toObservable(this.committeeName); return toSignal( - combineLatest([projectUid$, filters$, this.refresh$]).pipe( + combineLatest([projectUid$, committeeName$, filters$, this.refresh$]).pipe( switchMap(([projectUid]) => { if (!projectUid) return of(0); const searchName = this.filters().search; @@ -119,9 +120,10 @@ export class CommitteeVotesListComponent { private initVotes(): Signal { const filters$ = toObservable(this.filters); const projectUid$ = toObservable(this.projectUid); + const committeeName$ = toObservable(this.committeeName); return toSignal( - combineLatest([projectUid$, filters$, this.fetch$]).pipe( + combineLatest([projectUid$, committeeName$, filters$, this.fetch$]).pipe( tap(() => this.loading.set(true)), switchMap(([projectUid]) => { if (!projectUid) { 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(); From e54fa57b61330df37beda9c6c016210cbfee8d56 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sat, 14 Mar 2026 22:00:04 -0700 Subject: [PATCH 16/48] fix(committees): reset totalRecords on count fetch error Reset the totalRecords signal to 0 in the catchError handler so the paginator doesn't show a stale count when the API call fails. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-votes-list/committee-votes-list.component.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 8825adbae..3b22ad4b7 100644 --- 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 @@ -109,7 +109,10 @@ export class CommitteeVotesListComponent { const queryFilters = this.buildFilters(); return this.voteService.getVotesCountByProject(projectUid, searchName || undefined, queryFilters).pipe( tap((count) => this.totalRecords.set(count)), - catchError(() => of(0)) + catchError(() => { + this.totalRecords.set(0); + return of(0); + }) ); }) ), From c4ef034d2b750d2585da52addca9250d5233c051 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sun, 15 Mar 2026 00:09:49 -0700 Subject: [PATCH 17/48] feat(committees): add observers stat card to group detail overview Adds a 5th stat card displaying the observer count alongside Members, Voting Reps, Organizations, and Join Mode. Observers are a distinct role class tracked separately by EDs and governance stakeholders. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view/committee-view.component.html | 15 ++++++++++++++- .../committee-view/committee-view.component.ts | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) 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 42980a562..9133583f6 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 @@ -137,7 +137,7 @@

{{ committee()?.name }}

-
+
@@ -179,6 +179,19 @@

{{ committee()?.name }}

+ + +
+
+ +
+
+

{{ observerCount() }}

+

Observers

+
+
+
+
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 670f058b5..fd913e089 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 @@ -181,6 +181,7 @@ export class CommitteeViewComponent { return [...new Set(orgs)]; }); public orgCount: Signal = computed(() => this.uniqueOrganizations().length); + public observerCount: Signal = computed(() => this.members().filter((m) => m.voting?.status === CommitteeMemberVotingStatus.OBSERVER).length); public roleBreakdown: Signal<{ name: string; count: number }[]> = computed(() => { const roleCounts: Record = {}; this.members().forEach((m) => { From 6993ec495031ce7511fa229ce0b620b694789d68 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sun, 15 Mar 2026 08:08:57 -0700 Subject: [PATCH 18/48] feat(committees): add key topics chip list to overview Adds a scannable chip/tag list of key topics below the group description. Allows stakeholders to assess a working group's focus areas at a glance without reading a full paragraph. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view/committee-view.component.html | 8 ++++++++ packages/shared/src/interfaces/committee.interface.ts | 2 ++ 2 files changed, 10 insertions(+) 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 9133583f6..6fbc19887 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 @@ -85,6 +85,14 @@

{{ committee()?.name }}

@if (committee()?.description) {

{{ committee()?.description }}

} + + @if (committee()?.key_topics?.length) { +
+ @for (topic of committee()!.key_topics!; track topic) { + {{ topic }} + } +
+ }
@if (committee()?.category) { diff --git a/packages/shared/src/interfaces/committee.interface.ts b/packages/shared/src/interfaces/committee.interface.ts index b476526df..1ce4bb94c 100644 --- a/packages/shared/src/interfaces/committee.interface.ts +++ b/packages/shared/src/interfaces/committee.interface.ts @@ -226,6 +226,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') */ From 1a0c7c625aef943922c9fdb36075f3c7993c6692 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sun, 15 Mar 2026 08:19:09 -0700 Subject: [PATCH 19/48] feat(committees): surface last meeting summary on overview tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a collapsed summary card for the most recent meeting to the overview tab. EDs and governance stakeholders check meeting activity first — this surfaces that signal without requiring navigation to the Meetings tab. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 40 +++++++++++++++++++ .../committee-view.component.ts | 34 +++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) 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 6fbc19887..e19bfdfef 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 @@ -607,6 +607,46 @@

+
+

+ Last Meeting Summary +

+ @if (lastPastMeeting(); as meeting) { + {{ meeting.start_time | date: 'MMM d, y' }} + } +
+ @if (lastPastMeeting(); as meeting) { +

{{ meeting.title }}

+ } +
+ {{ summary.summary_data.edited_content || summary.summary_data.content }} +
+
+ + +
+ + } +

Channels

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 fd913e089..c478bff33 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 @@ -41,10 +41,11 @@ import { LeadershipRole, TagSeverity, } from '@lfx-one/shared'; -import { Meeting } from '@lfx-one/shared/interfaces'; +import { Meeting, PastMeeting, PastMeetingSummary } from '@lfx-one/shared/interfaces'; import { MeetingCardComponent } from '@app/modules/meetings/components/meeting-card/meeting-card.component'; import { CommitteeMemberVotingStatus } from '@lfx-one/shared/enums'; import { CommitteeService } from '@services/committee.service'; +import { MeetingService } from '@services/meeting.service'; import { PersonaService } from '@services/persona.service'; import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; @@ -94,6 +95,7 @@ export class CommitteeViewComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly committeeService = inject(CommitteeService); + private readonly meetingService = inject(MeetingService); private readonly dialogService = inject(DialogService); private readonly messageService = inject(MessageService); private readonly personaService = inject(PersonaService); @@ -132,6 +134,11 @@ export class CommitteeViewComponent { public upcomingMeetings: Signal = this.initializeUpcomingMeetings(); public pastCommitteeMeetings: Signal = this.initializePastMeetings(); + // -- Last meeting summary -- + public lastPastMeeting = signal(null); + public lastMeetingSummary = signal(null); + public summaryExpanded = signal(false); + // -- Committee (writable so leadership updates apply instantly) -- public committeeSignal: WritableSignal = signal(null); public committee: Signal = this.committeeSignal.asReadonly(); @@ -366,6 +373,7 @@ export class CommitteeViewComponent { if (committee) { this.populateSettingsForm(committee); this.loadMeetings(committeeId); + this.loadPastMeetingSummary(committee.project_uid); return this.loadGroupTypeData$(committeeId, committee); } @@ -393,6 +401,17 @@ export class CommitteeViewComponent { }); } + private loadPastMeetingSummary(projectUid: string | undefined): void { + if (!projectUid) return; + this.meetingService + .getPastMeetingsByProject(projectUid, 1) + .pipe( + take(1), + catchError(() => of([])) + ) + .subscribe((pastMeetings) => this.loadLastMeetingSummary(pastMeetings)); + } + private loadGroupTypeData$(committeeId: string, committee: Committee): Observable { const cls = getGroupBehavioralClass(committee.category); @@ -537,6 +556,19 @@ export class CommitteeViewComponent { }); } + private loadLastMeetingSummary(pastMeetings: PastMeeting[]): void { + const lastMeeting = pastMeetings?.[0] ?? null; + this.lastPastMeeting.set(lastMeeting); + this.lastMeetingSummary.set(null); + + if (lastMeeting) { + this.meetingService + .getPastMeetingSummary(lastMeeting.id) + .pipe(catchError(() => of(null))) + .subscribe((summary) => this.lastMeetingSummary.set(summary)); + } + } + private createSettingsForm(): FormGroup { return new FormGroup({ business_email_required: new FormControl(false), From 34c11a5e612981b422c51cc64d982733297059d3 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sun, 15 Mar 2026 08:30:47 -0700 Subject: [PATCH 20/48] fix(committees): address Copilot review findings for overview enhancements - Sort past meetings by start_time descending before selecting most recent - Compose summary fetch into RxJS chain to eliminate unmanaged subscription - Gate meeting summary card on summary.approved for non-admin users - Remove LFXV2-XXXX placeholder from key topics comment Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 72 ++++++++++--------- .../committee-view.component.ts | 24 ++++--- 2 files changed, 51 insertions(+), 45 deletions(-) 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 e19bfdfef..620642848 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 @@ -85,7 +85,7 @@

{{ committee()?.name }}

@if (committee()?.description) {

{{ committee()?.description }}

} - + @if (committee()?.key_topics?.length) {
@for (topic of committee()!.key_topics!; track topic) { @@ -609,42 +609,44 @@

-
-

- Last Meeting Summary -

+ @if (summary.approved || canManageConfigurations()) { + +
+

+ Last Meeting Summary +

+ @if (lastPastMeeting(); as meeting) { + {{ meeting.start_time | date: 'MMM d, y' }} + } +
@if (lastPastMeeting(); as meeting) { - {{ meeting.start_time | date: 'MMM d, y' }} +

{{ meeting.title }}

} -
- @if (lastPastMeeting(); as meeting) { -

{{ meeting.title }}

- } -
- {{ summary.summary_data.edited_content || summary.summary_data.content }} -
-
- - -
- +
+ {{ summary.summary_data.edited_content || summary.summary_data.content }} +
+
+ + +
+ + } } 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 c478bff33..9d5e3d40c 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 @@ -52,7 +52,7 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; -import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; +import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, map, Observable, of, switchMap, take, tap } from 'rxjs'; import { ApplicationReviewComponent } from '../components/application-review/application-review.component'; import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; @@ -407,9 +407,10 @@ export class CommitteeViewComponent { .getPastMeetingsByProject(projectUid, 1) .pipe( take(1), - catchError(() => of([])) + catchError(() => of([])), + switchMap((pastMeetings) => this.loadLastMeetingSummary$(pastMeetings)) ) - .subscribe((pastMeetings) => this.loadLastMeetingSummary(pastMeetings)); + .subscribe(); } private loadGroupTypeData$(committeeId: string, committee: Committee): Observable { @@ -556,17 +557,20 @@ export class CommitteeViewComponent { }); } - private loadLastMeetingSummary(pastMeetings: PastMeeting[]): void { - const lastMeeting = pastMeetings?.[0] ?? null; + private loadLastMeetingSummary$(pastMeetings: PastMeeting[]): Observable { + const sorted = [...(pastMeetings ?? [])].sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime()); + const lastMeeting = sorted[0] ?? null; this.lastPastMeeting.set(lastMeeting); this.lastMeetingSummary.set(null); - if (lastMeeting) { - this.meetingService - .getPastMeetingSummary(lastMeeting.id) - .pipe(catchError(() => of(null))) - .subscribe((summary) => this.lastMeetingSummary.set(summary)); + if (!lastMeeting) { + return of(null); } + + return this.meetingService.getPastMeetingSummary(lastMeeting.id).pipe( + catchError(() => of(null)), + tap((summary) => this.lastMeetingSummary.set(summary)) + ); } private createSettingsForm(): FormGroup { From 44c5a4250bd93d67588ab4b5e93ca94640e2e534 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Mon, 16 Mar 2026 13:49:05 -0700 Subject: [PATCH 21/48] fix(committees): remove unused map import and decouple meetings from render path Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committees/committee-view/committee-view.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9d5e3d40c..793186089 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 @@ -52,7 +52,7 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; -import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, map, Observable, of, switchMap, take, tap } from 'rxjs'; +import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; import { ApplicationReviewComponent } from '../components/application-review/application-review.component'; import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; From c5f5499a346d596ab3ae25cc06f8a3d6d00ae46c Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Sun, 15 Mar 2026 22:42:43 -0700 Subject: [PATCH 22/48] feat(committees): add Surveys tab with BFF endpoint and Create Survey flow - Add GET /committees/:id/surveys BFF endpoint with committee_uid filtering - Add committee-surveys-list component with loading/error/empty states - Add signal-driven tab navigation (Members | Surveys) to committee view - Add "Create Survey" button gated by isBoardMember permission - Add committee preselection in survey create form from query params Co-Authored-By: Claude Opus 4.6 Signed-off-by: Manish Dixit --- .../committee-view.component.html | 22 ++-- .../committee-view.component.ts | 13 +++ .../committee-surveys-list.component.html | 50 +++++++++ .../committee-surveys-list.component.ts | 100 ++++++++++++++++++ .../survey-manage/survey-manage.component.ts | 13 +++ .../src/app/shared/services/survey.service.ts | 6 ++ .../controllers/committee.controller.ts | 36 +++++++ .../src/server/routes/committees.route.ts | 3 + .../src/server/services/survey.service.ts | 28 +++++ skills | 1 + 10 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.html create mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.ts create mode 160000 skills 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 620642848..7dc85d947 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 @@ -1082,13 +1082,23 @@

-
-
- -

Surveys

-

Coming in a future PR

+ @if (committee(); as c) { +
+ @if (!isBoardMember()) { + + + }
-
+ + + } 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 793186089..01356f4d6 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 @@ -58,6 +58,7 @@ import { ApplicationReviewComponent } from '../components/application-review/app import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; import { CommitteeSettingsComponent } from '../components/committee-settings/committee-settings.component'; +import { CommitteeSurveysListComponent } from '../components/committee-surveys-list/committee-surveys-list.component'; import { CommitteeVotesListComponent } from '../components/committee-votes-list/committee-votes-list.component'; @Component({ @@ -84,6 +85,7 @@ import { CommitteeVotesListComponent } from '../components/committee-votes-list/ MeetingCardComponent, ReactiveFormsModule, NgClass, + CommitteeSurveysListComponent, CommitteeVotesListComponent, ], providers: [ConfirmationService, DialogService], @@ -253,6 +255,17 @@ export class CommitteeViewComponent { this.refresh.next(); } + public createSurvey(): void { + const committee = this.committee(); + if (!committee) return; + this.router.navigate(['/surveys/create'], { + queryParams: { + committee_uid: committee.uid, + committee_name: committee.name, + }, + }); + } + public getMembersCountByOrg(org: string): number { return this.members().filter((m) => m.organization?.name === org).length; } 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..deeee91be --- /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..ad228eedb --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.ts @@ -0,0 +1,100 @@ +// 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'; +import { Survey } from '@lfx-one/shared/interfaces'; +import { SurveyService } from '@services/survey.service'; +import { BehaviorSubject, 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); + + // === Constants === + protected readonly surveyLabelPlural = SURVEY_LABEL.plural; + + // === Inputs === + public readonly committeeUid = input.required(); + public readonly committeeName = input.required(); + public readonly hasPMOAccess = input(false); + + // === Subjects === + private readonly refresh$ = new BehaviorSubject(undefined); + + // === Writable Signals === + 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.refresh$.next(); + } + + protected onDuplicateSurvey(surveyId: string): void { + console.warn('Survey duplication not yet implemented for:', surveyId); + } + + protected onCloseSurvey(surveyId: string): void { + console.warn('Survey close not yet implemented for:', surveyId); + } + + // === 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); + return this.refresh$.pipe( + switchMap(() => + 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/surveys/survey-manage/survey-manage.component.ts b/apps/lfx-one/src/app/modules/surveys/survey-manage/survey-manage.component.ts index c4bcdf9a5..f148dbb8b 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(); + constructor() { + this.preselectCommitteeFromQueryParams(); + } + public nextStep(): void { const next = this.currentStep() + 1; if (next <= this.totalSteps && this.canNavigateToStep(next)) { @@ -384,6 +388,15 @@ 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 }]); + } + } + private markAllFormControlsAsTouched(): void { markFormControlsAsTouched(this.form()); } 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..0a4acd66c 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,12 @@ export class SurveyService { return this.getSurveys(params); } + public getSurveysByCommittee(committeeUid: string): Observable { + return this.http.get(`/api/committees/${committeeUid}/surveys`).pipe( + catchError(() => of([])), + ); + } + 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 51df61294..1f5f7c9e1 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -13,12 +13,14 @@ 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(); + private surveyService: SurveyService = new SurveyService(); /** * GET /committees @@ -934,4 +936,38 @@ export class CommitteeController { next(error); } } + + /** + * GET /committees/:id/surveys + */ + 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) { + 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 c29e24475..92e1b730c 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -39,6 +39,9 @@ router.get('/:id/campaigns', (req, res, next) => committeeController.getCommitte router.get('/:id/engagement', (req, res, next) => committeeController.getCommitteeEngagement(req, res, next)); router.get('/:id/budget', (req, res, next) => committeeController.getCommitteeBudget(req, res, next)); +// Survey routes +router.get('/:id/surveys', (req, res, next) => committeeController.getCommitteeSurveys(req, res, next)); + // Invite routes router.post('/:id/invites', (req, res, next) => committeeController.createInvites(req, res, next)); router.get('/:id/invites', (req, res, next) => committeeController.getInvites(req, res, next)); diff --git a/apps/lfx-one/src/server/services/survey.service.ts b/apps/lfx-one/src/server/services/survey.service.ts index 89195c2d9..aea287daa 100644 --- a/apps/lfx-one/src/server/services/survey.service.ts +++ b/apps/lfx-one/src/server/services/survey.service.ts @@ -89,6 +89,34 @@ 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, + }); + + const params = { + ...query, + committee_uid: committeeId, + type: 'survey', + }; + + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); + + const surveys: Survey[] = (resources ?? []) + .map((resource) => resource.data) + .filter((s) => s?.committees?.some((c) => c.committee_uid === committeeId)); + + 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/skills b/skills new file mode 160000 index 000000000..72e13f81d --- /dev/null +++ b/skills @@ -0,0 +1 @@ +Subproject commit 72e13f81d2974da525790d9922c8a5b8455fc882 From ebfeb1845cc022832b387f3a74d86b90eb099165 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Tue, 17 Mar 2026 16:59:48 -0700 Subject: [PATCH 23/48] chore: remove accidentally added skills dir Signed-off-by: Manish Dixit --- skills | 1 - 1 file changed, 1 deletion(-) delete mode 160000 skills diff --git a/skills b/skills deleted file mode 160000 index 72e13f81d..000000000 --- a/skills +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 72e13f81d2974da525790d9922c8a5b8455fc882 From 602fb067300066179d3038731891ab123de97034 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Mon, 16 Mar 2026 23:37:48 -0700 Subject: [PATCH 24/48] fix(committees): address review findings on surveys tab PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix barrel import: @lfx-one/shared → @lfx-one/shared/constants - Replace console.warn no-op actions with MessageService toasts for user feedback on unimplemented duplicate/close survey actions - Let getSurveysByCommittee errors propagate so component's loadError signal triggers properly instead of silently returning empty array - Add query_params to getCommitteeSurveys controller logging for consistency with other GET endpoints Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-surveys-list.component.ts | 20 +++++++++++-------- .../src/app/shared/services/survey.service.ts | 4 +--- 2 files changed, 13 insertions(+), 11 deletions(-) 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 index ad228eedb..05632e9e7 100644 --- 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 @@ -4,9 +4,10 @@ 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'; +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 { BehaviorSubject, catchError, finalize, of, switchMap } from 'rxjs'; import { SurveyResultsDrawerComponent } from '@app/modules/surveys/components/survey-results-drawer/survey-results-drawer.component'; @@ -20,6 +21,7 @@ import { SurveysTableComponent } from '@app/modules/surveys/components/surveys-t export class CommitteeSurveysListComponent { // === Services === private readonly surveyService = inject(SurveyService); + private readonly messageService = inject(MessageService); // === Constants === protected readonly surveyLabelPlural = SURVEY_LABEL.plural; @@ -59,12 +61,14 @@ export class CommitteeSurveysListComponent { this.refresh$.next(); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars protected onDuplicateSurvey(surveyId: string): void { - console.warn('Survey duplication not yet implemented for:', surveyId); + this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey duplication is not yet available' }); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars protected onCloseSurvey(surveyId: string): void { - console.warn('Survey close not yet implemented for:', surveyId); + this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey close is not yet available' }); } // === Private Initializers === @@ -88,13 +92,13 @@ export class CommitteeSurveysListComponent { this.loadError.set(true); return of([]); }), - finalize(() => this.loading.set(false)), - ), - ), + finalize(() => this.loading.set(false)) + ) + ) ); - }), + }) ), - { initialValue: [] }, + { initialValue: [] } ); } } 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 0a4acd66c..3d9a99d16 100644 --- a/apps/lfx-one/src/app/shared/services/survey.service.ts +++ b/apps/lfx-one/src/app/shared/services/survey.service.ts @@ -36,9 +36,7 @@ export class SurveyService { } public getSurveysByCommittee(committeeUid: string): Observable { - return this.http.get(`/api/committees/${committeeUid}/surveys`).pipe( - catchError(() => of([])), - ); + return this.http.get(`/api/committees/${committeeUid}/surveys`); } public getSurvey(surveyUid: string, projectId?: string): Observable { From b1e3adaf2c1973754f48336adc7ef43e92276aac Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Tue, 17 Mar 2026 16:21:15 -0700 Subject: [PATCH 25/48] fix(committees): address review findings on surveys tab PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix loading flicker in surveys list by setting loading=true inside inner switchMap before service call - Add access enrichment to mailing-list update flows (updateService, updateMailingList, updateMember) — consistent with create/get flows - Add TODO for polling staleness concern on update flows Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-surveys-list.component.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 05632e9e7..f5c39c008 100644 --- 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 @@ -86,15 +86,16 @@ export class CommitteeSurveysListComponent { this.loading.set(true); this.loadError.set(false); return this.refresh$.pipe( - switchMap(() => - this.surveyService.getSurveysByCommittee(committeeUid).pipe( + switchMap(() => { + this.loading.set(true); + return this.surveyService.getSurveysByCommittee(committeeUid).pipe( catchError(() => { this.loadError.set(true); return of([]); }), finalize(() => this.loading.set(false)) - ) - ) + ); + }) ); }) ), From a386498ddd897ba52f1a8d7dbf4f41715e07b6ca Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Tue, 17 Mar 2026 16:31:18 -0700 Subject: [PATCH 26/48] fix(committees): reset survey drawer state when committeeUid changes Close the results drawer and clear selected survey when navigating to a different committee, preventing stale drawer content. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-surveys-list/committee-surveys-list.component.ts | 3 +++ 1 file changed, 3 insertions(+) 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 index f5c39c008..b768e5e88 100644 --- 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 @@ -85,6 +85,9 @@ export class CommitteeSurveysListComponent { this.loading.set(true); this.loadError.set(false); + this.resultsDrawerVisible.set(false); + this.selectedSurveyId.set(null); + this.selectedListSurvey.set(null); return this.refresh$.pipe( switchMap(() => { this.loading.set(true); From e1f79de07b06c8d55dd59607a8a46530a5f1e32f Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 07:35:00 -0700 Subject: [PATCH 27/48] feat(committees): add committee detail shell with dashboard sub-resources and tab views Includes: - Committee detail shell with overview, members, settings, meetings, and votes tabs - BFF endpoints for dashboard sub-resources (votes, resolutions, activity, etc.) - Committee create/edit form - Vote results integration with reusable table UI - Key topics, observers stat card, and meeting summary on overview LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 76 ------ .../committee-view.component.ts | 60 +---- .../application-review.component.html | 101 -------- .../application-review.component.ts | 144 ----------- .../assign-leadership-dialog.component.html | 79 ------ .../assign-leadership-dialog.component.ts | 154 ------------ .../committee-members.component.html | 10 - .../committee-members.component.ts | 27 -- .../committee-surveys-list.component.html | 50 ---- .../committee-surveys-list.component.ts | 108 -------- .../invite-member-dialog.component.html | 65 ----- .../invite-member-dialog.component.ts | 104 -------- .../join-application-dialog.component.html | 58 ----- .../join-application-dialog.component.ts | 88 ------- .../survey-manage/survey-manage.component.ts | 13 - .../app/shared/services/committee.service.ts | 26 -- .../src/app/shared/services/survey.service.ts | 4 - .../controllers/committee.controller.ts | 234 +----------------- .../src/server/routes/committees.route.ts | 13 - .../src/server/services/committee.service.ts | 76 ------ .../src/server/services/survey.service.ts | 28 --- .../committee-application.interface.ts | 39 --- packages/shared/src/interfaces/index.ts | 3 - 23 files changed, 3 insertions(+), 1557 deletions(-) delete mode 100644 apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html delete mode 100644 apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts delete mode 100644 apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.html delete mode 100644 apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.ts delete mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.html delete mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.ts delete mode 100644 apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html delete mode 100644 apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts delete mode 100644 apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.html delete mode 100644 apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts delete mode 100644 packages/shared/src/interfaces/committee-application.interface.ts 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 7dc85d947..87634e11a 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 @@ -133,7 +133,6 @@

{{ committee()?.name }}

Votes } Meetings - Surveys Documents @if (canManageConfigurations()) { Settings @@ -803,19 +802,6 @@

Leadership

- @if (canManageConfigurations() && !hasChair() && !hasCoChair()) { - - - }
@@ -835,16 +821,6 @@

Since {{ chairElectedDate() }}

}

- @if (canManageConfigurations()) { - - }

} @else {
@@ -853,14 +829,6 @@

Vacant

- @if (canManageConfigurations()) { - - }

} @@ -883,16 +851,6 @@

Since {{ coChairElectedDate() }}

}

- @if (canManageConfigurations()) { - - }
} @else {
@@ -901,14 +859,6 @@

Vacant

- @if (canManageConfigurations()) { - - }
} @@ -960,11 +910,6 @@

- - @if (committee()?.uid) { - - } - @if (committee()?.uid) { - @if (committee(); as c) { -
- @if (!isBoardMember()) { - - - } -
- - - } - -
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 01356f4d6..8383501b4 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 @@ -31,14 +31,12 @@ import { CommitteeDiscussionThread, CommitteeEngagementMetrics, CommitteeEvent, - CommitteeLeadership, CommitteeMember, CommitteeOutreachCampaign, CommitteeResolution, CommitteeVote, getCommitteeCategorySeverity, GroupBehavioralClass, - LeadershipRole, TagSeverity, } from '@lfx-one/shared'; import { Meeting, PastMeeting, PastMeetingSummary } from '@lfx-one/shared/interfaces'; @@ -49,16 +47,13 @@ import { MeetingService } from '@services/meeting.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 { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; -import { ApplicationReviewComponent } from '../components/application-review/application-review.component'; -import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; import { CommitteeSettingsComponent } from '../components/committee-settings/committee-settings.component'; -import { CommitteeSurveysListComponent } from '../components/committee-surveys-list/committee-surveys-list.component'; import { CommitteeVotesListComponent } from '../components/committee-votes-list/committee-votes-list.component'; @Component({ @@ -70,7 +65,6 @@ import { CommitteeVotesListComponent } from '../components/committee-votes-list/ TagComponent, RouterLink, ConfirmDialogModule, - DynamicDialogModule, TooltipModule, Tabs, TabList, @@ -81,14 +75,12 @@ import { CommitteeVotesListComponent } from '../components/committee-votes-list/ DecimalPipe, CommitteeMembersComponent, CommitteeSettingsComponent, - ApplicationReviewComponent, MeetingCardComponent, ReactiveFormsModule, NgClass, - CommitteeSurveysListComponent, CommitteeVotesListComponent, ], - providers: [ConfirmationService, DialogService], + providers: [ConfirmationService], templateUrl: './committee-view.component.html', styleUrl: './committee-view.component.scss', }) @@ -98,7 +90,6 @@ export class CommitteeViewComponent { private readonly router = inject(Router); private readonly committeeService = inject(CommitteeService); private readonly meetingService = inject(MeetingService); - private readonly dialogService = inject(DialogService); private readonly messageService = inject(MessageService); private readonly personaService = inject(PersonaService); @@ -255,17 +246,6 @@ export class CommitteeViewComponent { this.refresh.next(); } - public createSurvey(): void { - const committee = this.committee(); - if (!committee) return; - this.router.navigate(['/surveys/create'], { - queryParams: { - committee_uid: committee.uid, - committee_name: committee.name, - }, - }); - } - public getMembersCountByOrg(org: string): number { return this.members().filter((m) => m.organization?.name === org).length; } @@ -310,42 +290,6 @@ export class CommitteeViewComponent { }); } - public openAssignLeadership(role: LeadershipRole): void { - const committee = this.committee(); - if (!committee) return; - - const currentLeader = role === 'chair' ? this.chair() : this.coChair(); - const roleLabel = role === 'chair' ? 'Assign Chair' : 'Assign Co-Chair'; - - const dialogRef = this.dialogService.open(AssignLeadershipDialogComponent, { - header: roleLabel, - width: '500px', - modal: true, - closable: true, - data: { - role, - committee, - members: this.members(), - currentLeader: currentLeader ?? null, - }, - }) as DynamicDialogRef; - - dialogRef.onClose.pipe(take(1)).subscribe((result: { role: LeadershipRole; leadership: CommitteeLeadership | null } | undefined) => { - if (result) { - const current = this.committee(); - if (current) { - const updated = { ...current }; - if (result.role === 'chair') { - updated.chair = result.leadership; - } else { - updated.co_chair = result.leadership; - } - this.committeeSignal.set(updated); - } - } - }); - } - // -- Private initializer functions -- private initializeCommittee(): void { combineLatest([this.route.paramMap, this.refresh]) diff --git a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html deleted file mode 100644 index 1cd672c70..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.html +++ /dev/null @@ -1,101 +0,0 @@ - - - -@if (isApplyMode() && canReview()) { - -
-
-

Join Requests

- @if (pendingCount() > 0) { - - {{ pendingCount() }} - - } -
-
- - @if (loading()) { - -
- @for (_ of [1, 2]; track _) { -
-
-
-
-
-
-
-
-
-
-
-
-
- } -
- } @else if (pendingCount() === 0) { - -
- -

No pending requests

-

New join requests will appear here for review.

-
- } @else { - -
- @for (app of pendingApplications(); track app.uid) { -
-
- -
-
-
- {{ (app.applicant_name || app.applicant_email || '?')[0] | uppercase }} -
-
-

- {{ app.applicant_name || app.applicant_email }} -

- @if (app.applicant_name && app.applicant_email) { -

{{ app.applicant_email }}

- } -
-
- - @if (app.reason) { -
-

"{{ app.reason }}"

-
- } - -

Applied {{ app.created_at | date: 'MMM d, yyyy' }}

-
- - -
- - - - -
-
-
- } -
- } -
-} diff --git a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts b/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts deleted file mode 100644 index 2ff0ed37a..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/application-review/application-review.component.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -import { DatePipe, UpperCasePipe } from '@angular/common'; -import { Component, computed, effect, inject, input, output, signal, Signal } from '@angular/core'; -import { ButtonComponent } from '@components/button/button.component'; -import { CardComponent } from '@components/card/card.component'; -import { Committee, GroupJoinApplication } from '@lfx-one/shared/interfaces'; -import { CommitteeService } from '@services/committee.service'; -import { MessageService } from 'primeng/api'; - -@Component({ - selector: 'lfx-application-review', - imports: [DatePipe, UpperCasePipe, CardComponent, ButtonComponent], - templateUrl: './application-review.component.html', -}) -export class ApplicationReviewComponent { - private readonly committeeService = inject(CommitteeService); - private readonly messageService = inject(MessageService); - - // Inputs - public committee = input.required(); - - // Outputs - public readonly memberAdded = output(); - - // State - public applications = signal([]); - public loading = signal(true); - public processingId = signal(null); - - // Permissions — only writers can review applications - public canReview: Signal = computed(() => !!this.committee()?.writer); - - // Only show this section if the group uses the 'application' join mode - public isApplyMode: Signal = computed(() => { - return this.committee()?.join_mode === 'application'; - }); - - public pendingApplications: Signal = computed(() => { - return this.applications().filter((a) => a.status === 'pending'); - }); - - public pendingCount: Signal = computed(() => { - return this.pendingApplications().length; - }); - - public constructor() { - effect(() => { - const c = this.committee(); - if (!c?.uid) { - this.applications.set([]); - this.loading.set(false); - return; - } - if (c.join_mode !== 'application') { - this.applications.set([]); - this.loading.set(false); - return; - } - if (!c.writer) { - this.applications.set([]); - this.loading.set(false); - return; - } - this.loadApplications(c.uid); - }); - } - - public loadApplications(committeeUid: string): void { - this.applications.set([]); - this.loading.set(true); - this.committeeService.getApplications(committeeUid).subscribe({ - next: (apps) => { - if (this.committee()?.uid !== committeeUid) return; - this.applications.set(apps); - this.loading.set(false); - }, - error: () => { - if (this.committee()?.uid !== committeeUid) return; - this.applications.set([]); - this.loading.set(false); - }, - }); - } - - public approve(application: GroupJoinApplication): void { - const c = this.committee(); - if (!c?.uid) return; - - this.processingId.set(application.uid); - - this.committeeService.approveApplication(c.uid, application.uid).subscribe({ - next: () => { - this.processingId.set(null); - this.messageService.add({ - severity: 'success', - summary: 'Application Approved', - detail: `${application.applicant_name || application.applicant_email} has been added to the group.`, - }); - // Remove from list - this.applications.update((apps) => apps.filter((a) => a.uid !== application.uid)); - // Notify parent to refresh members - this.memberAdded.emit(); - }, - error: () => { - this.processingId.set(null); - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to approve application. Please try again.', - }); - }, - }); - } - - public reject(application: GroupJoinApplication): void { - const c = this.committee(); - if (!c?.uid) return; - - this.processingId.set(application.uid); - - this.committeeService.rejectApplication(c.uid, application.uid).subscribe({ - next: () => { - this.processingId.set(null); - this.messageService.add({ - severity: 'info', - summary: 'Application Declined', - detail: `Request from ${application.applicant_name || application.applicant_email} has been declined.`, - }); - // Remove from list - this.applications.update((apps) => apps.filter((a) => a.uid !== application.uid)); - }, - error: () => { - this.processingId.set(null); - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to decline application. Please try again.', - }); - }, - }); - } -} 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 deleted file mode 100644 index 040bfed91..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.html +++ /dev/null @@ -1,79 +0,0 @@ - - - -
- -
- - - -
- - -
- - - -
- - -
-
- @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 deleted file mode 100644 index 377fb0174..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -import { Component, computed, inject, signal, 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 { 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: Signal<{ 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; - - 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 }, - }); - } - 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()}`, - }); - }, - }); - } - - 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(): Signal<{ label: string; value: string }[]> { - return computed(() => - 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 b250bd2a2..41cd07993 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 @@ -19,16 +19,6 @@

{{ committeeLabel.singular }} Memb data-testid="add-member-btn"> } - @if (canInviteMembers()) { - - - }

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 6e68dcbbf..16285b72f 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 @@ -20,7 +20,6 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog'; import { debounceTime, distinctUntilChanged, startWith, take } from 'rxjs'; -import { InviteMemberDialogComponent } from '../invite-member-dialog/invite-member-dialog.component'; import { MemberFormComponent } from '../member-form/member-form.component'; @Component({ @@ -66,7 +65,6 @@ export class CommitteeMembersComponent implements OnInit { public isBoardMember: Signal; public isMaintainer: Signal; public canManageMembers: Signal; - public canInviteMembers: Signal; public isMembersVisible: Signal; // Filter-related variables @@ -93,12 +91,6 @@ export class CommitteeMembersComponent implements OnInit { const visibility = this.committee()?.member_visibility; return visibility !== 'hidden' || this.canManageMembers(); }); - // Invite requires both a compatible join_mode and management permission - this.canInviteMembers = computed(() => { - const committee = this.committee(); - const hasInviteMode = committee?.join_mode === 'invite_only' || committee?.join_mode === 'open'; - return hasInviteMode && this.canManageMembers(); - }); // Initialize filter form this.filterForm = this.initializeFilterForm(); this.searchTerm = this.initializeSearchTerm(); @@ -144,25 +136,6 @@ export class CommitteeMembersComponent implements OnInit { }); } - public openInviteMemberDialog(): void { - const dialogRef = this.dialogService.open(InviteMemberDialogComponent, { - header: 'Invite Members', - width: '550px', - modal: true, - closable: true, - duplicate: true, - data: { - committee: this.committee(), - }, - }); - - dialogRef?.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { - if (result) { - this.refreshMembers(); - } - }); - } - private editMember(): void { const member = this.selectedMember(); if (member) { 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 deleted file mode 100644 index deeee91be..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.html +++ /dev/null @@ -1,50 +0,0 @@ - - - -@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 deleted file mode 100644 index b768e5e88..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.ts +++ /dev/null @@ -1,108 +0,0 @@ -// 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 { BehaviorSubject, 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 committeeName = input.required(); - public readonly hasPMOAccess = input(false); - - // === Subjects === - private readonly refresh$ = new BehaviorSubject(undefined); - - // === Writable Signals === - 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.refresh$.next(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected onDuplicateSurvey(surveyId: string): void { - this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey duplication is not yet available' }); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected onCloseSurvey(surveyId: string): 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 this.refresh$.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/invite-member-dialog/invite-member-dialog.component.html b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html deleted file mode 100644 index a9c73be5e..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.html +++ /dev/null @@ -1,65 +0,0 @@ - - - -
- -

- Invite colleagues to join {{ committee?.display_name || committee?.name }}. They will receive an email with a one-click link to accept. -

- - -
- - - - @if (form.get('emails')?.touched && form.get('emails')?.hasError('required')) { - At least one email address is required - } - @if (invalidAddresses().length > 0) { -
- The following addresses are invalid and must be corrected before sending: -
    - @for (addr of invalidAddresses(); track addr) { -
  • {{ addr }}
  • - } -
-
- } -
- - -
- - - -
- - -
- - - -
-
diff --git a/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts b/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts deleted file mode 100644 index f82c96735..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/invite-member-dialog/invite-member-dialog.component.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -import { Component, inject, signal } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { ButtonComponent } from '@components/button/button.component'; -import { TextareaComponent } from '@components/textarea/textarea.component'; -import { Committee, CreateGroupInviteRequest } from '@lfx-one/shared/interfaces'; -import { CommitteeService } from '@services/committee.service'; -import { MessageService } from 'primeng/api'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -@Component({ - selector: 'lfx-invite-member-dialog', - imports: [ReactiveFormsModule, ButtonComponent, TextareaComponent], - templateUrl: './invite-member-dialog.component.html', -}) -export class InviteMemberDialogComponent { - private readonly config = inject(DynamicDialogConfig); - private readonly dialogRef = inject(DynamicDialogRef); - private readonly committeeService = inject(CommitteeService); - private readonly messageService = inject(MessageService); - - public readonly committee: Committee | undefined = this.config.data?.committee; - public submitting = signal(false); - public invalidAddresses = signal([]); - - public form = new FormGroup({ - emails: new FormControl('', [Validators.required]), - message: new FormControl(''), - }); - - public onCancel(): void { - this.dialogRef.close(); - } - - public onSubmit(): void { - if (!this.form.valid || !this.committee) { - Object.keys(this.form.controls).forEach((key) => { - this.form.get(key)?.markAsTouched(); - }); - return; - } - - const rawEmails = this.form.value.emails || ''; - // Split by comma, semicolon, newline, or space — then trim and de-dup - const parsed = [ - ...new Set( - rawEmails - .split(/[,;\n\s]+/) - .map((e: string) => e.trim().toLowerCase()) - .filter((e: string) => e.length > 0) - ), - ]; - - const validEmails = parsed.filter((e) => EMAIL_REGEX.test(e)); - const invalidEmails = parsed.filter((e) => !EMAIL_REGEX.test(e)); - - if (invalidEmails.length > 0) { - this.invalidAddresses.set(invalidEmails); - return; - } - - this.invalidAddresses.set([]); - - if (validEmails.length === 0) { - this.messageService.add({ - severity: 'warn', - summary: 'No valid emails', - detail: 'Please enter at least one valid email address.', - }); - return; - } - - this.submitting.set(true); - - const payload: CreateGroupInviteRequest = { - emails: validEmails, - message: this.form.value.message || undefined, - }; - - this.committeeService.createInvites(this.committee.uid, payload).subscribe({ - next: (invites) => { - this.submitting.set(false); - this.messageService.add({ - severity: 'success', - summary: 'Invites Sent', - detail: `${invites.length} invite(s) sent successfully`, - }); - this.dialogRef.close(true); - }, - error: () => { - this.submitting.set(false); - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to send invites. Please try again.', - }); - }, - }); - } -} diff --git a/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.html b/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.html deleted file mode 100644 index 6d0976774..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.html +++ /dev/null @@ -1,58 +0,0 @@ - - - -
- -
-

You are requesting to join:

-

{{ committee?.name }}

- @if (committee?.description) { -

{{ committee?.description }}

- } -
- - -
- -

This group requires admin approval. Your request will be reviewed by a group maintainer.

-
- - -
- -
- - - -
- @if (form.controls.reason.touched && form.controls.reason.errors) { - - @if (form.controls.reason.errors['required']) { - Please provide a reason for joining. - } @else if (form.controls.reason.errors['minlength']) { - Please provide at least 10 characters. - } - - } @else { - - } - {{ reasonLength }}/500 -
-
- - -
- - - -
-
-
diff --git a/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts b/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts deleted file mode 100644 index 3a82472b4..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/join-application-dialog/join-application-dialog.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -import { Component, inject, signal } from '@angular/core'; -import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; -import { ButtonComponent } from '@components/button/button.component'; -import { TextareaComponent } from '@components/textarea/textarea.component'; -import { Committee, GroupJoinApplicationRequest } from '@lfx-one/shared/interfaces'; -import { CommitteeService } from '@services/committee.service'; -import { MessageService } from 'primeng/api'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -@Component({ - selector: 'lfx-join-application-dialog', - imports: [ReactiveFormsModule, ButtonComponent, TextareaComponent], - templateUrl: './join-application-dialog.component.html', -}) -export class JoinApplicationDialogComponent { - private readonly config = inject(DynamicDialogConfig); - private readonly dialogRef = inject(DynamicDialogRef); - private readonly committeeService = inject(CommitteeService); - private readonly messageService = inject(MessageService); - - public readonly committee: Committee | undefined = this.config.data?.committee; - public submitting = signal(false); - - public form = new FormGroup({ - reason: new FormControl('', [ - JoinApplicationDialogComponent.trimmedRequired, - JoinApplicationDialogComponent.trimmedMinLength(10), - Validators.maxLength(500), - ]), - }); - - public get reasonLength(): number { - return this.form.value.reason?.length || 0; - } - - public onCancel(): void { - this.dialogRef.close(); - } - - public onSubmit(): void { - if (!this.form.valid || !this.committee) { - Object.keys(this.form.controls).forEach((key) => { - this.form.get(key)?.markAsTouched(); - }); - return; - } - - this.submitting.set(true); - - const payload: GroupJoinApplicationRequest = { - reason: this.form.value.reason?.trim() || undefined, - }; - - this.committeeService.applyToJoin(this.committee.uid, payload).subscribe({ - next: () => { - this.submitting.set(false); - this.messageService.add({ - severity: 'success', - summary: 'Application Submitted', - detail: `Your request to join "${this.committee?.name}" has been submitted for review.`, - }); - this.dialogRef.close(true); - }, - error: () => { - this.submitting.set(false); - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to submit your application. Please try again.', - }); - }, - }); - } - - private static trimmedRequired(control: AbstractControl): ValidationErrors | null { - return (control.value as string)?.trim().length ? null : { required: true }; - } - - private static trimmedMinLength(min: number): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - const trimmed = (control.value as string)?.trim() ?? ''; - return trimmed.length >= min ? null : { minlength: { requiredLength: min, actualLength: trimmed.length } }; - }; - } -} 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 f148dbb8b..c4bcdf9a5 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,10 +72,6 @@ export class SurveyManageComponent { public currentStep: Signal = this.initCurrentStep(); public readonly submitButtonLabel: Signal = this.initSubmitButtonLabel(); - constructor() { - this.preselectCommitteeFromQueryParams(); - } - public nextStep(): void { const next = this.currentStep() + 1; if (next <= this.totalSteps && this.canNavigateToStep(next)) { @@ -388,15 +384,6 @@ 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 }]); - } - } - private markAllFormControlsAsTouched(): void { markFormControlsAsTouched(this.form()); } 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 7920290e0..0e0d1d060 100644 --- a/apps/lfx-one/src/app/shared/services/committee.service.ts +++ b/apps/lfx-one/src/app/shared/services/committee.service.ts @@ -19,10 +19,6 @@ import { CreateCommitteeMemberRequest, Meeting, PaginatedResponse, - CreateGroupInviteRequest, - GroupInvite, - GroupJoinApplication, - GroupJoinApplicationRequest, QueryServiceCountResponse, } from '@lfx-one/shared/interfaces'; import { catchError, map, Observable, of, take, tap, throwError } from 'rxjs'; @@ -158,26 +154,4 @@ export class CommitteeService { public getCommitteeBudget(committeeId: string): Observable { return this.http.get(`/api/committees/${committeeId}/budget`).pipe(catchError(() => of(null))); } - - // Invite methods - public createInvites(committeeId: string, payload: CreateGroupInviteRequest): Observable { - return this.http.post(`/api/committees/${committeeId}/invites`, payload).pipe(take(1)); - } - - // Application methods - public applyToJoin(committeeId: string, payload: GroupJoinApplicationRequest): Observable { - return this.http.post(`/api/committees/${committeeId}/applications`, payload).pipe(take(1)); - } - - public getApplications(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/applications`).pipe(catchError(() => of([]))); - } - - public approveApplication(committeeId: string, applicationId: string): Observable { - return this.http.post(`/api/committees/${committeeId}/applications/${applicationId}/approve`, {}).pipe(take(1)); - } - - public rejectApplication(committeeId: string, applicationId: string): Observable { - return this.http.post(`/api/committees/${committeeId}/applications/${applicationId}/reject`, {}).pipe(take(1)); - } } 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 3d9a99d16..ad7ddadb5 100644 --- a/apps/lfx-one/src/app/shared/services/survey.service.ts +++ b/apps/lfx-one/src/app/shared/services/survey.service.ts @@ -35,10 +35,6 @@ 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 1f5f7c9e1..c56bb8065 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -1,26 +1,18 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { - CommitteeCreateData, - CommitteeUpdateData, - CreateCommitteeMemberRequest, - CreateGroupInviteRequest, - GroupJoinApplicationRequest, -} from '@lfx-one/shared/interfaces'; +import { CommitteeCreateData, CommitteeUpdateData, CreateCommitteeMemberRequest } from '@lfx-one/shared/interfaces'; 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(); - private surveyService: SurveyService = new SurveyService(); /** * GET /committees @@ -712,196 +704,6 @@ export class CommitteeController { } } - /** - * POST /committees/:id/invites - */ - public async createInvites(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'create_invites', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'create_invites', { committee_id: committeeId }); - - try { - const payload: CreateGroupInviteRequest = req.body; - const invites = await this.committeeService.createInvites(req, committeeId, payload); - - logger.success(req, 'create_invites', startTime, { - committee_id: committeeId, - invite_count: Array.isArray(invites) ? invites.length : 1, - }); - - res.status(201).json(invites); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/invites - */ - public async getInvites(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_invites', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_invites', { committee_id: committeeId }); - - try { - const invites = await this.committeeService.getInvites(req, committeeId); - - logger.success(req, 'get_invites', startTime, { - committee_id: committeeId, - invite_count: invites.length, - }); - - res.json(invites); - } catch (error) { - next(error); - } - } - - /** - * POST /committees/:id/applications - */ - public async applyToJoin(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'apply_to_join', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'apply_to_join', { committee_id: committeeId }); - - try { - const payload: GroupJoinApplicationRequest = req.body; - const application = await this.committeeService.applyToJoin(req, committeeId, payload); - - logger.success(req, 'apply_to_join', startTime, { committee_id: committeeId }); - res.status(201).json(application); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/applications - */ - public async getApplications(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_applications', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_applications', { committee_id: committeeId }); - - try { - const applications = await this.committeeService.getApplications(req, committeeId); - - logger.success(req, 'get_applications', startTime, { - committee_id: committeeId, - application_count: applications.length, - }); - - res.json(applications); - } catch (error) { - next(error); - } - } - - /** - * POST /committees/:id/applications/:applicationId/approve - */ - public async approveApplication(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - const applicationId = req.params['applicationId']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'approve_application', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - if (!applicationId) { - const validationError = ServiceValidationError.forField('applicationId', 'Application ID is required', { - operation: 'approve_application', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'approve_application', { committee_id: committeeId, application_id: applicationId }); - - try { - const application = await this.committeeService.approveApplication(req, committeeId, applicationId); - - logger.success(req, 'approve_application', startTime, { committee_id: committeeId, application_id: applicationId }); - res.json(application); - } catch (error) { - next(error); - } - } - - /** - * POST /committees/:id/applications/:applicationId/reject - */ - public async rejectApplication(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - const applicationId = req.params['applicationId']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'reject_application', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - if (!applicationId) { - const validationError = ServiceValidationError.forField('applicationId', 'Application ID is required', { - operation: 'reject_application', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'reject_application', { committee_id: committeeId, application_id: applicationId }); - - try { - const application = await this.committeeService.rejectApplication(req, committeeId, applicationId); - - logger.success(req, 'reject_application', startTime, { committee_id: committeeId, application_id: applicationId }); - res.json(application); - } catch (error) { - next(error); - } - } - /** * GET /committees/:id/meetings */ @@ -936,38 +738,4 @@ export class CommitteeController { next(error); } } - - /** - * GET /committees/:id/surveys - */ - 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) { - 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 92e1b730c..d9b5f6e38 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -39,17 +39,4 @@ router.get('/:id/campaigns', (req, res, next) => committeeController.getCommitte router.get('/:id/engagement', (req, res, next) => committeeController.getCommitteeEngagement(req, res, next)); router.get('/:id/budget', (req, res, next) => committeeController.getCommitteeBudget(req, res, next)); -// Survey routes -router.get('/:id/surveys', (req, res, next) => committeeController.getCommitteeSurveys(req, res, next)); - -// Invite routes -router.post('/:id/invites', (req, res, next) => committeeController.createInvites(req, res, next)); -router.get('/:id/invites', (req, res, next) => committeeController.getInvites(req, res, next)); - -// Application routes -router.post('/:id/applications', (req, res, next) => committeeController.applyToJoin(req, res, next)); -router.get('/:id/applications', (req, res, next) => committeeController.getApplications(req, res, next)); -router.post('/:id/applications/:applicationId/approve', (req, res, next) => committeeController.approveApplication(req, res, next)); -router.post('/:id/applications/:applicationId/reject', (req, res, next) => committeeController.rejectApplication(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 10804060b..49b5c1ff4 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -8,10 +8,6 @@ import { CommitteeSettingsData, CommitteeUpdateData, CreateCommitteeMemberRequest, - CreateGroupInviteRequest, - GroupInvite, - GroupJoinApplication, - GroupJoinApplicationRequest, Meeting, PaginatedResponse, QueryServiceCountResponse, @@ -553,78 +549,6 @@ export class CommitteeService { } } - // ── Invite Methods ────────────────────────────────────────────────────────── - - public async createInvites(req: Request, committeeId: string, payload: CreateGroupInviteRequest): Promise { - const result = await this.microserviceProxy.proxyRequest( - req, - 'LFX_V2_SERVICE', - `/committees/${committeeId}/invites`, - 'POST', - {}, - payload - ); - return Array.isArray(result) ? result : [result]; - } - - public async getInvites(req: Request, committeeId: string): Promise { - try { - const result = await this.microserviceProxy.proxyRequest>( - req, - 'LFX_V2_SERVICE', - `/committees/${committeeId}/invites`, - 'GET' - ); - return Array.isArray(result) ? result : result?.resources?.map((r) => r.data) || []; - } catch { - logger.warning(req, 'get_invites', 'Failed to fetch invites, returning empty', { - committee_uid: committeeId, - }); - return []; - } - } - - // ── Application Methods ───────────────────────────────────────────────────── - - public async applyToJoin(req: Request, committeeId: string, payload: GroupJoinApplicationRequest): Promise { - return this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/applications`, 'POST', {}, payload); - } - - public async getApplications(req: Request, committeeId: string): Promise { - try { - const result = await this.microserviceProxy.proxyRequest>( - req, - 'LFX_V2_SERVICE', - `/committees/${committeeId}/applications`, - 'GET' - ); - return Array.isArray(result) ? result : result?.resources?.map((r) => r.data) || []; - } catch { - logger.warning(req, 'get_applications', 'Failed to fetch applications, returning empty', { - committee_uid: committeeId, - }); - return []; - } - } - - public async approveApplication(req: Request, committeeId: string, applicationId: string): Promise { - return this.microserviceProxy.proxyRequest( - req, - 'LFX_V2_SERVICE', - `/committees/${committeeId}/applications/${applicationId}/approve`, - 'POST' - ); - } - - public async rejectApplication(req: Request, committeeId: string, applicationId: string): Promise { - return this.microserviceProxy.proxyRequest( - req, - 'LFX_V2_SERVICE', - `/committees/${committeeId}/applications/${applicationId}/reject`, - 'POST' - ); - } - /** * Fetches meetings associated with a committee. */ diff --git a/apps/lfx-one/src/server/services/survey.service.ts b/apps/lfx-one/src/server/services/survey.service.ts index aea287daa..89195c2d9 100644 --- a/apps/lfx-one/src/server/services/survey.service.ts +++ b/apps/lfx-one/src/server/services/survey.service.ts @@ -89,34 +89,6 @@ 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, - }); - - const params = { - ...query, - committee_uid: committeeId, - type: 'survey', - }; - - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); - - const surveys: Survey[] = (resources ?? []) - .map((resource) => resource.data) - .filter((s) => s?.committees?.some((c) => c.committee_uid === committeeId)); - - 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 7e49f2dac..000000000 --- a/packages/shared/src/interfaces/committee-application.interface.ts +++ /dev/null @@ -1,39 +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; -} - -/** - * Alias for CreateCommitteeJoinApplicationRequest used in group-context components - */ -export type GroupJoinApplicationRequest = CreateCommitteeJoinApplicationRequest; - -/** - * Alias for CommitteeJoinApplication used in group-context components - */ -export type GroupJoinApplication = CommitteeJoinApplication; 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'; From 1973bb1efab7d3bac40d6787312d3ba37d9a705d Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 08:22:33 -0700 Subject: [PATCH 28/48] feat(committees): extract members and votes tabs for separate PRs Remove members tab and votes tab code to be submitted in stacked PRs. Keeps: detail shell, overview tab, BFF sub-resource endpoints, settings tab, meetings tab, documents tab, committee create/edit form. LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 36 -- .../committee-view.component.ts | 21 +- .../committee-members.component.html | 332 ++++++++---------- .../committee-members.component.ts | 39 +- .../committee-votes-list.component.html | 38 -- .../committee-votes-list.component.ts | 177 ---------- .../member-form/member-form.component.html | 225 ++++-------- .../member-form/member-form.component.ts | 145 ++------ .../votes-table/votes-table.component.html | 26 +- .../votes-table/votes-table.component.ts | 1 - 10 files changed, 274 insertions(+), 766 deletions(-) delete mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.html delete mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.ts 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 87634e11a..454983c94 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 @@ -126,12 +126,6 @@

{{ committee()?.name }}

Overview - @if (isMembersTabVisible()) { - Members - } - @if (isVotesTabVisible()) { - Votes - } Meetings Documents @if (canManageConfigurations()) { @@ -906,36 +900,6 @@

-
- - @if (committee()?.uid) { - - - } -
- - } - - - @if (isVotesTabVisible()) { - -
- @if (committee(); as c) { - - - } -
-
- } -
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 8383501b4..749e3d9d5 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 @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, effect, inject, signal, Signal, WritableSignal } from '@angular/core'; +import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; import { DatePipe, DecimalPipe, NgClass } from '@angular/common'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -52,9 +52,7 @@ import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; -import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; import { CommitteeSettingsComponent } from '../components/committee-settings/committee-settings.component'; -import { CommitteeVotesListComponent } from '../components/committee-votes-list/committee-votes-list.component'; @Component({ selector: 'lfx-committee-view', @@ -73,12 +71,10 @@ import { CommitteeVotesListComponent } from '../components/committee-votes-list/ TabPanel, DatePipe, DecimalPipe, - CommitteeMembersComponent, CommitteeSettingsComponent, MeetingCardComponent, ReactiveFormsModule, NgClass, - CommitteeVotesListComponent, ], providers: [ConfirmationService], templateUrl: './committee-view.component.html', @@ -151,10 +147,6 @@ export class CommitteeViewComponent { public isMaintainer: Signal = computed(() => this.personaService.currentPersona() === 'maintainer'); public canManageConfigurations: Signal = computed(() => this.isMaintainer() || (!!this.committee()?.writer && !this.isBoardMember())); - // -- Tab visibility signals -- - public isMembersTabVisible: Signal = computed(() => this.committee()?.member_visibility !== 'hidden' || this.canManageConfigurations()); - public isVotesTabVisible: Signal = computed(() => !!this.committee()?.enable_voting); - // -- Behavioral class signals -- public behavioralClass: Signal = computed(() => getGroupBehavioralClass(this.committee()?.category)); public isGovernanceClass: Signal = computed(() => isGovernanceClass(this.committee()?.category)); @@ -223,13 +215,6 @@ export class CommitteeViewComponent { public constructor() { this.initializeCommittee(); - - // Redirect away from votes tab when voting is disabled - effect(() => { - if (!this.isVotesTabVisible() && this.activeTab() === 'votes') { - this.activeTab.set('overview'); - } - }); } // -- Public methods -- @@ -242,10 +227,6 @@ export class CommitteeViewComponent { this.refresh.next(); } - public refreshMembers(): void { - this.refresh.next(); - } - public getMembersCountByOrg(org: string): number { return this.members().filter((m) => m.organization?.name === org).length; } 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 41cd07993..9f3d2242e 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,209 +6,171 @@

{{ 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) { - Name + Role - Email + Voting Status - - Organization - - @if (committee()?.enable_voting) { - - Role - - - Voting Status - - } - -
- Actions -
- - -
+ } + +
+ Actions +
+ + +
- - - - - {{ (member.first_name || '') + ' ' + (member.last_name || '') | titlecase }} - - + + + + + {{ (member.first_name || '') + ' ' + (member.last_name || '') | titlecase }} + + + + {{ member.email || '-' }} + + + @if (member.organization?.website) { + + {{ member.organization.name }} + + } @else { + {{ member.organization?.name || '-' }} + } + + @if (committee()?.enable_voting) { - {{ member.email || '-' }} + {{ member.role?.name || 'None' }} - @if (member.organization?.website) { - - {{ member.organization.name }} - - } @else { - {{ member.organization?.name || '-' }} - } + {{ member.voting?.status || 'None' }} - @if (committee()?.enable_voting) { - - {{ member.role?.name || 'None' }} - - - {{ member.voting?.status || 'None' }} - + } + + + @if (canManageMembers()) { + + + } @else { + + } - - - @if (canManageMembers()) { - - - } @else { - - - } + + + + + + + @if (membersLoading()) { + + + - - - - - @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()) { -
- -
- } -
- - - } -
-
- } + } @else { + + +
+ +

No members found

+
+ + + } + +
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 16285b72f..b3ee5d397 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,8 +1,8 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { isPlatformBrowser, TitleCasePipe } from '@angular/common'; -import { Component, computed, inject, input, OnInit, output, PLATFORM_ID, signal, Signal, WritableSignal } from '@angular/core'; +import { TitleCasePipe } from '@angular/common'; +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'; @@ -12,12 +12,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, GroupBehavioralClass } from '@lfx-one/shared/interfaces'; +import { Committee, CommitteeMember } 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 } from 'primeng/dynamicdialog'; +import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; import { debounceTime, distinctUntilChanged, startWith, take } from 'rxjs'; import { MemberFormComponent } from '../member-form/member-form.component'; @@ -47,13 +47,11 @@ export class CommitteeMembersComponent implements OnInit { 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(); @@ -63,9 +61,7 @@ export class CommitteeMembersComponent implements OnInit { 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; @@ -84,13 +80,7 @@ export class CommitteeMembersComponent implements OnInit { this.isDeleting = signal(false); // Initialize permission signals 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(); - }); + this.canManageMembers = computed(() => !this.isBoardMember() && !!this.committee()?.writer); // Initialize filter form this.filterForm = this.initializeFilterForm(); this.searchTerm = this.initializeSearchTerm(); @@ -119,7 +109,6 @@ export class CommitteeMembersComponent implements OnInit { width: '700px', modal: true, closable: true, - duplicate: true, data: { isEditing: false, committee: this.committee(), @@ -127,9 +116,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(); } @@ -144,7 +133,6 @@ export class CommitteeMembersComponent implements OnInit { width: '700px', modal: true, closable: true, - duplicate: true, data: { isEditing: true, memberId: member.uid, @@ -154,9 +142,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(); } @@ -207,8 +195,9 @@ 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', @@ -254,11 +243,7 @@ export class CommitteeMembersComponent implements OnInit { { label: 'Send Message', icon: 'fa-light fa-envelope', - command: () => { - if (isPlatformBrowser(this.platformId)) { - window.open(`mailto:${this.selectedMember()?.email}`, '_blank'); - } - }, + command: () => window.open(`mailto:${this.selectedMember()?.email}`, '_blank'), }, { separator: true, 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 deleted file mode 100644 index 725d7b65d..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.html +++ /dev/null @@ -1,38 +0,0 @@ - - - -@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 deleted file mode 100644 index 3b22ad4b7..000000000 --- a/apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.ts +++ /dev/null @@ -1,177 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -import { Component, computed, inject, input, signal, Signal } from '@angular/core'; -import { 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 { BehaviorSubject, 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); - - // === Subjects === - private readonly fetch$ = new BehaviorSubject(undefined); - private readonly refresh$ = new BehaviorSubject(undefined); - - // === 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(); - protected readonly totalCount: Signal = this.initTotalCount(); - - // === 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.fetch$.next(); - this.refresh$.next(); - } - - 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.fetch$.next(); - return; - } - - this.currentFirst.set(event.first); - this.fetch$.next(); - } - - 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 initTotalCount(): Signal { - const filters$ = toObservable(this.filters); - const projectUid$ = toObservable(this.projectUid); - const committeeName$ = toObservable(this.committeeName); - - return toSignal( - combineLatest([projectUid$, committeeName$, filters$, this.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( - tap((count) => this.totalRecords.set(count)), - catchError(() => { - this.totalRecords.set(0); - return of(0); - }) - ); - }) - ), - { initialValue: 0 } - ); - } - - private initVotes(): Signal { - const filters$ = toObservable(this.filters); - const projectUid$ = toObservable(this.projectUid); - const committeeName$ = toObservable(this.committeeName); - - return toSignal( - combineLatest([projectUid$, committeeName$, filters$, this.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 5afb1e92e..5fa2482c0 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,14 +8,7 @@
- + @if (form().get('first_name')?.errors?.['required'] && form().get('first_name')?.touched) {

First name is required

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

Last name is required

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

Email address is required

} @@ -63,14 +42,7 @@
- +
@@ -86,19 +58,9 @@ 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 (form().get('role')?.errors?.['required'] && form().get('role')?.touched) { -

Role is required

- } -
- - -
-
- - @if (form().get('role_start')?.value || form().get('role_end')?.value) { - - } -
-
- +
+
+ +
+ + - + [options]="roleOptions" + placeholder="Select role" + styleClass="w-full" + id="role">
- @if (form().errors?.['role_start_after_role_end']) { -

Role end date must be after start date

- } -
- -
- - - @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) { - - } + +
+ + +
-
- - +
+ + + id="voting-status"> +
+ + +
+ +
+ + +
+ + +
+ + +
- @if (form().errors?.['voting_status_start_after_voting_status_end']) { -

Voting end date must be after start date

- }
-
- -
- - + +
+ + +
-
+ }
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 c0ba4117c..f894d0799 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,16 +1,14 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { ChangeDetectorRef, Component, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms'; +import { Component, inject, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, 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 { APPOINTED_BY_OPTIONS, LINKEDIN_PROFILE_PATTERN, MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; +import { LINKEDIN_PROFILE_PATTERN, MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; import { Committee, CommitteeMember, CreateCommitteeMemberRequest } from '@lfx-one/shared/interfaces'; import { formatDateToISOString, parseISODateString } from '@lfx-one/shared/utils'; import { CommitteeService } from '@services/committee.service'; @@ -19,7 +17,7 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; @Component({ selector: 'lfx-member-form', - imports: [ReactiveFormsModule, ButtonComponent, SelectComponent, InputTextComponent, CalendarComponent, OrganizationSearchComponent, CheckboxComponent], + imports: [ReactiveFormsModule, ButtonComponent, SelectComponent, InputTextComponent, CalendarComponent, OrganizationSearchComponent], templateUrl: './member-form.component.html', styleUrl: './member-form.component.scss', }) @@ -28,7 +26,6 @@ export class MemberFormComponent { private readonly dialogRef = inject(DynamicDialogRef); private readonly committeeService = inject(CommitteeService); private readonly messageService = inject(MessageService); - private readonly cdr = inject(ChangeDetectorRef); // Loading state for form submissions public submitting = signal(false); @@ -48,7 +45,6 @@ export class MemberFormComponent { // Member options public roleOptions = MEMBER_ROLES; public votingStatusOptions = VOTING_STATUSES; - public appointedByOptions = APPOINTED_BY_OPTIONS; public constructor() { // Initialize config-based properties @@ -60,50 +56,6 @@ 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(); - this.cdr.detectChanges(); - } - - public clearVotingDates(): void { - this.form().get('voting_status_start')?.reset(); - this.form().get('voting_status_end')?.reset(); - this.form().updateValueAndValidity(); - this.cdr.detectChanges(); - } - - public onDateChange(): void { - this.form().updateValueAndValidity(); - this.cdr.detectChanges(); } public onCancel(): void { @@ -114,7 +66,7 @@ export class MemberFormComponent { public onSubmit(): void { if (this.form().valid) { this.submitting.set(true); - const formValue = this.form().getRawValue(); + const formValue = this.form().value; // Prepare member data using form values, mapping to new structure const memberData: CreateCommitteeMemberRequest = { @@ -138,7 +90,13 @@ export class MemberFormComponent { end_date: formatDateToISOString(formValue.voting_status_end) || null, } : null, - organization: this.buildOrganizationPayload(formValue), + organization: + formValue.organization || formValue.organization_url + ? { + name: formValue.organization || null, + website: formValue.organization_url || null, + } + : null, }; // In wizard mode, return the data without calling API @@ -176,6 +134,7 @@ export class MemberFormComponent { }, error: (error) => { this.submitting.set(false); + console.error('Failed to save member:', error); if (error.status === 409) { this.messageService.add({ @@ -193,22 +152,22 @@ export class MemberFormComponent { }, }); } else { - this.form().markAllAsTouched(); - this.cdr.detectChanges(); + // Mark all fields as touched to show validation errors + Object.keys(this.form().controls).forEach((key) => { + this.form().get(key)?.markAsTouched(); + }); } } private initializeForm(): void { if (this.isEditing && this.member) { const member = this.member; - const hasOrg = !!member.organization?.name; 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, @@ -219,63 +178,25 @@ 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: Record): 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)]), - is_individual: new FormControl(false), - organization: new FormControl('', [Validators.required]), - organization_url: new FormControl(''), - role: new FormControl('', [Validators.required]), - voting_status: new FormControl('', [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; - }; + 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), + }); } } 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 cae0a1184..fa8a9f09f 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,20 +15,18 @@ 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(); From fd66d0d8a93d5eb79b1f5b5334ff79556fded0ed Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 09:13:27 -0700 Subject: [PATCH 29/48] fix(committees): address review feedback on detail shell Apply @MRashad26 review patterns: - Replace BehaviorSubject refresh trigger with WritableSignal - Use Record for type-safe join mode labels - Add explicit standalone: true to JoinModeLabelPipe - Replace mb-* heading spacing with flex gap-* where straightforward LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 318 +++++++++--------- .../committee-view.component.ts | 26 +- .../src/constants/committees.constants.ts | 13 +- 3 files changed, 185 insertions(+), 172 deletions(-) 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 454983c94..03050f4a9 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 @@ -326,17 +326,19 @@

@if (recentResolutions().length > 0) { -

Recent Resolutions

-
- @for (res of recentResolutions(); track res.uid) { -
-
-

{{ res.title }}

-

{{ res.date | date: 'MMM d, y' }} · {{ res.votes_for }}-{{ res.votes_against }}

+
+

Recent Resolutions

+
+ @for (res of recentResolutions(); track res.uid) { +
+
+

{{ res.title }}

+

{{ res.date | date: 'MMM d, y' }} · {{ res.votes_for }}-{{ res.votes_against }}

+
+
- -
- } + } +
} @@ -644,146 +646,152 @@

-

Channels

- - @if (committee()?.mailing_list; as mailingList) { -
-

Mailing List

-
-
- -
-
- @if (mailingList.url) { - - {{ mailingList.name }} - - - } @else { -

{{ mailingList.name }}

- } - @if (mailingList.subscriber_count) { -

{{ mailingList.subscriber_count }} subscribers

- } +

Channels

+
+ + @if (committee()?.mailing_list; as mailingList) { +
+

Mailing List

+
+
+ +
+
+ @if (mailingList.url) { + + {{ mailingList.name }} + + + } @else { +

{{ mailingList.name }}

+ } + @if (mailingList.subscriber_count) { +

{{ mailingList.subscriber_count }} subscribers

+ } +
-
- } @else { -
-

Mailing List

-
-
- + } @else { +
+

Mailing List

+
+
+ +
+

No mailing list connected

-

No mailing list connected

-
- } + } - - @if (committee()?.chat_channel; as chatChannel) { -
-

- {{ chatChannel.platform === 'slack' ? 'Slack' : 'Discord' }} Channel -

-
-
- -
-
- @if (chatChannel.url) { - - {{ chatChannel.name }} - - - } @else { -

{{ chatChannel.name }}

- } -

{{ chatChannel.platform }}

+ + @if (committee()?.chat_channel; as chatChannel) { +
+

+ {{ chatChannel.platform === 'slack' ? 'Slack' : 'Discord' }} Channel +

+
+
+ +
+
+ @if (chatChannel.url) { + + {{ chatChannel.name }} + + + } @else { +

{{ chatChannel.name }}

+ } +

{{ chatChannel.platform }}

+
-
- } @else { -
-

Chat Channel

-
-
- + } @else { +
+

Chat Channel

+
+
+ +
+

No chat channel connected

-

No chat channel connected

-
- } + } - - @if (committee()?.website) { -
-

Website

-
-
- + + @if (committee()?.website) { + -
- } + } +
@if (canManageConfigurations()) { -

Configurations

-
-
- Public - -
-
- Voting - -
-
- Business Email - -
-
- Audit - -
-
- Join Mode - -
-
- SSO Group - +
+

Configurations

+
+
+ Public + +
+
+ Voting + +
+
+ Business Email + +
+
+ Audit + +
+
+ Join Mode + +
+
+ SSO Group + +
@@ -863,20 +871,22 @@

@if (uniqueOrganizations().length > 0) { -

Organizations

-
- @for (org of uniqueOrganizations().slice(0, 8); track org) { -
- {{ org }} - - {{ getMembersCountByOrg(org) }} - {{ getMembersCountByOrg(org) === 1 ? 'member' : 'members' }} - -
- } - @if (uniqueOrganizations().length > 8) { -

+ {{ uniqueOrganizations().length - 8 }} more organizations

- } +
+

Organizations

+
+ @for (org of uniqueOrganizations().slice(0, 8); track org) { +
+ {{ org }} + + {{ getMembersCountByOrg(org) }} + {{ getMembersCountByOrg(org) === 1 ? 'member' : 'members' }} + +
+ } + @if (uniqueOrganizations().length > 8) { +

+ {{ uniqueOrganizations().length - 8 }} more organizations

+ } +
} @@ -884,14 +894,16 @@

-

Roles

-
- @for (role of roleBreakdown(); track role.name) { -
- {{ role.name }} - {{ role.count }} -
- } +
+

Roles

+
+ @for (role of roleBreakdown(); track role.name) { +
+ {{ role.name }} + {{ role.count }} +
+ } +
} 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 749e3d9d5..d6773448c 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 @@ -4,13 +4,13 @@ import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; import { DatePipe, DecimalPipe, NgClass } from '@angular/common'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component'; import { ButtonComponent } from '@components/button/button.component'; import { CardComponent } from '@components/card/card.component'; import { TagComponent } from '@components/tag/tag.component'; -import { COMMITTEE_LABEL } from '@lfx-one/shared/constants'; +import { COMMITTEE_LABEL, JOIN_MODE_LABELS } from '@lfx-one/shared/constants'; import { getGroupBehavioralClass, isGovernanceClass, @@ -50,7 +50,7 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; -import { BehaviorSubject, catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; +import { catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; import { CommitteeSettingsComponent } from '../components/committee-settings/committee-settings.component'; @@ -98,7 +98,7 @@ export class CommitteeViewComponent { // -- Writable signals -- public loading = signal(true); public error = signal(false); - public refresh = new BehaviorSubject(undefined); + public refresh = signal(0); // Sub-resource writable signals public membersLoading = signal(true); @@ -199,18 +199,8 @@ export class CommitteeViewComponent { // -- Configuration label signals -- public joinModeLabel: Signal = computed(() => { - switch (this.committee()?.join_mode) { - case 'open': - return 'Open'; - case 'invite_only': - return 'Invite Only'; - case 'application': - return 'Apply to Join'; - case 'closed': - return 'Closed'; - default: - return 'Closed'; - } + const mode = this.committee()?.join_mode; + return mode ? JOIN_MODE_LABELS[mode] : 'Closed'; }); public constructor() { @@ -224,7 +214,7 @@ export class CommitteeViewComponent { public refreshCommittee(): void { this.loading.set(true); - this.refresh.next(); + this.refresh.update((v) => v + 1); } public getMembersCountByOrg(org: string): number { @@ -273,7 +263,7 @@ export class CommitteeViewComponent { // -- Private initializer functions -- private initializeCommittee(): void { - combineLatest([this.route.paramMap, this.refresh]) + combineLatest([this.route.paramMap, toObservable(this.refresh)]) .pipe( switchMap(([params]) => { const committeeId = params?.get('id'); diff --git a/packages/shared/src/constants/committees.constants.ts b/packages/shared/src/constants/committees.constants.ts index 719fd11cc..0754216c0 100644 --- a/packages/shared/src/constants/committees.constants.ts +++ b/packages/shared/src/constants/committees.constants.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { CommitteeMemberAppointedBy, CommitteeMemberRole, CommitteeMemberVotingStatus } from '../enums/committee-member.enum'; -import { GroupBehavioralClass } from '../interfaces/committee.interface'; +import { GroupBehavioralClass, JoinMode } from '../interfaces/committee.interface'; import { lfxColors } from './colors.constants'; // Re-export helper functions from utils for backward compatibility @@ -481,6 +481,17 @@ export const JOIN_MODE_OPTIONS = [ { label: 'Closed — admin adds members', value: 'closed' }, ]; +/** + * Human-readable labels for each join mode value. + * @description Type-safe mapping from JoinMode to display label. + */ +export const JOIN_MODE_LABELS: Record = { + open: 'Open', + invite_only: 'Invite Only', + application: 'Apply to Join', + closed: 'Closed', +}; + // ============================================================================ // Group-Type Behavioral Classification (6-Type Taxonomy v1.1) // ============================================================================ From f3abd695cf747fb42d3cd21dcff1b4b5a2c1c70e Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 08:42:29 -0700 Subject: [PATCH 30/48] feat(committees): add votes tab with reusable table UI and results drawer Wire votes tab into committee detail view: - Committee votes list component with open/closed vote filtering - Reusable votes table with committee context support - Vote results drawer integration for viewing detailed results - Votes tab visibility gated on committee enable_voting setting LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 15 ++ .../committee-view.component.ts | 14 +- .../committee-votes-list.component.html | 38 ++++ .../committee-votes-list.component.ts | 177 ++++++++++++++++++ .../votes-table/votes-table.component.html | 26 +-- .../votes-table/votes-table.component.ts | 1 + 6 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.html create mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-votes-list/committee-votes-list.component.ts 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 03050f4a9..2979780c9 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 @@ -126,6 +126,9 @@

{{ committee()?.name }}

Overview + @if (isVotesTabVisible()) { + Votes + } Meetings Documents @if (canManageConfigurations()) { @@ -912,6 +915,18 @@

+
+ @if (committee(); as c) { + + + } +
+ + } +
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 d6773448c..8aa7c51c0 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 @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; +import { Component, computed, effect, inject, signal, Signal, WritableSignal } from '@angular/core'; import { DatePipe, DecimalPipe, NgClass } from '@angular/common'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; @@ -53,6 +53,7 @@ import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; import { CommitteeSettingsComponent } from '../components/committee-settings/committee-settings.component'; +import { CommitteeVotesListComponent } from '../components/committee-votes-list/committee-votes-list.component'; @Component({ selector: 'lfx-committee-view', @@ -75,6 +76,7 @@ import { CommitteeSettingsComponent } from '../components/committee-settings/com MeetingCardComponent, ReactiveFormsModule, NgClass, + CommitteeVotesListComponent, ], providers: [ConfirmationService], templateUrl: './committee-view.component.html', @@ -147,6 +149,9 @@ export class CommitteeViewComponent { public isMaintainer: Signal = computed(() => this.personaService.currentPersona() === 'maintainer'); public canManageConfigurations: Signal = computed(() => this.isMaintainer() || (!!this.committee()?.writer && !this.isBoardMember())); + // -- Tab visibility signals -- + public isVotesTabVisible: Signal = computed(() => !!this.committee()?.enable_voting); + // -- Behavioral class signals -- public behavioralClass: Signal = computed(() => getGroupBehavioralClass(this.committee()?.category)); public isGovernanceClass: Signal = computed(() => isGovernanceClass(this.committee()?.category)); @@ -205,6 +210,13 @@ export class CommitteeViewComponent { public constructor() { this.initializeCommittee(); + + // Redirect away from votes tab when voting is disabled + effect(() => { + if (!this.isVotesTabVisible() && this.activeTab() === 'votes') { + this.activeTab.set('overview'); + } + }); } // -- Public methods -- 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..3b22ad4b7 --- /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 { 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 { BehaviorSubject, 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); + + // === Subjects === + private readonly fetch$ = new BehaviorSubject(undefined); + private readonly refresh$ = new BehaviorSubject(undefined); + + // === 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(); + protected readonly totalCount: Signal = this.initTotalCount(); + + // === 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.fetch$.next(); + this.refresh$.next(); + } + + 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.fetch$.next(); + return; + } + + this.currentFirst.set(event.first); + this.fetch$.next(); + } + + 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 initTotalCount(): Signal { + const filters$ = toObservable(this.filters); + const projectUid$ = toObservable(this.projectUid); + const committeeName$ = toObservable(this.committeeName); + + return toSignal( + combineLatest([projectUid$, committeeName$, filters$, this.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( + tap((count) => this.totalRecords.set(count)), + catchError(() => { + this.totalRecords.set(0); + return of(0); + }) + ); + }) + ), + { initialValue: 0 } + ); + } + + private initVotes(): Signal { + const filters$ = toObservable(this.filters); + const projectUid$ = toObservable(this.projectUid); + const committeeName$ = toObservable(this.committeeName); + + return toSignal( + combineLatest([projectUid$, committeeName$, filters$, this.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/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(); From a8946b3f4bb0a82fe099bb89e5231f02c622977d Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 07:57:15 -0700 Subject: [PATCH 31/48] feat(committees): add assign leadership dialog for chair and co-chair roles Add leadership assignment capability: - Assign leadership dialog component with member selection - Wire assign chair/co-chair buttons in overview tab - Dynamic dialog integration for leadership role assignment LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 49 ++++++ .../committee-view.component.ts | 45 ++++- .../assign-leadership-dialog.component.html | 79 +++++++++ .../assign-leadership-dialog.component.ts | 154 ++++++++++++++++++ 4 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.html create mode 100644 apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.ts 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 03050f4a9..ff3712e70 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 @@ -804,6 +804,19 @@

<

Leadership

+ @if (canManageConfigurations() && !hasChair() && !hasCoChair()) { + + + }
@@ -823,6 +836,16 @@

Since {{ chairElectedDate() }}

}

+ @if (canManageConfigurations()) { + + }

} @else {
@@ -831,6 +854,14 @@

Vacant

+ @if (canManageConfigurations()) { + + }

} @@ -853,6 +884,16 @@

Since {{ coChairElectedDate() }}

}

+ @if (canManageConfigurations()) { + + }

} @else {
@@ -861,6 +902,14 @@

Vacant

+ @if (canManageConfigurations()) { + + }
} 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 d6773448c..d10c07693 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 @@ -31,12 +31,14 @@ import { CommitteeDiscussionThread, CommitteeEngagementMetrics, CommitteeEvent, + CommitteeLeadership, CommitteeMember, CommitteeOutreachCampaign, CommitteeResolution, CommitteeVote, getCommitteeCategorySeverity, GroupBehavioralClass, + LeadershipRole, TagSeverity, } from '@lfx-one/shared'; import { Meeting, PastMeeting, PastMeetingSummary } from '@lfx-one/shared/interfaces'; @@ -47,11 +49,12 @@ import { MeetingService } from '@services/meeting.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 { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; +import { AssignLeadershipDialogComponent } from '../components/assign-leadership-dialog/assign-leadership-dialog.component'; import { CommitteeSettingsComponent } from '../components/committee-settings/committee-settings.component'; @Component({ @@ -63,6 +66,7 @@ import { CommitteeSettingsComponent } from '../components/committee-settings/com TagComponent, RouterLink, ConfirmDialogModule, + DynamicDialogModule, TooltipModule, Tabs, TabList, @@ -76,7 +80,7 @@ import { CommitteeSettingsComponent } from '../components/committee-settings/com ReactiveFormsModule, NgClass, ], - providers: [ConfirmationService], + providers: [ConfirmationService, DialogService], templateUrl: './committee-view.component.html', styleUrl: './committee-view.component.scss', }) @@ -86,6 +90,7 @@ export class CommitteeViewComponent { private readonly router = inject(Router); private readonly committeeService = inject(CommitteeService); private readonly meetingService = inject(MeetingService); + private readonly dialogService = inject(DialogService); private readonly messageService = inject(MessageService); private readonly personaService = inject(PersonaService); @@ -261,6 +266,42 @@ export class CommitteeViewComponent { }); } + public openAssignLeadership(role: LeadershipRole): void { + const committee = this.committee(); + if (!committee) return; + + const currentLeader = role === 'chair' ? this.chair() : this.coChair(); + const roleLabel = role === 'chair' ? 'Assign Chair' : 'Assign Co-Chair'; + + const dialogRef = this.dialogService.open(AssignLeadershipDialogComponent, { + header: roleLabel, + width: '500px', + modal: true, + closable: true, + data: { + role, + committee, + members: this.members(), + currentLeader: currentLeader ?? null, + }, + }) as DynamicDialogRef; + + dialogRef.onClose.pipe(take(1)).subscribe((result: { role: LeadershipRole; leadership: CommitteeLeadership | null } | undefined) => { + if (result) { + const current = this.committee(); + if (current) { + const updated = { ...current }; + if (result.role === 'chair') { + updated.chair = result.leadership; + } else { + updated.co_chair = result.leadership; + } + this.committeeSignal.set(updated); + } + } + }); + } + // -- Private initializer functions -- private initializeCommittee(): void { combineLatest([this.route.paramMap, toObservable(this.refresh)]) 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..040bfed91 --- /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..377fb0174 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/assign-leadership-dialog/assign-leadership-dialog.component.ts @@ -0,0 +1,154 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, signal, 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 { 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: Signal<{ 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; + + 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 }, + }); + } + 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()}`, + }); + }, + }); + } + + 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(): Signal<{ label: string; value: string }[]> { + return computed(() => + this.members.map((m) => ({ + label: `${m.first_name} ${m.last_name}${m.organization?.name ? ` — ${m.organization.name}` : ''}`, + value: m.uid, + })) + ); + } +} From daccba6d24fd9da10a96002b7960a126c95f6617 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 08:39:18 -0700 Subject: [PATCH 32/48] feat(committees): add members tab with full CRUD and governance fields Wire members tab into committee detail view: - Enhanced committee-members component with visibility controls and search/filter - Member form with governance fields (voting_start_date, voting_end_date, appointed_by) - Add/edit/delete member operations with role-based access control - Members tab visibility gated on committee member_visibility setting LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 21 ++ .../committee-view.component.ts | 10 + .../committee-members.component.html | 332 ++++++++++-------- .../committee-members.component.ts | 39 +- .../member-form/member-form.component.html | 225 ++++++++---- .../member-form/member-form.component.ts | 145 ++++++-- 6 files changed, 511 insertions(+), 261 deletions(-) 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 03050f4a9..1272130db 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 @@ -126,6 +126,9 @@

{{ committee()?.name }}

Overview + @if (isMembersTabVisible()) { + Members + } Meetings Documents @if (canManageConfigurations()) { @@ -912,6 +915,24 @@

+
+ + @if (committee()?.uid) { + + + } +
+ + } +
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 d6773448c..e4b4d8d65 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 @@ -52,6 +52,7 @@ import { TooltipModule } from 'primeng/tooltip'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { catchError, combineLatest, finalize, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs'; +import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; import { CommitteeSettingsComponent } from '../components/committee-settings/committee-settings.component'; @Component({ @@ -71,6 +72,7 @@ import { CommitteeSettingsComponent } from '../components/committee-settings/com TabPanel, DatePipe, DecimalPipe, + CommitteeMembersComponent, CommitteeSettingsComponent, MeetingCardComponent, ReactiveFormsModule, @@ -147,6 +149,10 @@ export class CommitteeViewComponent { public isMaintainer: Signal = computed(() => this.personaService.currentPersona() === 'maintainer'); public canManageConfigurations: Signal = computed(() => this.isMaintainer() || (!!this.committee()?.writer && !this.isBoardMember())); + // -- Tab visibility signals -- + // -- Tab visibility signals -- + public isMembersTabVisible: Signal = computed(() => this.committee()?.member_visibility !== 'hidden' || this.canManageConfigurations()); + // -- Behavioral class signals -- public behavioralClass: Signal = computed(() => getGroupBehavioralClass(this.committee()?.category)); public isGovernanceClass: Signal = computed(() => isGovernanceClass(this.committee()?.category)); @@ -217,6 +223,10 @@ export class CommitteeViewComponent { this.refresh.update((v) => v + 1); } + public refreshMembers(): void { + this.refresh.next(); + } + public getMembersCountByOrg(org: string): number { return this.members().filter((m) => m.organization?.name === org).length; } 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 9f3d2242e..41cd07993 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.first_name || '') + ' ' + (member.last_name || '') | 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.first_name || '') + ' ' + (member.last_name || '') | 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 b3ee5d397..16285b72f 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,8 +1,8 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { TitleCasePipe } from '@angular/common'; -import { Component, computed, inject, input, OnInit, output, signal, Signal, WritableSignal } from '@angular/core'; +import { isPlatformBrowser, TitleCasePipe } from '@angular/common'; +import { Component, computed, inject, input, OnInit, output, PLATFORM_ID, 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'; @@ -12,12 +12,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 +47,13 @@ export class CommitteeMembersComponent implements OnInit { 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(); @@ -61,7 +63,9 @@ export class CommitteeMembersComponent implements OnInit { 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; @@ -80,7 +84,13 @@ export class CommitteeMembersComponent implements OnInit { this.isDeleting = signal(false); // Initialize permission signals this.isBoardMember = computed(() => this.personaService.currentPersona() === 'board-member'); - this.canManageMembers = computed(() => !this.isBoardMember() && !!this.committee()?.writer); + 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(); @@ -109,6 +119,7 @@ export class CommitteeMembersComponent implements OnInit { width: '700px', modal: true, closable: true, + duplicate: true, data: { isEditing: false, committee: this.committee(), @@ -116,9 +127,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(); } @@ -133,6 +144,7 @@ export class CommitteeMembersComponent implements OnInit { width: '700px', modal: true, closable: true, + duplicate: true, data: { isEditing: true, memberId: member.uid, @@ -142,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(); } @@ -195,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', @@ -243,7 +254,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/member-form/member-form.component.html b/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.html index 5fa2482c0..5afb1e92e 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) { - -
-
- -
- - -
- - -
- -
- - -
+ +
+
+ +
+ + + @if (form().get('role')?.errors?.['required'] && form().get('role')?.touched) { +

Role is required

+ } +
- -
- - -
+ +
+
+ + @if (form().get('role_start')?.value || form().get('role_end')?.value) { + + }
- - -
- - + -
- - -
- -
- - -
- - -
- - -
+ (onSelect)="onDateChange()" + data-testid="member-form-role-start"> +
+ @if (form().errors?.['role_start_after_role_end']) { +

Role end date must be after start date

+ }
- +
- - Voting Status * + + control="voting_status" + [options]="votingStatusOptions" + placeholder="Select voting status" + styleClass="w-full" + appendTo="body" + 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

+ }
- } + + +
+ + +
+
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..c0ba4117c 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,14 +1,16 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, inject, signal } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ChangeDetectorRef, Component, inject, signal } from '@angular/core'; +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 { APPOINTED_BY_OPTIONS, LINKEDIN_PROFILE_PATTERN, MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; import { Committee, CommitteeMember, CreateCommitteeMemberRequest } from '@lfx-one/shared/interfaces'; import { formatDateToISOString, parseISODateString } from '@lfx-one/shared/utils'; import { CommitteeService } from '@services/committee.service'; @@ -17,7 +19,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', }) @@ -26,6 +28,7 @@ export class MemberFormComponent { private readonly dialogRef = inject(DynamicDialogRef); private readonly committeeService = inject(CommitteeService); private readonly messageService = inject(MessageService); + private readonly cdr = inject(ChangeDetectorRef); // Loading state for form submissions public submitting = signal(false); @@ -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,50 @@ 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(); + this.cdr.detectChanges(); + } + + public clearVotingDates(): void { + this.form().get('voting_status_start')?.reset(); + this.form().get('voting_status_end')?.reset(); + this.form().updateValueAndValidity(); + this.cdr.detectChanges(); + } + + public onDateChange(): void { + this.form().updateValueAndValidity(); + this.cdr.detectChanges(); } public onCancel(): void { @@ -66,7 +114,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(); // Prepare member data using form values, mapping to new structure const memberData: CreateCommitteeMemberRequest = { @@ -90,13 +138,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 @@ -134,7 +176,6 @@ export class MemberFormComponent { }, error: (error) => { this.submitting.set(false); - console.error('Failed to save member:', error); if (error.status === 409) { this.messageService.add({ @@ -152,22 +193,22 @@ 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(); + this.cdr.detectChanges(); } } private initializeForm(): void { if (this.isEditing && this.member) { const member = this.member; + const hasOrg = !!member.organization?.name; 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 +219,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: Record): 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('', [Validators.required]), + voting_status: new FormControl('', [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; + }; } } From 81892489ca70bcaee7c7721d55fa1b9e8efd2d86 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 18:17:42 -0700 Subject: [PATCH 33/48] fix(committees): address review feedback on leadership dialog - Validate committee data in dialog constructor, close with error if missing - Add aria-label and type="button" to icon-only leadership buttons - Fix tooltip text from "Assign Chair / Co-Chair" to "Assign Chair" - Refresh members after leadership change to sync UI with backend - Remove unnecessary DynamicDialogRef cast - Add comment about partial failure in sequential chair updates - Close dialog with result on error to trigger member refresh Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 8 +++++++- .../committee-view.component.ts | 19 ++++++++++++++++--- .../assign-leadership-dialog.component.ts | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) 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 ff3712e70..8d219a5df 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 @@ -811,8 +811,10 @@

[text]="true" severity="secondary" styleClass="h-6 w-6 text-gray-400 hover:text-gray-600" - pTooltip="Assign Chair / Co-Chair" + pTooltip="Assign Chair" tooltipPosition="top" + ariaLabel="Assign Chair" + type="button" (onClick)="openAssignLeadership('chair')" data-testid="committee-view-leadership-edit-btn"> @@ -838,9 +840,11 @@

@if (canManageConfigurations()) {
@if (canManageConfigurations()) {
- @if (!isBoardMember()) { + @if (canManageConfigurations()) {
{{ vote.title }}

Budget Summary — FY{{ budget.fiscal_year }}

- @if (!isBoardMember()) { + @if (canManageConfigurations()) { }
@@ -281,7 +281,7 @@

{{ budget.total_budget ? ((budget.spent / budget.total_budget) * 100 | number: '1.0-0') : 0 }}% spent - ${{ budget.total_budget / 1000000 | number: '1.1-1' }}M total + ${{ budget.total_budget ? (budget.total_budget / 1000000 | number: '1.1-1') : '0.0' }}M total
{{ org }} - {{ getMembersCountByOrg(org) }} - {{ getMembersCountByOrg(org) === 1 ? 'member' : 'members' }} + {{ memberCountByOrg().get(org) || 0 }} + {{ (memberCountByOrg().get(org) || 0) === 1 ? 'member' : 'members' }}
} 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 d6773448c..f36263f0e 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 @@ -62,6 +62,7 @@ import { CommitteeSettingsComponent } from '../components/committee-settings/com ButtonComponent, TagComponent, RouterLink, + // TODO: No LFX wrapper exists for ConfirmDialog or Tabs — use PrimeNG directly ConfirmDialogModule, TooltipModule, Tabs, @@ -92,6 +93,9 @@ export class CommitteeViewComponent { // -- Label constants -- protected readonly committeeLabel = COMMITTEE_LABEL; + // -- Settings form -- + public settingsForm: FormGroup = this.createSettingsForm(); + // -- Tab state -- public activeTab = signal('overview'); @@ -173,6 +177,16 @@ export class CommitteeViewComponent { return [...new Set(orgs)]; }); public orgCount: Signal = computed(() => this.uniqueOrganizations().length); + public memberCountByOrg: Signal> = computed(() => { + const counts = new Map(); + this.members().forEach((m) => { + const org = m.organization?.name; + if (org) { + counts.set(org, (counts.get(org) || 0) + 1); + } + }); + return counts; + }); public observerCount: Signal = computed(() => this.members().filter((m) => m.voting?.status === CommitteeMemberVotingStatus.OBSERVER).length); public roleBreakdown: Signal<{ name: string; count: number }[]> = computed(() => { const roleCounts: Record = {}; @@ -193,8 +207,6 @@ export class CommitteeViewComponent { public chairElectedDate: Signal = this.initializeChairElectedDate(); public coChairElectedDate: Signal = this.initializeCoChairElectedDate(); - // -- Settings form -- - public settingsForm: FormGroup = this.createSettingsForm(); public settingsSaving = signal(false); // -- Configuration label signals -- @@ -217,10 +229,6 @@ export class CommitteeViewComponent { this.refresh.update((v) => v + 1); } - public getMembersCountByOrg(org: string): number { - return this.members().filter((m) => m.organization?.name === org).length; - } - public saveSettings(): void { const committee = this.committee(); if (!committee) return; @@ -300,9 +308,11 @@ export class CommitteeViewComponent { if (committee) { this.populateSettingsForm(committee); - this.loadMeetings(committeeId); - this.loadPastMeetingSummary(committee.project_uid); - return this.loadGroupTypeData$(committeeId, committee); + return forkJoin([ + this.loadGroupTypeData$(committeeId, committee), + this.loadMeetings$(committeeId), + this.loadPastMeetingSummary$(committee.project_uid), + ]); } return of(null); @@ -315,30 +325,29 @@ export class CommitteeViewComponent { .subscribe(); } - private loadMeetings(committeeId: string): void { + private loadMeetings$(committeeId: string): Observable { this.meetingsLoading.set(true); - this.committeeService - .getCommitteeMeetings(committeeId) - .pipe( - take(1), - catchError(() => of([])) - ) - .subscribe((meetings) => { + return this.committeeService.getCommitteeMeetings(committeeId).pipe( + take(1), + catchError(() => of([])), + tap((meetings) => { this.committeeMeetings.set(Array.isArray(meetings) ? meetings : []); this.meetingsLoading.set(false); - }); + }) + ); } - private loadPastMeetingSummary(projectUid: string | undefined): void { - if (!projectUid) return; - this.meetingService - .getPastMeetingsByProject(projectUid, 1) - .pipe( - take(1), - catchError(() => of([])), - switchMap((pastMeetings) => this.loadLastMeetingSummary$(pastMeetings)) - ) - .subscribe(); + private loadPastMeetingSummary$(projectUid: string | undefined): Observable { + if (!projectUid) { + this.lastPastMeeting.set(null); + this.lastMeetingSummary.set(null); + return of(null); + } + return this.meetingService.getPastMeetingsByProject(projectUid, 1).pipe( + take(1), + catchError(() => of([])), + switchMap((pastMeetings) => this.loadLastMeetingSummary$(pastMeetings)) + ); } private loadGroupTypeData$(committeeId: string, committee: Committee): Observable { 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 0e0d1d060..53abd9cc0 100644 --- a/apps/lfx-one/src/app/shared/services/committee.service.ts +++ b/apps/lfx-one/src/app/shared/services/committee.service.ts @@ -109,49 +109,48 @@ export class CommitteeService { public getCommitteeMeetings(committeeId: string): Observable { return this.http.get>(`/api/committees/${committeeId}/meetings`).pipe( map((response) => response.data), - catchError(() => of([])), take(1) ); } - // Dashboard sub-resource methods + // 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`).pipe(catchError(() => of([]))); + return this.http.get(`/api/committees/${committeeId}/votes`); } public getCommitteeResolutions(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/resolutions`).pipe(catchError(() => of([]))); + return this.http.get(`/api/committees/${committeeId}/resolutions`); } public getCommitteeActivity(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/activity`).pipe(catchError(() => of([]))); + return this.http.get(`/api/committees/${committeeId}/activity`); } public getCommitteeContributors(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/contributors`).pipe(catchError(() => of([]))); + return this.http.get(`/api/committees/${committeeId}/contributors`); } public getCommitteeDeliverables(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/deliverables`).pipe(catchError(() => of([]))); + return this.http.get(`/api/committees/${committeeId}/deliverables`); } public getCommitteeDiscussions(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/discussions`).pipe(catchError(() => of([]))); + return this.http.get(`/api/committees/${committeeId}/discussions`); } public getCommitteeEvents(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/events`).pipe(catchError(() => of([]))); + return this.http.get(`/api/committees/${committeeId}/events`); } public getCommitteeCampaigns(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/campaigns`).pipe(catchError(() => of([]))); + return this.http.get(`/api/committees/${committeeId}/campaigns`); } public getCommitteeEngagement(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/engagement`).pipe(catchError(() => of(null))); + return this.http.get(`/api/committees/${committeeId}/engagement`); } public getCommitteeBudget(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/budget`).pipe(catchError(() => of(null))); + return this.http.get(`/api/committees/${committeeId}/budget`); } } diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index c56bb8065..650775585 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -14,6 +14,73 @@ import { CommitteeService } from '../services/committee.service'; export class CommitteeController { private committeeService: CommitteeService = new CommitteeService(); + // ── 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/resolutions */ + public getCommitteeResolutions = this.subResourceHandler( + 'get_committee_resolutions', + (req, id) => this.committeeService.getCommitteeResolutions(req, id), + 'resolution_count' + ); + + /** GET /committees/:id/activity */ + public getCommitteeActivity = this.subResourceHandler( + 'get_committee_activity', + (req, id) => this.committeeService.getCommitteeActivity(req, id), + 'activity_count' + ); + + /** GET /committees/:id/contributors */ + public getCommitteeContributors = this.subResourceHandler( + 'get_committee_contributors', + (req, id) => this.committeeService.getCommitteeContributors(req, id), + 'contributor_count' + ); + + /** GET /committees/:id/deliverables */ + public getCommitteeDeliverables = this.subResourceHandler( + 'get_committee_deliverables', + (req, id) => this.committeeService.getCommitteeDeliverables(req, id), + 'deliverable_count' + ); + + /** GET /committees/:id/discussions */ + public getCommitteeDiscussions = this.subResourceHandler( + 'get_committee_discussions', + (req, id) => this.committeeService.getCommitteeDiscussions(req, id), + 'discussion_count' + ); + + /** GET /committees/:id/events */ + public getCommitteeEvents = this.subResourceHandler('get_committee_events', (req, id) => this.committeeService.getCommitteeEvents(req, id), 'event_count'); + + /** GET /committees/:id/campaigns */ + public getCommitteeCampaigns = this.subResourceHandler( + 'get_committee_campaigns', + (req, id) => this.committeeService.getCommitteeCampaigns(req, id), + 'campaign_count' + ); + + /** GET /committees/:id/engagement */ + public getCommitteeEngagement = this.subResourceHandler( + 'get_committee_engagement', + (req, id) => this.committeeService.getCommitteeEngagement(req, id), + 'has_engagement' + ); + + /** GET /committees/:id/budget */ + public getCommitteeBudget = this.subResourceHandler('get_committee_budget', (req, id) => this.committeeService.getCommitteeBudget(req, id), 'has_budget'); + + /** 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 */ @@ -464,278 +531,38 @@ export class CommitteeController { } } - /** - * GET /committees/:id/votes - */ - public async getCommitteeVotes(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_votes', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_votes', { committee_id: committeeId }); - try { - const votes = await this.committeeService.getCommitteeVotes(req, committeeId); - logger.success(req, 'get_committee_votes', startTime, { vote_count: votes.length }); - res.json(votes); - } catch (error) { - next(error); - } - } + // ── Private helpers ────────────────────────────────────────────────────── /** - * GET /committees/:id/resolutions + * 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. */ - public async getCommitteeResolutions(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_resolutions', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_resolutions', { committee_id: committeeId }); - try { - const resolutions = await this.committeeService.getCommitteeResolutions(req, committeeId); - logger.success(req, 'get_committee_resolutions', startTime, { resolution_count: resolutions.length }); - res.json(resolutions); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/activity - */ - public async getCommitteeActivity(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_activity', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_activity', { committee_id: committeeId }); - try { - const activity = await this.committeeService.getCommitteeActivity(req, committeeId); - logger.success(req, 'get_committee_activity', startTime, { activity_count: activity.length }); - res.json(activity); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/contributors - */ - public async getCommitteeContributors(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_contributors', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_contributors', { committee_id: committeeId }); - try { - const contributors = await this.committeeService.getCommitteeContributors(req, committeeId); - logger.success(req, 'get_committee_contributors', startTime, { contributor_count: contributors.length }); - res.json(contributors); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/deliverables - */ - public async getCommitteeDeliverables(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_deliverables', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_deliverables', { committee_id: committeeId }); - try { - const deliverables = await this.committeeService.getCommitteeDeliverables(req, committeeId); - logger.success(req, 'get_committee_deliverables', startTime, { deliverable_count: deliverables.length }); - res.json(deliverables); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/discussions - */ - public async getCommitteeDiscussions(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_discussions', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_discussions', { committee_id: committeeId }); - try { - const discussions = await this.committeeService.getCommitteeDiscussions(req, committeeId); - logger.success(req, 'get_committee_discussions', startTime, { discussion_count: discussions.length }); - res.json(discussions); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/events - */ - public async getCommitteeEvents(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_events', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_events', { committee_id: committeeId }); - try { - const events = await this.committeeService.getCommitteeEvents(req, committeeId); - logger.success(req, 'get_committee_events', startTime, { event_count: events.length }); - res.json(events); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/campaigns - */ - public async getCommitteeCampaigns(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_campaigns', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_campaigns', { committee_id: committeeId }); - try { - const campaigns = await this.committeeService.getCommitteeCampaigns(req, committeeId); - logger.success(req, 'get_committee_campaigns', startTime, { campaign_count: campaigns.length }); - res.json(campaigns); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/engagement - */ - public async getCommitteeEngagement(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_engagement', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_engagement', { committee_id: committeeId }); - try { - const engagement = await this.committeeService.getCommitteeEngagement(req, committeeId); - logger.success(req, 'get_committee_engagement', startTime, { has_engagement: !!engagement }); - res.json(engagement); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/budget - */ - public async getCommitteeBudget(req: Request, res: Response, next: NextFunction): Promise { - const committeeId = req.params['id']; - if (!committeeId) { - const validationError = ServiceValidationError.forField('id', 'Committee ID is required', { - operation: 'get_committee_budget', - service: 'committee_controller', - path: req.path, - }); - next(validationError); - return; - } - const startTime = logger.startOperation(req, 'get_committee_budget', { committee_id: committeeId }); - try { - const budget = await this.committeeService.getCommitteeBudget(req, committeeId); - logger.success(req, 'get_committee_budget', startTime, { has_budget: !!budget }); - res.json(budget); - } catch (error) { - next(error); - } - } - - /** - * GET /committees/:id/meetings - */ - public async getCommitteeMeetings(req: Request, res: Response, next: NextFunction): Promise { - const { id } = req.params; - const startTime = logger.startOperation(req, 'get_committee_meetings', { - 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_meetings', - service: 'committee_controller', - path: req.path, - }); - next(validationError); + 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 result = await this.committeeService.getCommitteeMeetings(req, id, req.query as Record); - - logger.success(req, 'get_committee_meetings', startTime, { - committee_id: id, - meeting_count: result.data.length, - }); - - res.json(result); - } catch (error) { - logger.error(req, 'get_committee_meetings', startTime, error, { committee_id: id }); - next(error); - } + 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) { + next(error); + } + }; } } diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index 49b5c1ff4..1af2b14b2 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -3,10 +3,20 @@ import { Committee, + CommitteeActivity, + CommitteeBudgetSummary, + CommitteeContributor, CommitteeCreateData, + CommitteeDeliverable, + CommitteeDiscussionThread, + CommitteeEngagementMetrics, + CommitteeEvent, CommitteeMember, + CommitteeOutreachCampaign, + CommitteeResolution, CommitteeSettingsData, CommitteeUpdateData, + CommitteeVote, CreateCommitteeMemberRequest, Meeting, PaginatedResponse, @@ -34,6 +44,8 @@ export class CommitteeService { private accessCheckService: AccessCheckService; private etagService: ETagService; private microserviceProxy: MicroserviceProxyService; + // Cached lazy-loaded MeetingService to avoid creating a new instance per request + private cachedMeetingService?: { getMeetings: (req: Request, params: Record) => Promise> }; public constructor() { this.accessCheckService = new AccessCheckService(); @@ -365,9 +377,9 @@ export class CommitteeService { // ── Dashboard Sub-Resource Methods ────────────────────────────────────────── - public async getCommitteeVotes(req: Request, committeeId: string): Promise { + public async getCommitteeVotes(req: Request, committeeId: string): Promise { try { - const { resources: committeeVoteResources } = await this.microserviceProxy.proxyRequest>( + const { resources: committeeVoteResources } = await this.microserviceProxy.proxyRequest>( req, 'LFX_V2_SERVICE', '/query/resources', @@ -382,24 +394,28 @@ export class CommitteeService { return committeeVoteResources.map((r) => r.data); } - const committee = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}`, 'GET'); + 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>( - req, - 'LFX_V2_SERVICE', - '/query/resources', - 'GET', - { - type: 'vote', - parent: `project:${projectUid}`, - page_size: 100, - } - ); + 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}`, + page_size: 100, + }); return voteResources .filter((r) => r.data.committee_uid === committeeId) @@ -422,12 +438,18 @@ export class CommitteeService { } } - public async getCommitteeResolutions(req: Request, committeeId: string): Promise { + public async getCommitteeResolutions(req: Request, committeeId: string): Promise { try { - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { - type: 'committee_resolution', - tags: `committee_uid:${committeeId}`, - }); + const { resources } = await this.microserviceProxy.proxyRequest>( + req, + 'LFX_V2_SERVICE', + '/query/resources', + 'GET', + { + type: 'committee_resolution', + tags: `committee_uid:${committeeId}`, + } + ); return resources.map((r) => r.data); } catch { logger.warning(req, 'get_committee_resolutions', 'Failed to fetch committee resolutions, returning empty', { @@ -437,12 +459,18 @@ export class CommitteeService { } } - public async getCommitteeActivity(req: Request, committeeId: string): Promise { + public async getCommitteeActivity(req: Request, committeeId: string): Promise { try { - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { - type: 'committee_activity', - tags: `committee_uid:${committeeId}`, - }); + const { resources } = await this.microserviceProxy.proxyRequest>( + req, + 'LFX_V2_SERVICE', + '/query/resources', + 'GET', + { + type: 'committee_activity', + tags: `committee_uid:${committeeId}`, + } + ); return resources.map((r) => r.data); } catch { logger.warning(req, 'get_committee_activity', 'Failed to fetch committee activity, returning empty', { @@ -452,12 +480,18 @@ export class CommitteeService { } } - public async getCommitteeContributors(req: Request, committeeId: string): Promise { + public async getCommitteeContributors(req: Request, committeeId: string): Promise { try { - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { - type: 'committee_contributor', - tags: `committee_uid:${committeeId}`, - }); + const { resources } = await this.microserviceProxy.proxyRequest>( + req, + 'LFX_V2_SERVICE', + '/query/resources', + 'GET', + { + type: 'committee_contributor', + tags: `committee_uid:${committeeId}`, + } + ); return resources.map((r) => r.data); } catch { logger.warning(req, 'get_committee_contributors', 'Failed to fetch committee contributors, returning empty', { @@ -467,12 +501,18 @@ export class CommitteeService { } } - public async getCommitteeDeliverables(req: Request, committeeId: string): Promise { + public async getCommitteeDeliverables(req: Request, committeeId: string): Promise { try { - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { - type: 'committee_deliverable', - tags: `committee_uid:${committeeId}`, - }); + const { resources } = await this.microserviceProxy.proxyRequest>( + req, + 'LFX_V2_SERVICE', + '/query/resources', + 'GET', + { + type: 'committee_deliverable', + tags: `committee_uid:${committeeId}`, + } + ); return resources.map((r) => r.data); } catch { logger.warning(req, 'get_committee_deliverables', 'Failed to fetch committee deliverables, returning empty', { @@ -482,12 +522,18 @@ export class CommitteeService { } } - public async getCommitteeDiscussions(req: Request, committeeId: string): Promise { + public async getCommitteeDiscussions(req: Request, committeeId: string): Promise { try { - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { - type: 'committee_discussion', - tags: `committee_uid:${committeeId}`, - }); + const { resources } = await this.microserviceProxy.proxyRequest>( + req, + 'LFX_V2_SERVICE', + '/query/resources', + 'GET', + { + type: 'committee_discussion', + tags: `committee_uid:${committeeId}`, + } + ); return resources.map((r) => r.data); } catch { logger.warning(req, 'get_committee_discussions', 'Failed to fetch committee discussions, returning empty', { @@ -497,9 +543,9 @@ export class CommitteeService { } } - public async getCommitteeEvents(req: Request, committeeId: string): Promise { + public async getCommitteeEvents(req: Request, committeeId: string): Promise { try { - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { type: 'committee_event', tags: `committee_uid:${committeeId}`, }); @@ -512,12 +558,18 @@ export class CommitteeService { } } - public async getCommitteeCampaigns(req: Request, committeeId: string): Promise { + public async getCommitteeCampaigns(req: Request, committeeId: string): Promise { try { - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { - type: 'committee_campaign', - tags: `committee_uid:${committeeId}`, - }); + const { resources } = await this.microserviceProxy.proxyRequest>( + req, + 'LFX_V2_SERVICE', + '/query/resources', + 'GET', + { + type: 'committee_campaign', + tags: `committee_uid:${committeeId}`, + } + ); return resources.map((r) => r.data); } catch { logger.warning(req, 'get_committee_campaigns', 'Failed to fetch committee campaigns, returning empty', { @@ -527,9 +579,9 @@ export class CommitteeService { } } - public async getCommitteeEngagement(req: Request, committeeId: string): Promise { + public async getCommitteeEngagement(req: Request, committeeId: string): Promise { try { - return await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/engagement`, 'GET'); + return await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/engagement`, 'GET'); } catch { logger.warning(req, 'get_committee_engagement', 'Failed to fetch committee engagement, returning null', { committee_uid: committeeId, @@ -538,9 +590,9 @@ export class CommitteeService { } } - public async getCommitteeBudget(req: Request, committeeId: string): Promise { + public async getCommitteeBudget(req: Request, committeeId: string): Promise { try { - return await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/budget`, 'GET'); + return await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/budget`, 'GET'); } catch { logger.warning(req, 'get_committee_budget', 'Failed to fetch committee budget, returning null', { committee_uid: committeeId, @@ -564,9 +616,11 @@ export class CommitteeService { }); // Lazy import to avoid circular dependency (MeetingService imports CommitteeService) - const { MeetingService } = await import('./meeting.service'); - const meetingService = new MeetingService(); - const result = await meetingService.getMeetings(req, params); + if (!this.cachedMeetingService) { + const { MeetingService } = await import('./meeting.service'); + this.cachedMeetingService = new MeetingService(); + } + const result = await this.cachedMeetingService.getMeetings(req, params); logger.debug(req, 'get_committee_meetings', 'Fetched committee meetings', { committee_uid: committeeId, From 4a22ae42e8ba01404eccff07323360752530adf9 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 18:22:46 -0700 Subject: [PATCH 35/48] fix(committees): address review feedback on votes tab component - Replace BehaviorSubject fetch$/refresh$ with signal-based triggers - Fix section headers from '// === Name ===' to '// -- Name --' - Remove redundant totalCount signal, convert to subscription that sets totalRecords Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-votes-list.component.ts | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) 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 index 3b22ad4b7..3bd7bfb9a 100644 --- 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 @@ -2,12 +2,12 @@ // SPDX-License-Identifier: MIT import { Component, computed, inject, input, signal, Signal } from '@angular/core'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +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 { BehaviorSubject, catchError, combineLatest, map, of, switchMap, tap } from 'rxjs'; +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'; @@ -18,22 +18,22 @@ import { VotesTableComponent } from '@app/modules/votes/components/votes-table/v templateUrl: './committee-votes-list.component.html', }) export class CommitteeVotesListComponent { - // === Services === + // -- Services -- private readonly voteService = inject(VoteService); - // === Constants === + // -- Constants -- protected readonly voteLabelPlural = VOTE_LABEL.plural; - // === Inputs === + // -- Inputs -- public readonly projectUid = input.required(); public readonly committeeName = input.required(); public readonly hasPMOAccess = input(false); - // === Subjects === - private readonly fetch$ = new BehaviorSubject(undefined); - private readonly refresh$ = new BehaviorSubject(undefined); + // -- Trigger signals -- + private readonly fetchTrigger = signal(0); + private readonly refreshTrigger = signal(0); - // === Writable Signals === + // -- Writable Signals -- protected readonly loading = signal(true); protected readonly resultsDrawerVisible = signal(false); protected readonly selectedVoteId = signal(null); @@ -41,18 +41,21 @@ export class CommitteeVotesListComponent { protected readonly currentFirst = signal(0); protected readonly totalRecords = signal(0); - // === Filter State === + // -- Filter State -- protected readonly filters = signal({ search: '', status: null, group: null }); - // === Page Tokens === + // -- Page Tokens -- private pageTokens: string[] = []; - // === Computed Signals === + // -- Computed Signals -- protected readonly votes: Signal = this.initVotes(); protected readonly selectedListVote: Signal = this.initSelectedListVote(); - protected readonly totalCount: Signal = this.initTotalCount(); - // === Protected Methods === + public constructor() { + this.initTotalCountSubscription(); + } + + // -- Protected Methods -- protected onViewVote(voteId: string): void { this.selectedVoteId.set(voteId); this.resultsDrawerVisible.set(true); @@ -62,8 +65,8 @@ export class CommitteeVotesListComponent { this.loading.set(true); this.pageTokens = []; this.currentFirst.set(0); - this.fetch$.next(); - this.refresh$.next(); + this.fetchTrigger.update((v) => v + 1); + this.refreshTrigger.update((v) => v + 1); } protected onPageChange(event: { first: number; rows: number }): void { @@ -71,12 +74,12 @@ export class CommitteeVotesListComponent { this.pageTokens = []; this.rowsPerPage.set(event.rows); this.currentFirst.set(0); - this.fetch$.next(); + this.fetchTrigger.update((v) => v + 1); return; } this.currentFirst.set(event.first); - this.fetch$.next(); + this.fetchTrigger.update((v) => v + 1); } protected onFiltersChange(state: VoteFilterState): void { @@ -85,7 +88,7 @@ export class CommitteeVotesListComponent { this.filters.set(state); } - // === Private Helpers === + // -- Private Helpers -- private buildFilters(): string[] { const queryFilters: string[] = [`committee_name:${this.committeeName()}`]; const { status } = this.filters(); @@ -95,38 +98,35 @@ export class CommitteeVotesListComponent { return queryFilters; } - // === Private Initializers === - private initTotalCount(): Signal { + // -- 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); - return toSignal( - combineLatest([projectUid$, committeeName$, filters$, this.refresh$]).pipe( + 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( - tap((count) => this.totalRecords.set(count)), - catchError(() => { - this.totalRecords.set(0); - return of(0); - }) - ); - }) - ), - { initialValue: 0 } - ); + 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$, this.fetch$]).pipe( + combineLatest([projectUid$, committeeName$, filters$, fetch$]).pipe( tap(() => this.loading.set(true)), switchMap(([projectUid]) => { if (!projectUid) { From b988e1e106dbad6f0775373706bdf8b5926c27e1 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 18:24:50 -0700 Subject: [PATCH 36/48] fix(committees): address review feedback on members tab PR - Remove duplicate Tab visibility comment and refreshMembers() dead code - Add canManageMembers signal with comment noting equivalence to parent gate - Remove ChangeDetectorRef anti-pattern (4 detectChanges calls) - Make role/voting_status validators conditional on enable_voting - Fix hasOrg check to include organization.website - Type buildOrganizationPayload with MemberFormValue interface - Remove duplicate:true from dialog configs Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.ts | 10 +++---- .../committee-members.component.ts | 2 -- .../member-form/member-form.component.ts | 27 ++++++++----------- .../shared/src/interfaces/member.interface.ts | 22 +++++++++++++++ 4 files changed, 37 insertions(+), 24 deletions(-) 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 e4b4d8d65..a7c8b7b36 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 @@ -148,10 +148,12 @@ export class CommitteeViewComponent { public isBoardMember: Signal = computed(() => this.personaService.currentPersona() === 'board-member'); public isMaintainer: Signal = computed(() => this.personaService.currentPersona() === 'maintainer'); public canManageConfigurations: Signal = computed(() => this.isMaintainer() || (!!this.committee()?.writer && !this.isBoardMember())); + // Note: canManageMembers is logically equivalent to canManageConfigurations (De Morgan's law), + // kept separate to align with the child component's permission check. + public canManageMembers: Signal = computed(() => !this.isBoardMember() && (!!this.committee()?.writer || this.isMaintainer())); // -- Tab visibility signals -- - // -- Tab visibility signals -- - public isMembersTabVisible: Signal = computed(() => this.committee()?.member_visibility !== 'hidden' || this.canManageConfigurations()); + public isMembersTabVisible: Signal = computed(() => this.committee()?.member_visibility !== 'hidden' || this.canManageMembers()); // -- Behavioral class signals -- public behavioralClass: Signal = computed(() => getGroupBehavioralClass(this.committee()?.category)); @@ -223,10 +225,6 @@ export class CommitteeViewComponent { this.refresh.update((v) => v + 1); } - public refreshMembers(): void { - this.refresh.next(); - } - public getMembersCountByOrg(org: string): number { return this.members().filter((m) => m.organization?.name === org).length; } 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 16285b72f..62ccb08eb 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 @@ -119,7 +119,6 @@ export class CommitteeMembersComponent implements OnInit { width: '700px', modal: true, closable: true, - duplicate: true, data: { isEditing: false, committee: this.committee(), @@ -144,7 +143,6 @@ export class CommitteeMembersComponent implements OnInit { width: '700px', modal: true, closable: true, - duplicate: true, data: { isEditing: true, memberId: member.uid, 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 c0ba4117c..f5bc9585d 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,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { ChangeDetectorRef, Component, inject, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; @@ -11,7 +11,7 @@ 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 { APPOINTED_BY_OPTIONS, LINKEDIN_PROFILE_PATTERN, MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; -import { Committee, CommitteeMember, CreateCommitteeMemberRequest } from '@lfx-one/shared/interfaces'; +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'; @@ -28,7 +28,6 @@ export class MemberFormComponent { private readonly dialogRef = inject(DynamicDialogRef); private readonly committeeService = inject(CommitteeService); private readonly messageService = inject(MessageService); - private readonly cdr = inject(ChangeDetectorRef); // Loading state for form submissions public submitting = signal(false); @@ -91,19 +90,16 @@ export class MemberFormComponent { this.form().get('role_start')?.reset(); this.form().get('role_end')?.reset(); this.form().updateValueAndValidity(); - this.cdr.detectChanges(); } public clearVotingDates(): void { this.form().get('voting_status_start')?.reset(); this.form().get('voting_status_end')?.reset(); this.form().updateValueAndValidity(); - this.cdr.detectChanges(); } public onDateChange(): void { this.form().updateValueAndValidity(); - this.cdr.detectChanges(); } public onCancel(): void { @@ -114,7 +110,7 @@ export class MemberFormComponent { public onSubmit(): void { if (this.form().valid) { this.submitting.set(true); - const formValue = this.form().getRawValue(); + const formValue = this.form().getRawValue() as MemberFormValue; // Prepare member data using form values, mapping to new structure const memberData: CreateCommitteeMemberRequest = { @@ -194,14 +190,13 @@ export class MemberFormComponent { }); } else { this.form().markAllAsTouched(); - this.cdr.detectChanges(); } } private initializeForm(): void { if (this.isEditing && this.member) { const member = this.member; - const hasOrg = !!member.organization?.name; + const hasOrg = !!member.organization?.name || !!member.organization?.website; this.form().patchValue({ first_name: member.first_name, last_name: member.last_name, @@ -227,14 +222,14 @@ export class MemberFormComponent { } } - private buildOrganizationPayload(formValue: Record): CreateCommitteeMemberRequest['organization'] { - if (formValue['is_individual']) { + private buildOrganizationPayload(formValue: MemberFormValue): CreateCommitteeMemberRequest['organization'] { + if (formValue.is_individual) { return null; } - if (formValue['organization'] || formValue['organization_url']) { + if (formValue.organization || formValue.organization_url) { return { - name: formValue['organization'] || null, - website: formValue['organization_url'] || null, + name: formValue.organization || null, + website: formValue.organization_url || null, }; } return null; @@ -251,8 +246,8 @@ export class MemberFormComponent { is_individual: new FormControl(false), organization: new FormControl('', [Validators.required]), organization_url: new FormControl(''), - role: new FormControl('', [Validators.required]), - voting_status: new FormControl('', [Validators.required]), + 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), diff --git a/packages/shared/src/interfaces/member.interface.ts b/packages/shared/src/interfaces/member.interface.ts index 33d9c23f5..d1cb56d6e 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 From 27212508eccbc62ce3b531e23fa02b4eaa4fb986 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 19:22:29 -0700 Subject: [PATCH 37/48] fix(committees): resolve merge artifacts and address round 2 review on detail overview - Remove duplicate JOIN_MODE_LABELS declaration (build failure) - Fix missing closing braces in committee.service.ts and committee.controller.ts - Merge duplicate import blocks in frontend committee.service.ts - Fix duplicate imports and signals in committee-members.component.ts - Clean committee-view to use refactored CommitteeOverviewComponent (remove 1600+ lines of duplicated old template) - Add TODO comments for PrimeNG wrapper migration and vote pagination - Add clarifying comment on num_response_received field mapping - Add CommitteeLeadership interface and chair/co_chair fields for leadership dialog - Fix logger import path in server committee.service.ts Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 1088 +---------------- .../committee-view.component.ts | 572 +-------- .../committee-members.component.html | 28 +- .../committee-members.component.ts | 4 +- .../app/shared/services/committee.service.ts | 4 +- .../controllers/committee.controller.ts | 68 +- .../src/server/services/committee.service.ts | 11 +- .../src/constants/committees.constants.ts | 11 - .../src/interfaces/committee.interface.ts | 35 +- 9 files changed, 89 insertions(+), 1732 deletions(-) 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 526e4e0d6..0a7d2f9be 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 @@ -2,71 +2,8 @@
- + @if (loading()) { -
- -
-
-
-
-
-
-
-
-
- -
- @for (_ of [1, 2, 3, 4]; track _) { -
-
-
-
-
-
-
-
-
- } -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } @else if (error()) { - -
-
-
- -
-

Group Not Found

-

The group you're looking for doesn't exist, has been removed, or you may not have permission to view it.

- } @else if (error()) { @@ -101,51 +38,25 @@

Something Went Wrong

{{ committee()?.name }}

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

{{ committee()?.description }}

} - - @if (committee()?.key_topics?.length) { -
- @for (topic of committee()!.key_topics!; track topic) { - {{ topic }} - } -
- }
@if (committee()?.category) { } @if (committee()?.enable_voting) { - - Voting Enabled - - } - Created {{ formattedCreatedDate() }} - @if (committee()?.updated_at) { - · Updated {{ formattedUpdatedDate() }} } 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' }} }
- @if (canManageConfigurations()) { -
- @if (canEdit()) {
@@ -153,1000 +64,7 @@

{{ committee()?.name }}

}
- - - - Overview - @if (isVotesTabVisible()) { - Votes - @if (isMembersTabVisible()) { - Members - } - Meetings - Documents - @if (canManageConfigurations()) { - Settings - } - - - - - -
- -
- - -
-
- -
-
-

{{ totalMembers() }}

-

Members

-
-
-
- - - @if (committee()?.enable_voting) { - -
-
- -
-
-

{{ activeVoters() }}

-

Voting Reps

-
-
-
- } - - - -
-
- -
-
-

{{ orgCount() }}

-

Organizations

-
-
-
- - - -
-
- -
-
-

{{ observerCount() }}

-

Observers

-
-
-
- - - -
-
- -
-
-

{{ joinModeLabel() }}

-

Join Mode

-
-
-
-
- - -
- -
- - @if (isGovernanceClass()) { - - @if (openVotes().length > 0) { - -
-

Open Votes

- {{ openVotes().length }} pending -
-
- @for (vote of openVotes(); track vote.uid) { -
-
-
-

{{ vote.title }}

-

Proposed by {{ vote.created_by }}

-
- -
- -
-
- {{ vote.votes_for + vote.votes_against + vote.votes_abstain }} of {{ vote.total_eligible }} votes cast - Deadline: {{ vote.deadline | date: 'MMM d, y' }} -
-
-
-
-
-
-
- {{ vote.votes_for }} for - {{ vote.votes_against }} against - {{ vote.votes_abstain }} abstain -
-
-
- } -
-
- } @else { - - -
- -

No Open Votes

-

All votes have been resolved. Check back before your next board meeting.

-
-
- } - - - @if (budgetSummary(); as budget) { - -
-

Budget Summary — FY{{ budget.fiscal_year }}

- @if (canManageConfigurations()) { - - } -
- -
-
- {{ budget.total_budget ? ((budget.spent / budget.total_budget) * 100 | number: '1.0-0') : 0 }}% spent - ${{ budget.total_budget ? (budget.total_budget / 1000000 | number: '1.1-1') : '0.0' }}M total -
-
-
-
-
-
- - - Spent: ${{ budget.spent / 1000 | number: '1.0-0' }}K - - - - Committed: ${{ budget.committed / 1000 | number: '1.0-0' }}K - - - - Remaining: ${{ budget.remaining / 1000 | number: '1.0-0' }}K - -
-
- -
- @for (cat of budget.categories; track cat.name) { -
- {{ cat.name }} -
-
-
-
- - ${{ cat.spent / 1000 | number: '1.0-0' }}K / ${{ cat.allocated / 1000 | number: '1.0-0' }}K - -
-
- } -
-
- } - - - @if (recentResolutions().length > 0) { - -
-

Recent Resolutions

-
- @for (res of recentResolutions(); track res.uid) { -
-
-

{{ res.title }}

-

{{ res.date | date: 'MMM d, y' }} · {{ res.votes_for }}-{{ res.votes_against }}

-
- -
- } -
-
-
- } - } - - - @if (isWorkingGroup() || isOversightCommittee()) { - - @if (recentActivity().length > 0) { - -
-

Recent Activity

- Last 48 hours -
-
- @for (activity of recentActivity(); track activity.uid) { -
-
- -
-
-

{{ activity.title }}

-
- {{ activity.author }} - · - {{ activity.repo }} - · - {{ activity.timestamp | date: 'MMM d, h:mm a' }} -
-
-
- } -
-
- } @else { - - -
- -

No Recent Activity

-

Here is how to get involved: check open issues, join a meeting, or submit a PR.

-
-
- } - - - @if (topContributors().length > 0) { - -
-

Top Contributors

- Last 30 days -
-
- @for (contrib of topContributors(); track contrib.name; let i = $index) { -
- #{{ i + 1 }} -
- - {{ contrib.name.split(' ')[0]?.charAt(0) }}{{ contrib.name.split(' ')[1]?.charAt(0) }} - -
-
-

{{ contrib.name }}

-

{{ contrib.org }}

-
-
- {{ contrib.commits }} - {{ contrib.prs }} - {{ contrib.reviews }} -
-
- } -
-
- } - } - - - @if (isWorkingGroup() && deliverables().length > 0) { - -
-

Deliverables & Milestones

- {{ deliverables().length }} items -
-
- @for (item of deliverables(); track item.uid) { -
-
-
-

{{ item.title }}

-

Owner: {{ item.owner }} · Due {{ item.due_date | date: 'MMM d, y' }}

-
- -
- -
-
-
-
- {{ item.progress }}% -
-
- } -
-
- } - - - @if (isSpecialInterestGroup() && discussionThreads().length > 0) { - -
-

Active Discussions

- {{ discussionThreads().length }} threads -
-
- @for (thread of discussionThreads(); track thread.uid) { -
-
- -
-
-

{{ thread.title }}

-
- {{ thread.author }} - · - {{ thread.replies }} replies - · - {{ thread.last_activity | date: 'MMM d, h:mm a' }} -
- @if (thread.tags?.length) { -
- @for (tag of thread.tags; track tag) { - {{ tag }} - } -
- } -
-
- } -
-
- } - - - @if (isSpecialInterestGroup() && upcomingEvents().length > 0) { - -
-

Upcoming Events

- {{ upcomingEvents().length }} upcoming -
-
- @for (evt of upcomingEvents(); track evt.uid) { -
-
-
-

{{ evt.title }}

-

{{ evt.date | date: 'EEE, MMM d · h:mm a' }}

-

{{ evt.speaker }}

-
-
- - {{ evt.attendees }} registered -
-
-
- } -
-
- } - - - @if (isAmbassadorProgram() && outreachCampaigns().length > 0) { - -
-

Outreach Campaigns

- {{ outreachCampaigns().length }} campaigns -
-
- @for (campaign of outreachCampaigns(); track campaign.uid) { -
-
- -
-
-

{{ campaign.title }}

-
- @if (campaign.status === 'active') { - - {{ campaign.reach | number }} reached · {{ campaign.conversions }} conversions ({{ campaign.conversion_rate }}%) - - } @else { - Upcoming - } -
-
- -
- } -
-
- } - - - @if (isAmbassadorProgram() && engagementMetrics(); as metrics) { - -

Engagement Metrics

-
-
-

{{ metrics.total_reach | number }}

-

Total Reach

-
-
-

+{{ metrics.new_members_30d }}

-

New Members (30d)

-
-
-

{{ metrics.event_attendance | number }}

-

Event Attendance

-
-
-

{{ metrics.newsletter_open_rate }}%

-

Newsletter Open Rate

-
-
-
-
- - {{ metrics.social_impressions | number }} social impressions -
-
- - {{ metrics.ambassador_count }} ambassadors -
-
-
- } - - - @if (lastMeetingSummary(); as summary) { - @if (summary.approved || canManageConfigurations()) { - -
-

- Last Meeting Summary -

- @if (lastPastMeeting(); as meeting) { - {{ meeting.start_time | date: 'MMM d, y' }} - } -
- @if (lastPastMeeting(); as meeting) { -

{{ meeting.title }}

- } -
- {{ summary.summary_data.edited_content || summary.summary_data.content }} -
-
- - -
-
- } - } - - - -
-

Channels

-
- - @if (committee()?.mailing_list; as mailingList) { -
-

Mailing List

-
-
- -
-
- @if (mailingList.url) { - - {{ mailingList.name }} - - - } @else { -

{{ mailingList.name }}

- } - @if (mailingList.subscriber_count) { -

{{ mailingList.subscriber_count }} subscribers

- } -
-
-
- } @else { -
-

Mailing List

-
-
- -
-

No mailing list connected

-
-
- } - - - @if (committee()?.chat_channel; as chatChannel) { -
-

- {{ chatChannel.platform === 'slack' ? 'Slack' : 'Discord' }} Channel -

-
-
- -
-
- @if (chatChannel.url) { - - {{ chatChannel.name }} - - - } @else { -

{{ chatChannel.name }}

- } -

{{ chatChannel.platform }}

-
-
-
- } @else { -
-

Chat Channel

-
-
- -
-

No chat channel connected

-
-
- } - - - @if (committee()?.website) { -
-

Website

- -
- } -
-
-
- - - @if (canManageConfigurations()) { - -
-

Configurations

-
-
- Public - -
-
- Voting - -
-
- Business Email - -
-
- Audit - -
-
- Join Mode - -
-
- SSO Group - -
-
-
-
- } -
- - -
- - -
-

Leadership

- @if (canManageConfigurations() && !hasChair() && !hasCoChair()) { - - - } -
-
- -
-

Chair

- @if (chair(); as c) { -
-
- {{ c.first_name?.charAt(0) }}{{ c.last_name?.charAt(0) }} -
-
-

{{ c.first_name }} {{ c.last_name }}

- @if (c.organization) { -

{{ c.organization }}

- } - @if (chairElectedDate()) { -

Since {{ chairElectedDate() }}

- } -
- @if (canManageConfigurations()) { - - } -
- } @else { -
-
- -
-
-

Vacant

- @if (canManageConfigurations()) { - - } -
-
- } -
- - -
-

Co-Chair

- @if (coChair(); as cc) { -
-
- {{ cc.first_name?.charAt(0) }}{{ cc.last_name?.charAt(0) }} -
-
-

{{ cc.first_name }} {{ cc.last_name }}

- @if (cc.organization) { -

{{ cc.organization }}

- } - @if (coChairElectedDate()) { -

Since {{ coChairElectedDate() }}

- } -
- @if (canManageConfigurations()) { - - } -
- } @else { -
-
- -
-
-

Vacant

- @if (canManageConfigurations()) { - - } -
-
- } -
-
-
- - - @if (uniqueOrganizations().length > 0) { - -
-

Organizations

-
- @for (org of uniqueOrganizations().slice(0, 8); track org) { -
- {{ org }} - - {{ memberCountByOrg().get(org) || 0 }} - {{ (memberCountByOrg().get(org) || 0) === 1 ? 'member' : 'members' }} - -
- } - @if (uniqueOrganizations().length > 8) { -

+ {{ uniqueOrganizations().length - 8 }} more organizations

- } -
-
-
- } - - - @if (committee()?.enable_voting && roleBreakdown().length > 0) { - -
-

Roles

-
- @for (role of roleBreakdown(); track role.name) { -
- {{ role.name }} - {{ role.count }} -
- } -
-
-
- } -
-
-
-
- - - @if (isVotesTabVisible()) { - -
- @if (committee(); as c) { - - - - @if (isMembersTabVisible()) { - -
- - @if (committee()?.uid) { - - - } -
-
- } - - - -
- -
-

Meetings

-
-
- - -
- @if (canManageConfigurations()) { - - - } -
-
- - - @if (meetingsLoading()) { -
- @for (i of [1, 2, 3]; track i) { -
- } -
- } @else if (meetingViewFilter() === 'upcoming' && upcomingMeetings().length > 0) { -
- @for (meeting of upcomingMeetings(); track meeting.id) { - - - } -
- - } @else if (meetingViewFilter() === 'past' && pastCommitteeMeetings().length > 0) { -
- @for (meeting of pastCommitteeMeetings(); track meeting.id) { - - - } -
- } @else { - -
- - @if (meetingViewFilter() === 'upcoming') { -

No Upcoming Meetings

-

There are no upcoming meetings scheduled for this group.

- } @else { -

No Past Meetings

-

No past meetings found for this group.

- } -
-
- } -
-
- - - -
-
- -

Documents

-

Coming in a future PR

-
-
-
- - - @if (canManageConfigurations()) { - -
- -
- - -
-
-
- } - - +
- -
-
- -
- - - @if (form().get('role')?.errors?.['required'] && form().get('role')?.touched) { -

Role is required

- } -
+ + @if (committee?.enable_voting) { +
+
+ +
+ + + @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().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

+ }
- @if (form().errors?.['role_start_after_role_end']) { -

Role end date must be after start date

- }
- +
- + - @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

- } + id="appointed-by" + data-testid="member-form-appointed-by">
- - -
- - -
-
+ }
diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index ec8f5c740..100625dcc 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -34,11 +34,9 @@ import { AccessCheckService } from './access-check.service'; import { ETagService } from './etag.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; -function getVoteStatus(status: string): 'open' | 'closed' | 'cancelled' { - if (status === 'ended') return 'closed'; - if (status === 'cancelled') return 'cancelled'; - return 'open'; -} +// 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 @@ -48,7 +46,7 @@ export class CommitteeService { private etagService: ETagService; private microserviceProxy: MicroserviceProxyService; // Cached lazy-loaded MeetingService to avoid creating a new instance per request - private cachedMeetingService?: { getMeetings: (req: Request, params: Record) => Promise> }; + private cachedMeetingService?: MeetingServiceType; public constructor() { this.accessCheckService = new AccessCheckService(); @@ -458,7 +456,7 @@ export class CommitteeService { .map((r) => ({ uid: r.data.uid, title: r.data.name, - status: getVoteStatus(r.data.status), + status: CommitteeService.getVoteStatus(r.data.status), deadline: r.data.end_time, // Note: num_response_received is total responses received, not a for/against breakdown votes_for: r.data.num_response_received ?? 0, @@ -477,144 +475,31 @@ export class CommitteeService { } public async getCommitteeResolutions(req: Request, committeeId: string): Promise { - try { - const { resources } = await this.microserviceProxy.proxyRequest>( - req, - 'LFX_V2_SERVICE', - '/query/resources', - 'GET', - { - type: 'committee_resolution', - tags: `committee_uid:${committeeId}`, - } - ); - return resources.map((r) => r.data); - } catch { - logger.warning(req, 'get_committee_resolutions', 'Failed to fetch committee resolutions, returning empty', { - committee_uid: committeeId, - }); - return []; - } + return this.getSubResource(req, committeeId, 'committee_resolution', 'get_committee_resolutions'); } public async getCommitteeActivity(req: Request, committeeId: string): Promise { - try { - const { resources } = await this.microserviceProxy.proxyRequest>( - req, - 'LFX_V2_SERVICE', - '/query/resources', - 'GET', - { - type: 'committee_activity', - tags: `committee_uid:${committeeId}`, - } - ); - return resources.map((r) => r.data); - } catch { - logger.warning(req, 'get_committee_activity', 'Failed to fetch committee activity, returning empty', { - committee_uid: committeeId, - }); - return []; - } + return this.getSubResource(req, committeeId, 'committee_activity', 'get_committee_activity'); } public async getCommitteeContributors(req: Request, committeeId: string): Promise { - try { - const { resources } = await this.microserviceProxy.proxyRequest>( - req, - 'LFX_V2_SERVICE', - '/query/resources', - 'GET', - { - type: 'committee_contributor', - tags: `committee_uid:${committeeId}`, - } - ); - return resources.map((r) => r.data); - } catch { - logger.warning(req, 'get_committee_contributors', 'Failed to fetch committee contributors, returning empty', { - committee_uid: committeeId, - }); - return []; - } + return this.getSubResource(req, committeeId, 'committee_contributor', 'get_committee_contributors'); } public async getCommitteeDeliverables(req: Request, committeeId: string): Promise { - try { - const { resources } = await this.microserviceProxy.proxyRequest>( - req, - 'LFX_V2_SERVICE', - '/query/resources', - 'GET', - { - type: 'committee_deliverable', - tags: `committee_uid:${committeeId}`, - } - ); - return resources.map((r) => r.data); - } catch { - logger.warning(req, 'get_committee_deliverables', 'Failed to fetch committee deliverables, returning empty', { - committee_uid: committeeId, - }); - return []; - } + return this.getSubResource(req, committeeId, 'committee_deliverable', 'get_committee_deliverables'); } public async getCommitteeDiscussions(req: Request, committeeId: string): Promise { - try { - const { resources } = await this.microserviceProxy.proxyRequest>( - req, - 'LFX_V2_SERVICE', - '/query/resources', - 'GET', - { - type: 'committee_discussion', - tags: `committee_uid:${committeeId}`, - } - ); - return resources.map((r) => r.data); - } catch { - logger.warning(req, 'get_committee_discussions', 'Failed to fetch committee discussions, returning empty', { - committee_uid: committeeId, - }); - return []; - } + return this.getSubResource(req, committeeId, 'committee_discussion', 'get_committee_discussions'); } public async getCommitteeEvents(req: Request, committeeId: string): Promise { - try { - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { - type: 'committee_event', - tags: `committee_uid:${committeeId}`, - }); - return resources.map((r) => r.data); - } catch { - logger.warning(req, 'get_committee_events', 'Failed to fetch committee events, returning empty', { - committee_uid: committeeId, - }); - return []; - } + return this.getSubResource(req, committeeId, 'committee_event', 'get_committee_events'); } public async getCommitteeCampaigns(req: Request, committeeId: string): Promise { - try { - const { resources } = await this.microserviceProxy.proxyRequest>( - req, - 'LFX_V2_SERVICE', - '/query/resources', - 'GET', - { - type: 'committee_campaign', - tags: `committee_uid:${committeeId}`, - } - ); - return resources.map((r) => r.data); - } catch { - logger.warning(req, 'get_committee_campaigns', 'Failed to fetch committee campaigns, returning empty', { - committee_uid: committeeId, - }); - return []; - } + return this.getSubResource(req, committeeId, 'committee_campaign', 'get_committee_campaigns'); } public async getCommitteeEngagement(req: Request, committeeId: string): Promise { @@ -644,6 +529,8 @@ export class CommitteeService { */ public async getCommitteeMeetings(req: Request, committeeId: string, query: Record = {}): Promise> { try { + // Note: req.query is passed through from the controller. The downstream MeetingService + // validates and sanitizes parameters before forwarding to the meeting microservice. const params = { ...query, committee_uid: committeeId, @@ -805,4 +692,36 @@ export class CommitteeService { settings_data: settingsData, }); } + + /** + * Generic helper for fetching committee sub-resources from the query service. + * All tag-based sub-resource queries follow the same pattern: query by type + committee_uid tag, + * map results to data, and return empty array on failure. + */ + private async getSubResource(req: Request, committeeId: string, resourceType: string, operation: string): Promise { + try { + logger.debug(req, operation, `Fetching ${operation.replace(/_/g, ' ')}`, { committee_id: committeeId }); + + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { + type: resourceType, + tags: `committee_uid:${committeeId}`, + }); + + return resources.map((r) => r.data); + } catch { + logger.warning(req, operation, `Failed to fetch ${operation.replace(/_/g, ' ')}, returning empty`, { + committee_uid: committeeId, + }); + return []; + } + } + + /** + * 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'; + } } From cb0640a34612265adcf66599e6395606a6b33405 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 19:54:00 -0700 Subject: [PATCH 39/48] fix(committees): address round 4 review findings on detail overview - Rename votes_for to total_responses in fallback vote mapping (honest field names) - Type error handler as HttpErrorResponse in member-form (was implicit any) - Remove unused ConfirmationService + ConfirmDialogModule from committee-view - Add req.query parameter whitelist in getCommitteeMeetings (security) - Remove take(1) from frontend getCommitteeMeetings for consistency Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 3 --- .../committee-view/committee-view.component.ts | 17 ++--------------- .../member-form/member-form.component.ts | 6 +++--- .../app/shared/services/committee.service.ts | 5 +---- .../src/server/services/committee.service.ts | 17 ++++++++++++----- .../src/interfaces/committee.interface.ts | 2 ++ 6 files changed, 20 insertions(+), 30 deletions(-) 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 0a7d2f9be..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 @@ -183,7 +183,4 @@

{{ 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 b4abf22d1..714efb9e8 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 @@ -11,9 +11,7 @@ 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'; -// TODO: Replace with LFX wrappers when available -import { ConfirmDialogModule } from 'primeng/confirmdialog'; -import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; +import { MenuItem, MessageService } from 'primeng/api'; import { catchError, combineLatest, finalize, of, switchMap } from 'rxjs'; import { CommitteeOverviewComponent } from '../components/committee-overview/committee-overview.component'; @@ -22,18 +20,7 @@ type CommitteeTab = 'overview' | 'members' | 'votes' | 'meetings' | 'surveys' | @Component({ selector: 'lfx-committee-view', - imports: [ - BreadcrumbComponent, - ButtonComponent, - TagComponent, - ConfirmDialogModule, - RouterLink, - RouteLoadingComponent, - DatePipe, - NgClass, - CommitteeOverviewComponent, - ], - providers: [ConfirmationService], + imports: [BreadcrumbComponent, ButtonComponent, TagComponent, RouterLink, RouteLoadingComponent, DatePipe, NgClass, CommitteeOverviewComponent], templateUrl: './committee-view.component.html', styleUrl: './committee-view.component.scss', }) 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 f5bc9585d..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,6 +1,7 @@ // 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms'; @@ -170,10 +171,9 @@ export class MemberFormComponent { }); this.dialogRef.close(true); }, - error: (error) => { + error: (err: HttpErrorResponse) => { this.submitting.set(false); - - if (error.status === 409) { + if (err.status === 409) { this.messageService.add({ severity: 'error', summary: 'Error', 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 1787cbba2..bc3bd6bb8 100644 --- a/apps/lfx-one/src/app/shared/services/committee.service.ts +++ b/apps/lfx-one/src/app/shared/services/committee.service.ts @@ -98,10 +98,7 @@ export class CommitteeService { } public getCommitteeMeetings(committeeId: string): Observable { - return this.http.get>(`/api/committees/${committeeId}/meetings`).pipe( - map((response) => response.data), - take(1) - ); + 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 diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index 100625dcc..c81469e46 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -458,10 +458,12 @@ export class CommitteeService { title: r.data.name, status: CommitteeService.getVoteStatus(r.data.status), deadline: r.data.end_time, - // Note: num_response_received is total responses received, not a for/against breakdown - votes_for: r.data.num_response_received ?? 0, + // Per-option breakdown not available from the vote resource; set to 0 + votes_for: 0, votes_against: 0, votes_abstain: 0, + // Carry the real response count separately so the UI can display it accurately + total_responses: r.data.num_response_received ?? 0, total_eligible: r.data.total_voting_request_invitations ?? 0, created_by: '', })); @@ -529,10 +531,15 @@ export class CommitteeService { */ public async getCommitteeMeetings(req: Request, committeeId: string, query: Record = {}): Promise> { try { - // Note: req.query is passed through from the controller. The downstream MeetingService - // validates and sanitizes parameters before forwarding to the meeting microservice. + // 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 = { - ...query, + ...sanitizedQuery, committee_uid: committeeId, }; diff --git a/packages/shared/src/interfaces/committee.interface.ts b/packages/shared/src/interfaces/committee.interface.ts index ef54747dd..932ef456b 100644 --- a/packages/shared/src/interfaces/committee.interface.ts +++ b/packages/shared/src/interfaces/committee.interface.ts @@ -318,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; } From 2d78552a47ca4ca0767d1435059a3ab3898bfdf4 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 20:09:03 -0700 Subject: [PATCH 40/48] fix(committees): fix misleading vote zeros and add error logging to sub-resource handler LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../controllers/committee.controller.ts | 1 + .../src/server/services/committee.service.ts | 32 +++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index 8aa94966f..805753735 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -619,6 +619,7 @@ export class CommitteeController { }); res.json(result); } catch (error) { + logger.error(req, operation, startTime, error, { committee_id: committeeId }); next(error); } }; diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index c81469e46..1e30e5061 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -453,20 +453,24 @@ export class CommitteeService { return voteResources .filter((r) => r.data.committee_uid === committeeId) - .map((r) => ({ - uid: r.data.uid, - title: r.data.name, - status: CommitteeService.getVoteStatus(r.data.status), - deadline: r.data.end_time, - // Per-option breakdown not available from the vote resource; set to 0 - votes_for: 0, - votes_against: 0, - votes_abstain: 0, - // Carry the real response count separately so the UI can display it accurately - total_responses: r.data.num_response_received ?? 0, - total_eligible: r.data.total_voting_request_invitations ?? 0, - created_by: '', - })); + .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, From da1d414b917bb23114876862c5f54c6ba6f7e24e Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 20:23:25 -0700 Subject: [PATCH 41/48] fix(committees): address round 5 review findings on detail overview - Handle leadership assignment partial failure with catchError + warning toast - Use Promise-based lazy pattern for MeetingService dynamic import - Add warning log when vote query hits page_size limit (100) - Simplify dialogRef null guards with optional chaining - Add blank line before Join/Leave route section - Fix MemberFormValue enum types to include empty string initial value LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../assign-leadership-dialog.component.ts | 27 ++++++++++++------- .../committee-members.component.ts | 10 ++----- .../src/server/routes/committees.route.ts | 1 + .../src/server/services/committee.service.ts | 22 ++++++++++----- .../shared/src/interfaces/member.interface.ts | 6 ++--- 5 files changed, 39 insertions(+), 27 deletions(-) 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 index 4ebbd02ce..154da5170 100644 --- 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 @@ -12,7 +12,7 @@ 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 { of, switchMap } from 'rxjs'; +import { catchError, of, switchMap } from 'rxjs'; @Component({ selector: 'lfx-assign-leadership-dialog', @@ -93,17 +93,27 @@ export class AssignLeadershipDialogComponent { }, }; - // Note: Sequential updates can partially fail — the new member may be assigned but the - // previous leader's role may not be cleared. The caller refreshes members on dialog close - // to ensure the UI reflects the actual backend state regardless of partial failure. 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 }, - }); + 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); }) @@ -125,8 +135,7 @@ export class AssignLeadershipDialogComponent { summary: 'Error', detail: `Failed to assign ${this.roleLabel.toLowerCase()}`, }); - // Close with result to trigger a member refresh even on partial failure - this.dialogRef.close({ role: this.role, leadership: null }); + this.dialogRef.close(); }, }); } 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 a8e389c66..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 @@ -130,10 +130,7 @@ export class CommitteeMembersComponent implements OnInit { }, }); - // dialogService.open should always return a ref; guard against null to satisfy strict types - if (!dialogRef) return; - - dialogRef.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { + dialogRef?.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { if (result) { this.refreshMembers(); } @@ -159,10 +156,7 @@ export class CommitteeMembersComponent implements OnInit { }, }); - // dialogService.open should always return a ref; guard against null to satisfy strict types - if (!dialogRef) return; - - dialogRef.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { + dialogRef?.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { if (result) { this.refreshMembers(); } diff --git a/apps/lfx-one/src/server/routes/committees.route.ts b/apps/lfx-one/src/server/routes/committees.route.ts index 8c158e3a8..4ebfb4c58 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -39,6 +39,7 @@ router.get('/:id/events', (req, res, next) => committeeController.getCommitteeEv router.get('/:id/campaigns', (req, res, next) => committeeController.getCommitteeCampaigns(req, res, next)); router.get('/:id/engagement', (req, res, next) => committeeController.getCommitteeEngagement(req, res, next)); router.get('/:id/budget', (req, res, next) => committeeController.getCommitteeBudget(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)); diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index 1e30e5061..00e1ba6ab 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -45,8 +45,8 @@ export class CommitteeService { private accessCheckService: AccessCheckService; private etagService: ETagService; private microserviceProxy: MicroserviceProxyService; - // Cached lazy-loaded MeetingService to avoid creating a new instance per request - private cachedMeetingService?: MeetingServiceType; + // Promise-based lazy initializer to avoid concurrent imports creating duplicate instances + private meetingServicePromise?: Promise; public constructor() { this.accessCheckService = new AccessCheckService(); @@ -451,6 +451,14 @@ export class CommitteeService { page_size: 100, }); + if (voteResources.length >= 100) { + 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) => { @@ -551,12 +559,12 @@ export class CommitteeService { committee_uid: committeeId, }); - // Lazy import to avoid circular dependency — instance is cached - if (!this.cachedMeetingService) { - const { MeetingService } = await import('./meeting.service'); - this.cachedMeetingService = new MeetingService(); + // 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 result = await this.cachedMeetingService.getMeetings(req, params); + const meetingService = await this.meetingServicePromise; + const result = await meetingService.getMeetings(req, params); logger.debug(req, 'get_committee_meetings', 'Fetched committee meetings', { committee_uid: committeeId, diff --git a/packages/shared/src/interfaces/member.interface.ts b/packages/shared/src/interfaces/member.interface.ts index d1cb56d6e..d3ebdabc4 100644 --- a/packages/shared/src/interfaces/member.interface.ts +++ b/packages/shared/src/interfaces/member.interface.ts @@ -132,9 +132,9 @@ export interface MemberFormValue { is_individual: boolean; organization: string; organization_url: string; - role: CommitteeMemberRole; - voting_status: CommitteeMemberVotingStatus; - appointed_by: CommitteeMemberAppointedBy; + role: CommitteeMemberRole | ''; + voting_status: CommitteeMemberVotingStatus | ''; + appointed_by: CommitteeMemberAppointedBy | ''; role_start: Date | null; role_end: Date | null; voting_status_start: Date | null; From 83a9d08167a3a1eea5d0a428af0e836a7afb971f Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 08:02:13 -0700 Subject: [PATCH 42/48] feat(committees): add surveys tab with BFF endpoint and create survey flow Add committee surveys tab with: - BFF endpoint for fetching committee surveys - Committee surveys list component with drawer integration - Create Survey flow with committee group preselection - Survey service integration for committee-scoped queries LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 22 +++- .../committee-view.component.ts | 14 ++- .../committee-surveys-list.component.html | 50 ++++++++ .../committee-surveys-list.component.ts | 108 ++++++++++++++++++ .../survey-manage/survey-manage.component.ts | 13 +++ .../src/app/shared/services/survey.service.ts | 4 + .../controllers/committee.controller.ts | 36 ++++++ .../src/server/routes/committees.route.ts | 2 + .../src/server/services/survey.service.ts | 26 +++++ 9 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.html create mode 100644 apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.ts 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 a2a58bc18..09c1cdf1d 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 @@ -163,13 +163,23 @@

{{ committee()?.name }}

} @case ('surveys') { -
-
- -

Surveys

-

Coming in a future PR

+ @if (committee(); as c) { +
+
+

Surveys

+ @if (canEdit()) { + + + } +
+
-
+ } } @case ('documents') {
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 714efb9e8..ad499c7a9 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 @@ -15,12 +15,13 @@ import { MenuItem, MessageService } from 'primeng/api'; import { catchError, combineLatest, finalize, of, switchMap } from 'rxjs'; import { CommitteeOverviewComponent } from '../components/committee-overview/committee-overview.component'; +import { CommitteeSurveysListComponent } from '../components/committee-surveys-list/committee-surveys-list.component'; type CommitteeTab = 'overview' | 'members' | 'votes' | 'meetings' | 'surveys' | 'documents'; @Component({ selector: 'lfx-committee-view', - imports: [BreadcrumbComponent, ButtonComponent, TagComponent, RouterLink, RouteLoadingComponent, DatePipe, NgClass, CommitteeOverviewComponent], + imports: [BreadcrumbComponent, ButtonComponent, TagComponent, RouterLink, RouteLoadingComponent, DatePipe, NgClass, CommitteeOverviewComponent, CommitteeSurveysListComponent], templateUrl: './committee-view.component.html', styleUrl: './committee-view.component.scss', }) @@ -66,6 +67,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/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..deeee91be --- /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..b768e5e88 --- /dev/null +++ b/apps/lfx-one/src/app/modules/committees/components/committee-surveys-list/committee-surveys-list.component.ts @@ -0,0 +1,108 @@ +// 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 { BehaviorSubject, 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 committeeName = input.required(); + public readonly hasPMOAccess = input(false); + + // === Subjects === + private readonly refresh$ = new BehaviorSubject(undefined); + + // === Writable Signals === + 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.refresh$.next(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected onDuplicateSurvey(surveyId: string): void { + this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey duplication is not yet available' }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected onCloseSurvey(surveyId: string): 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 this.refresh$.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/surveys/survey-manage/survey-manage.component.ts b/apps/lfx-one/src/app/modules/surveys/survey-manage/survey-manage.component.ts index c4bcdf9a5..858458dfe 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,15 @@ 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 }]); + } + } + private markAllFormControlsAsTouched(): void { markFormControlsAsTouched(this.form()); } 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 805753735..a283d3a6f 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -7,12 +7,14 @@ 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(); + private readonly surveyService = new SurveyService(); // ── Dashboard Sub-Resource Handlers (via factory) ───────────────────────── @@ -624,4 +626,38 @@ export class CommitteeController { } }; } + + /** + * GET /committees/:id/surveys + */ + 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) { + 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 4ebfb4c58..95cc05642 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -44,4 +44,6 @@ router.get('/:id/budget', (req, res, next) => committeeController.getCommitteeBu 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/survey.service.ts b/apps/lfx-one/src/server/services/survey.service.ts index 89195c2d9..27d4d6f05 100644 --- a/apps/lfx-one/src/server/services/survey.service.ts +++ b/apps/lfx-one/src/server/services/survey.service.ts @@ -89,6 +89,32 @@ 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, + }); + + const params = { + ...query, + committee_uid: committeeId, + type: 'survey', + }; + + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); + + const surveys: Survey[] = (resources ?? []).map((resource) => resource.data).filter((s) => s?.committees?.some((c) => c.committee_uid === committeeId)); + + 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 */ From 259bd24d7fc7b62297149ba0f0ee1a7f0c29bcca Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 18:25:37 -0700 Subject: [PATCH 43/48] fix(committees): address review feedback on surveys tab component - Make committeeName optional with empty string default - Remove unused surveyId parameter from onDuplicateSurvey and onCloseSurvey - Remove eslint-disable comments that are no longer needed Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-surveys-list.component.html | 4 ++-- .../committee-surveys-list.component.ts | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) 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 index deeee91be..3661dce41 100644 --- 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 @@ -44,7 +44,7 @@ [surveyId]="selectedSurveyId()" [listSurvey]="selectedListSurvey()" [hasPMOAccess]="hasPMOAccess()" - (duplicate)="onDuplicateSurvey($event)" - (closeSurvey)="onCloseSurvey($event)" + (duplicate)="onDuplicateSurvey()" + (closeSurvey)="onCloseSurvey()" data-testid="committee-survey-results-drawer"> 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 index b768e5e88..4be166dd5 100644 --- 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 @@ -28,7 +28,7 @@ export class CommitteeSurveysListComponent { // === Inputs === public readonly committeeUid = input.required(); - public readonly committeeName = input.required(); + public readonly committeeName = input(''); public readonly hasPMOAccess = input(false); // === Subjects === @@ -61,13 +61,11 @@ export class CommitteeSurveysListComponent { this.refresh$.next(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected onDuplicateSurvey(surveyId: string): void { + protected onDuplicateSurvey(): void { this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey duplication is not yet available' }); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected onCloseSurvey(surveyId: string): void { + protected onCloseSurvey(): void { this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey close is not yet available' }); } From 696c5a060a5f917f373f400f2a40638cce998fc5 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 19:43:32 -0700 Subject: [PATCH 44/48] fix(committees): address review findings on surveys tab - Replace BehaviorSubject with signal for refresh trigger - Gate Create Survey button with canManageConfigurations() not !isBoardMember() - Remove unused committeeName input - Add allowed_voting_statuses to survey committee preselection - Add security comment on req.query passthrough - Add cross-domain dependency comment for surveyService - Document why getCommitteeSurveys is manual (needs req.query) - Fix section headers to // -- Name -- convention Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-surveys-list.component.ts | 25 ++++++++----------- .../survey-manage/survey-manage.component.ts | 4 ++- .../controllers/committee.controller.ts | 2 ++ .../src/server/services/survey.service.ts | 1 + 4 files changed, 17 insertions(+), 15 deletions(-) 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 index 4be166dd5..347c2ea52 100644 --- 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 @@ -8,7 +8,7 @@ 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 { BehaviorSubject, catchError, finalize, of, switchMap } from 'rxjs'; +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'; @@ -19,32 +19,29 @@ import { SurveysTableComponent } from '@app/modules/surveys/components/surveys-t templateUrl: './committee-surveys-list.component.html', }) export class CommitteeSurveysListComponent { - // === Services === + // -- Services -- private readonly surveyService = inject(SurveyService); private readonly messageService = inject(MessageService); - // === Constants === + // -- Constants -- protected readonly surveyLabelPlural = SURVEY_LABEL.plural; - // === Inputs === + // -- Inputs -- public readonly committeeUid = input.required(); - public readonly committeeName = input(''); public readonly hasPMOAccess = input(false); - // === Subjects === - private readonly refresh$ = new BehaviorSubject(undefined); - - // === Writable Signals === + // -- 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 === + // -- Computed Signals -- protected readonly surveys: Signal = this.initSurveys(); - // === Protected Methods === + // -- Protected Methods -- protected onViewResults(surveyId: string): void { this.selectedSurveyId.set(surveyId); this.selectedListSurvey.set(this.surveys().find((s) => s.uid === surveyId) ?? null); @@ -58,7 +55,7 @@ export class CommitteeSurveysListComponent { protected refreshSurveys(): void { this.loading.set(true); this.loadError.set(false); - this.refresh$.next(); + this.refreshTrigger.update((v) => v + 1); } protected onDuplicateSurvey(): void { @@ -69,7 +66,7 @@ export class CommitteeSurveysListComponent { this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey close is not yet available' }); } - // === Private Initializers === + // -- Private Initializers -- private initSurveys(): Signal { const committeeUid$ = toObservable(this.committeeUid); @@ -86,7 +83,7 @@ export class CommitteeSurveysListComponent { this.resultsDrawerVisible.set(false); this.selectedSurveyId.set(null); this.selectedListSurvey.set(null); - return this.refresh$.pipe( + return toObservable(this.refreshTrigger).pipe( switchMap(() => { this.loading.set(true); return this.surveyService.getSurveysByCommittee(committeeUid).pipe( 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 858458dfe..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 @@ -393,7 +393,9 @@ Thank you, const uid = params['committee_uid']; const name = params['committee_name']; if (uid && name) { - this.form().get('committees')?.setValue([{ uid, name }]); + this.form() + .get('committees') + ?.setValue([{ uid, name, allowed_voting_statuses: [] }]); } } diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index a283d3a6f..4699003c4 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -14,6 +14,7 @@ import { SurveyService } from '../services/survey.service'; */ 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) ───────────────────────── @@ -629,6 +630,7 @@ export class CommitteeController { /** * 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; diff --git a/apps/lfx-one/src/server/services/survey.service.ts b/apps/lfx-one/src/server/services/survey.service.ts index 27d4d6f05..7ca7b3135 100644 --- a/apps/lfx-one/src/server/services/survey.service.ts +++ b/apps/lfx-one/src/server/services/survey.service.ts @@ -97,6 +97,7 @@ export class SurveyService { committee_uid: committeeId, }); + // Note: query params are passed through — downstream service validates/sanitizes const params = { ...query, committee_uid: committeeId, From 2a66b3821a9434809866c538b52145e375d22f58 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 20:33:09 -0700 Subject: [PATCH 45/48] fix(committees): address review findings on surveys tab PR - Add logger.error in getCommitteeSurveys controller catch block - Use tags parameter for server-side survey filtering instead of unsupported committee_uid param - Move getCommitteeSurveys before private methods to fix member-ordering lint - Use canEdit() (backend writer flag) for survey access control LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.ts | 12 +++- .../controllers/committee.controller.ts | 71 ++++++++++--------- .../src/server/services/survey.service.ts | 6 +- 3 files changed, 50 insertions(+), 39 deletions(-) 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 ad499c7a9..d149eb45b 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 @@ -21,7 +21,17 @@ type CommitteeTab = 'overview' | 'members' | 'votes' | 'meetings' | 'surveys' | @Component({ selector: 'lfx-committee-view', - imports: [BreadcrumbComponent, ButtonComponent, TagComponent, RouterLink, RouteLoadingComponent, DatePipe, NgClass, CommitteeOverviewComponent, CommitteeSurveysListComponent], + imports: [ + BreadcrumbComponent, + ButtonComponent, + TagComponent, + RouterLink, + RouteLoadingComponent, + DatePipe, + NgClass, + CommitteeOverviewComponent, + CommitteeSurveysListComponent, + ], templateUrl: './committee-view.component.html', styleUrl: './committee-view.component.scss', }) diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index 4699003c4..0bbfeefe3 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -592,6 +592,42 @@ export class CommitteeController { } } + /** + * 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 ────────────────────────────────────────────────────── /** @@ -627,39 +663,4 @@ export class CommitteeController { } }; } - - /** - * 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) { - next(error); - } - } } diff --git a/apps/lfx-one/src/server/services/survey.service.ts b/apps/lfx-one/src/server/services/survey.service.ts index 7ca7b3135..9a666d651 100644 --- a/apps/lfx-one/src/server/services/survey.service.ts +++ b/apps/lfx-one/src/server/services/survey.service.ts @@ -97,16 +97,16 @@ export class SurveyService { committee_uid: committeeId, }); - // Note: query params are passed through — downstream service validates/sanitizes + // Use tags parameter for server-side filtering — committee_uid is not a supported query param const params = { ...query, - committee_uid: committeeId, 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).filter((s) => s?.committees?.some((c) => c.committee_uid === committeeId)); + const surveys: Survey[] = (resources ?? []).map((resource) => resource.data); logger.debug(req, 'get_committee_surveys', 'Completed committee survey fetch', { committee_uid: committeeId, From 212127abc21138e639e2a8ae11ae1079761e299e Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Wed, 18 Mar 2026 20:49:55 -0700 Subject: [PATCH 46/48] fix(committees): address round 6 review findings on detail overview - Restore console.error in committee-manage error handler for debugging - Bump vote fallback page_size from 100 to 500 to reduce truncation risk - Add LFXV2-1218 ticket reference to pagination TODO LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-manage/committee-manage.component.ts | 8 +++++--- apps/lfx-one/src/server/services/committee.service.ts | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) 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 066cbbdbe..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: () => this.handleCommitteeError('update'), + error: (err: unknown) => this.handleCommitteeError('update', err), }); } else { // Create new committee this.committeeService.createCommittee(committeeData).subscribe({ next: (committee) => this.handleCreateSuccess(committee), - error: () => this.handleCommitteeError('create'), + error: (err: unknown) => this.handleCommitteeError('create', err), }); } } @@ -453,9 +453,11 @@ export class CommitteeManageComponent { } } - private handleCommitteeError(operation: 'create' | 'update'): void { + 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', diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index 00e1ba6ab..253a316b6 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -447,11 +447,11 @@ export class CommitteeService { >(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { type: 'vote', parent: `project:${projectUid}`, - // TODO: Implement pagination for complete vote results - page_size: 100, + // TODO(LFXV2-1218): Implement cursor-based pagination for complete vote results + page_size: 500, }); - if (voteResources.length >= 100) { + 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, From 46bdf9089cc916cb2cd4d5e04a6debfd55e7a48d Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Thu, 19 Mar 2026 08:14:58 -0700 Subject: [PATCH 47/48] fix(committees): remove endpoints for non-existent upstream APIs Remove 9 BFF endpoints that call upstream APIs which don't exist yet: - engagement, budget (not in committee-service OpenAPI spec) - resolutions, activity, contributors, deliverables, discussions, events, campaigns (query resource types not indexed) - PATCH channels update (not supported by upstream PUT-only contract) Keep votes endpoint (has working fallback via vote resource type) and surveys tab placeholder for future PR. LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committee-view.component.html | 22 +--- .../app/shared/services/committee.service.ts | 45 ------- .../controllers/committee.controller.ts | 55 --------- .../src/server/routes/committees.route.ts | 10 -- .../src/server/services/committee.service.ts | 112 +----------------- 5 files changed, 9 insertions(+), 235 deletions(-) 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 09c1cdf1d..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 @@ -163,23 +163,13 @@

{{ committee()?.name }}

} @case ('surveys') { - @if (committee(); as c) { -
-
-

Surveys

- @if (canEdit()) { - - - } -
- +
+
+ +

Surveys

+

Coming in a future PR

- } +
} @case ('documents') {
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 bc3bd6bb8..a1920af5d 100644 --- a/apps/lfx-one/src/app/shared/services/committee.service.ts +++ b/apps/lfx-one/src/app/shared/services/committee.service.ts @@ -5,16 +5,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable, signal, WritableSignal } from '@angular/core'; import { Committee, - CommitteeActivity, - CommitteeBudgetSummary, - CommitteeContributor, - CommitteeDeliverable, - CommitteeDiscussionThread, - CommitteeEngagementMetrics, - CommitteeEvent, CommitteeMember, - CommitteeOutreachCampaign, - CommitteeResolution, CommitteeVote, CreateCommitteeMemberRequest, Meeting, @@ -106,42 +97,6 @@ export class CommitteeService { return this.http.get(`/api/committees/${committeeId}/votes`); } - public getCommitteeResolutions(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/resolutions`); - } - - public getCommitteeActivity(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/activity`); - } - - public getCommitteeContributors(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/contributors`); - } - - public getCommitteeDeliverables(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/deliverables`); - } - - public getCommitteeDiscussions(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/discussions`); - } - - public getCommitteeEvents(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/events`); - } - - public getCommitteeCampaigns(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/campaigns`); - } - - public getCommitteeEngagement(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/engagement`); - } - - public getCommitteeBudget(committeeId: string): Observable { - return this.http.get(`/api/committees/${committeeId}/budget`); - } - // ── Join / Leave Methods ────────────────────────────────────────────────── /** Self-join an open group */ diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index 0bbfeefe3..19d04c18c 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -22,61 +22,6 @@ export class CommitteeController { /** GET /committees/:id/votes */ public getCommitteeVotes = this.subResourceHandler('get_committee_votes', (req, id) => this.committeeService.getCommitteeVotes(req, id), 'vote_count'); - /** GET /committees/:id/resolutions */ - public getCommitteeResolutions = this.subResourceHandler( - 'get_committee_resolutions', - (req, id) => this.committeeService.getCommitteeResolutions(req, id), - 'resolution_count' - ); - - /** GET /committees/:id/activity */ - public getCommitteeActivity = this.subResourceHandler( - 'get_committee_activity', - (req, id) => this.committeeService.getCommitteeActivity(req, id), - 'activity_count' - ); - - /** GET /committees/:id/contributors */ - public getCommitteeContributors = this.subResourceHandler( - 'get_committee_contributors', - (req, id) => this.committeeService.getCommitteeContributors(req, id), - 'contributor_count' - ); - - /** GET /committees/:id/deliverables */ - public getCommitteeDeliverables = this.subResourceHandler( - 'get_committee_deliverables', - (req, id) => this.committeeService.getCommitteeDeliverables(req, id), - 'deliverable_count' - ); - - /** GET /committees/:id/discussions */ - public getCommitteeDiscussions = this.subResourceHandler( - 'get_committee_discussions', - (req, id) => this.committeeService.getCommitteeDiscussions(req, id), - 'discussion_count' - ); - - /** GET /committees/:id/events */ - public getCommitteeEvents = this.subResourceHandler('get_committee_events', (req, id) => this.committeeService.getCommitteeEvents(req, id), 'event_count'); - - /** GET /committees/:id/campaigns */ - public getCommitteeCampaigns = this.subResourceHandler( - 'get_committee_campaigns', - (req, id) => this.committeeService.getCommitteeCampaigns(req, id), - 'campaign_count' - ); - - /** GET /committees/:id/engagement */ - public getCommitteeEngagement = this.subResourceHandler( - 'get_committee_engagement', - (req, id) => this.committeeService.getCommitteeEngagement(req, id), - 'has_engagement' - ); - - /** GET /committees/:id/budget */ - public getCommitteeBudget = this.subResourceHandler('get_committee_budget', (req, id) => this.committeeService.getCommitteeBudget(req, id), 'has_budget'); - /** GET /committees/:id/meetings */ public getCommitteeMeetings = this.subResourceHandler( 'get_committee_meetings', diff --git a/apps/lfx-one/src/server/routes/committees.route.ts b/apps/lfx-one/src/server/routes/committees.route.ts index 95cc05642..681645f82 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -30,16 +30,6 @@ router.get('/:id/meetings', (req, res, next) => committeeController.getCommittee // Dashboard sub-resource routes router.get('/:id/votes', (req, res, next) => committeeController.getCommitteeVotes(req, res, next)); -router.get('/:id/resolutions', (req, res, next) => committeeController.getCommitteeResolutions(req, res, next)); -router.get('/:id/activity', (req, res, next) => committeeController.getCommitteeActivity(req, res, next)); -router.get('/:id/contributors', (req, res, next) => committeeController.getCommitteeContributors(req, res, next)); -router.get('/:id/deliverables', (req, res, next) => committeeController.getCommitteeDeliverables(req, res, next)); -router.get('/:id/discussions', (req, res, next) => committeeController.getCommitteeDiscussions(req, res, next)); -router.get('/:id/events', (req, res, next) => committeeController.getCommitteeEvents(req, res, next)); -router.get('/:id/campaigns', (req, res, next) => committeeController.getCommitteeCampaigns(req, res, next)); -router.get('/:id/engagement', (req, res, next) => committeeController.getCommitteeEngagement(req, res, next)); -router.get('/:id/budget', (req, res, next) => committeeController.getCommitteeBudget(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)); diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index 253a316b6..66ba071f7 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -3,17 +3,8 @@ import { Committee, - CommitteeActivity, - CommitteeBudgetSummary, - CommitteeContributor, CommitteeCreateData, - CommitteeDeliverable, - CommitteeDiscussionThread, - CommitteeEngagementMetrics, - CommitteeEvent, CommitteeMember, - CommitteeOutreachCampaign, - CommitteeResolution, CommitteeSettingsData, CommitteeUpdateData, CommitteeVote, @@ -156,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; @@ -184,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, { @@ -488,56 +455,6 @@ export class CommitteeService { } } - public async getCommitteeResolutions(req: Request, committeeId: string): Promise { - return this.getSubResource(req, committeeId, 'committee_resolution', 'get_committee_resolutions'); - } - - public async getCommitteeActivity(req: Request, committeeId: string): Promise { - return this.getSubResource(req, committeeId, 'committee_activity', 'get_committee_activity'); - } - - public async getCommitteeContributors(req: Request, committeeId: string): Promise { - return this.getSubResource(req, committeeId, 'committee_contributor', 'get_committee_contributors'); - } - - public async getCommitteeDeliverables(req: Request, committeeId: string): Promise { - return this.getSubResource(req, committeeId, 'committee_deliverable', 'get_committee_deliverables'); - } - - public async getCommitteeDiscussions(req: Request, committeeId: string): Promise { - return this.getSubResource(req, committeeId, 'committee_discussion', 'get_committee_discussions'); - } - - public async getCommitteeEvents(req: Request, committeeId: string): Promise { - return this.getSubResource(req, committeeId, 'committee_event', 'get_committee_events'); - } - - public async getCommitteeCampaigns(req: Request, committeeId: string): Promise { - return this.getSubResource(req, committeeId, 'committee_campaign', 'get_committee_campaigns'); - } - - public async getCommitteeEngagement(req: Request, committeeId: string): Promise { - try { - return await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/engagement`, 'GET'); - } catch { - logger.warning(req, 'get_committee_engagement', 'Failed to fetch committee engagement, returning null', { - committee_uid: committeeId, - }); - return null; - } - } - - public async getCommitteeBudget(req: Request, committeeId: string): Promise { - try { - return await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}/budget`, 'GET'); - } catch { - logger.warning(req, 'get_committee_budget', 'Failed to fetch committee budget, returning null', { - committee_uid: committeeId, - }); - return null; - } - } - /** * Fetches meetings associated with a committee. */ @@ -712,29 +629,6 @@ export class CommitteeService { }); } - /** - * Generic helper for fetching committee sub-resources from the query service. - * All tag-based sub-resource queries follow the same pattern: query by type + committee_uid tag, - * map results to data, and return empty array on failure. - */ - private async getSubResource(req: Request, committeeId: string, resourceType: string, operation: string): Promise { - try { - logger.debug(req, operation, `Fetching ${operation.replace(/_/g, ' ')}`, { committee_id: committeeId }); - - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', { - type: resourceType, - tags: `committee_uid:${committeeId}`, - }); - - return resources.map((r) => r.data); - } catch { - logger.warning(req, operation, `Failed to fetch ${operation.replace(/_/g, ' ')}, returning empty`, { - committee_uid: committeeId, - }); - return []; - } - } - /** * Maps upstream vote status strings to the normalized status used by the UI. */ From 3fe8e028f0f442642c427d82ca09b7b42e6ab22f Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Thu, 19 Mar 2026 08:31:04 -0700 Subject: [PATCH 48/48] fix(committees): remove unused CommitteeSurveysListComponent import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surveys tab now uses a placeholder — the component import is no longer needed and was causing an NG8113 build warning. LFXV2-1218 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Manish Dixit --- .../committees/committee-view/committee-view.component.ts | 2 -- 1 file changed, 2 deletions(-) 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 d149eb45b..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 @@ -15,7 +15,6 @@ import { MenuItem, MessageService } from 'primeng/api'; import { catchError, combineLatest, finalize, of, switchMap } from 'rxjs'; import { CommitteeOverviewComponent } from '../components/committee-overview/committee-overview.component'; -import { CommitteeSurveysListComponent } from '../components/committee-surveys-list/committee-surveys-list.component'; type CommitteeTab = 'overview' | 'members' | 'votes' | 'meetings' | 'surveys' | 'documents'; @@ -30,7 +29,6 @@ type CommitteeTab = 'overview' | 'members' | 'votes' | 'meetings' | 'surveys' | DatePipe, NgClass, CommitteeOverviewComponent, - CommitteeSurveysListComponent, ], templateUrl: './committee-view.component.html', styleUrl: './committee-view.component.scss',