diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.html b/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.html index 68cb12bd2..a510d6b7f 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,208 @@

{{ committeeLabel.singular }} Members

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

No members found

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

No Board Members Found

+

Board members with voting rights will appear here once added.

+ } @else { + +

No Contributors Yet

+

Contributors will appear here as they join this working group.

+ } + @if (canManageMembers()) { +
+ +
+ } +
+ + + } +
+
+ }
diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.ts b/apps/lfx-one/src/app/modules/committees/components/committee-members/committee-members.component.ts index bee6d3e24..80aa772a0 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 @@ -2,8 +2,9 @@ // SPDX-License-Identifier: MIT import { TitleCasePipe } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, computed, inject, input, OnInit, output, signal, Signal } from '@angular/core'; import { FullNamePipe } from '@pipes/full-name.pipe'; -import { Component, computed, inject, input, OnInit, output, signal, Signal, WritableSignal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; @@ -13,11 +14,13 @@ 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 { Skeleton } from 'primeng/skeleton'; import { debounceTime, distinctUntilChanged, startWith, take } from 'rxjs'; import { MemberFormComponent } from '../member-form/member-form.component'; @@ -36,6 +39,7 @@ import { MemberFormComponent } from '../member-form/member-form.component'; TableComponent, ConfirmDialogModule, DynamicDialogModule, + Skeleton, ], providers: [DialogService], templateUrl: './committee-members.component.html', @@ -47,20 +51,32 @@ export class CommitteeMembersComponent implements OnInit { private readonly confirmationService = inject(ConfirmationService); private readonly dialogService = inject(DialogService); private readonly messageService = inject(MessageService); + private readonly personaService = inject(PersonaService); // Input signals public committee = input.required(); public members = input.required(); public membersLoading = input(true); + public groupBehavioralClass = input('other'); public readonly refresh = output(); - // Class variables with types - public selectedMember: WritableSignal; - public isDeleting: WritableSignal; + // Simple writable signals + public selectedMember = signal(null); + public isDeleting = signal(false); public memberActionMenuItems: MenuItem[] = []; public committeeLabel = COMMITTEE_LABEL; - public canManageMembers: Signal; + + // Computed signals — inline per component-organization.md + public readonly isBoardMember = computed(() => this.personaService.currentPersona() === 'board-member'); + public readonly isMaintainer = computed(() => this.personaService.currentPersona() === 'maintainer'); + public readonly canManageMembers = computed(() => !this.isBoardMember() && (!!this.committee()?.writer || this.isMaintainer())); + // Default to hidden while committee is loading (fail closed for privacy) + public readonly isMembersVisible = computed(() => { + const committee = this.committee(); + if (!committee) return false; + return committee.member_visibility !== 'hidden' || this.canManageMembers(); + }); // Filter-related variables public filterForm: FormGroup; @@ -74,11 +90,6 @@ export class CommitteeMembersComponent implements OnInit { public organizationOptions: Signal<{ label: string; value: string | null }[]>; public constructor() { - // Initialize all class variables - this.selectedMember = signal(null); - this.isDeleting = signal(false); - // Initialize permission signals - this.canManageMembers = computed(() => !!this.committee()?.writer); // Initialize filter form this.filterForm = this.initializeFilterForm(); this.searchTerm = this.initializeSearchTerm(); @@ -98,6 +109,8 @@ export class CommitteeMembersComponent implements OnInit { public toggleMemberActionMenu(event: Event, member: CommitteeMember, menuComponent: MenuComponent): void { event.stopPropagation(); this.selectedMember.set(member); + // Rebuild menu items so MenuItem.url reflects the selected member's email + this.memberActionMenuItems = this.initializeMemberActionMenuItems(member); menuComponent.toggle(event); } @@ -114,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(); } @@ -140,9 +153,9 @@ export class CommitteeMembersComponent implements OnInit { // Dialog will close itself }, }, - }) as DynamicDialogRef; + }); - dialogRef.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { + dialogRef?.onClose.pipe(take(1)).subscribe((result: boolean | undefined) => { if (result) { this.refreshMembers(); } @@ -193,9 +206,9 @@ export class CommitteeMembersComponent implements OnInit { // Refresh members list by re-fetching this.refreshMembers(); }, - error: (error) => { + error: (err: HttpErrorResponse) => { this.isDeleting.set(false); - console.error('Failed to delete member:', error); + console.error('Failed to delete member:', err); this.messageService.add({ severity: 'error', @@ -241,12 +254,12 @@ export class CommitteeMembersComponent implements OnInit { return toSignal(this.filterForm.get('organization')!.valueChanges.pipe(startWith(null), distinctUntilChanged()), { initialValue: null }); } - private initializeMemberActionMenuItems(): MenuItem[] { + private initializeMemberActionMenuItems(member?: CommitteeMember): MenuItem[] { return [ { label: 'Send Message', icon: 'fa-light fa-envelope', - command: () => window.open(`mailto:${this.selectedMember()?.email}`, '_blank'), + url: member?.email ? `mailto:${member.email}` : undefined, }, { 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..114380f4e 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 @@
- +
@@ -60,7 +88,7 @@
- +
+ @if (committee?.enable_voting) { -
- + + id="role" + data-testid="member-form-role"> + @if (form().get('role')?.errors?.['required'] && form().get('role')?.touched) { +

Role is required

+ }
-
- -
- - +
+
+ + @if (form().get('role_start')?.value || form().get('role_end')?.value) { + + }
+
+ -
- - + data-testid="member-form-role-start"> +
+ @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) { + + }
+
+ -
- - + data-testid="member-form-voting-start"> +
+ @if (form().errors?.['voting_status_start_after_voting_status_end']) { +

Voting end date must be after start date

+ }
- + data-testid="member-form-appointed-by">
} diff --git a/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.ts b/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.ts index f894d0799..3688156b2 100644 --- a/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/member-form/member-form.component.ts @@ -1,15 +1,16 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import { HttpErrorResponse } from '@angular/common/http'; import { Component, inject, signal } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; import { CalendarComponent } from '@components/calendar/calendar.component'; import { InputTextComponent } from '@components/input-text/input-text.component'; import { OrganizationSearchComponent } from '@components/organization-search/organization-search.component'; import { SelectComponent } from '@components/select/select.component'; -import { LINKEDIN_PROFILE_PATTERN, MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; -import { Committee, CommitteeMember, CreateCommitteeMemberRequest } from '@lfx-one/shared/interfaces'; +import { APPOINTED_BY_OPTIONS, LINKEDIN_PROFILE_PATTERN, MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; +import { Committee, CommitteeMember, CreateCommitteeMemberRequest, MemberFormValue } from '@lfx-one/shared/interfaces'; import { formatDateToISOString, parseISODateString } from '@lfx-one/shared/utils'; import { CommitteeService } from '@services/committee.service'; import { MessageService } from 'primeng/api'; @@ -30,8 +31,8 @@ export class MemberFormComponent { // Loading state for form submissions public submitting = signal(false); - // Create form group internally - public form = signal(this.createMemberFormGroup()); + // Form group created in constructor after committee is assigned from config + public form!: ReturnType>; public loading = signal(false); // Config-based properties (static, set once on dialog open) @@ -45,6 +46,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 @@ -54,10 +56,25 @@ export class MemberFormComponent { this.committee = this.config.data?.committee; this.wizardMode = this.config.data?.wizardMode || false; + // Create form group after committee is assigned so enable_voting validators work + this.form = signal(this.createMemberFormGroup()); + // Initialize form with data when component is created this.initializeForm(); } + public clearRoleDates(): void { + this.form().get('role_start')?.reset(); + this.form().get('role_end')?.reset(); + this.form().updateValueAndValidity(); + } + + public clearVotingDates(): void { + this.form().get('voting_status_start')?.reset(); + this.form().get('voting_status_end')?.reset(); + this.form().updateValueAndValidity(); + } + public onCancel(): void { this.config.data?.onCancel?.(); this.dialogRef.close(); @@ -66,7 +83,7 @@ export class MemberFormComponent { public onSubmit(): void { if (this.form().valid) { this.submitting.set(true); - const formValue = this.form().value; + const formValue = this.form().getRawValue() as MemberFormValue; // Prepare member data using form values, mapping to new structure const memberData: CreateCommitteeMemberRequest = { @@ -90,13 +107,7 @@ export class MemberFormComponent { end_date: formatDateToISOString(formValue.voting_status_end) || null, } : null, - organization: - formValue.organization || formValue.organization_url - ? { - name: formValue.organization || null, - website: formValue.organization_url || null, - } - : null, + organization: this.buildOrganizationPayload(formValue), }; // In wizard mode, return the data without calling API @@ -132,11 +143,9 @@ export class MemberFormComponent { }); this.dialogRef.close(true); }, - error: (error) => { + error: (err: HttpErrorResponse) => { this.submitting.set(false); - console.error('Failed to save member:', error); - - if (error.status === 409) { + if (err.status === 409) { this.messageService.add({ severity: 'error', summary: 'Error', @@ -152,10 +161,7 @@ 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(); } } @@ -181,22 +187,51 @@ export class MemberFormComponent { } } + private buildOrganizationPayload(formValue: MemberFormValue): CreateCommitteeMemberRequest['organization'] { + 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)]), + organization: new FormControl(''), + organization_url: new FormControl(''), + role: new FormControl('', this.committee?.enable_voting ? [Validators.required] : []), + voting_status: new FormControl('', this.committee?.enable_voting ? [Validators.required] : []), + appointed_by: new FormControl(''), + role_start: new FormControl(null), + role_end: new FormControl(null), + voting_status_start: new FormControl(null), + voting_status_end: new FormControl(null), + }, + { + validators: [ + MemberFormComponent.dateRangeValidator('role_start', 'role_end'), + MemberFormComponent.dateRangeValidator('voting_status_start', 'voting_status_end'), + ], + } + ); + } + + private static dateRangeValidator(startKey: string, endKey: string) { + return (group: AbstractControl): ValidationErrors | null => { + const start = group.get(startKey)?.value; + const end = group.get(endKey)?.value; + if (start && end && new Date(start) > new Date(end)) { + return { [`${startKey}_after_${endKey}`]: true }; + } + return null; + }; } } diff --git a/packages/shared/src/interfaces/member.interface.ts b/packages/shared/src/interfaces/member.interface.ts index 33d9c23f5..cea18bb23 100644 --- a/packages/shared/src/interfaces/member.interface.ts +++ b/packages/shared/src/interfaces/member.interface.ts @@ -119,6 +119,27 @@ 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; + 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