Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,31 @@
<!-- SPDX-License-Identifier: MIT -->

<div class="flex flex-col gap-5 px-1" data-testid="add-member-dialog">
<p class="text-sm text-gray-500">
Invite people by email — one or many at once. Each person gets an invitation to join the group and completes their own profile, so you don't need to enter
their details. They appear as "Pending" until they accept.
</p>
<div class="flex flex-col gap-2">
<label class="block text-sm font-medium text-gray-700">How should they join?</label>
<lfx-select-button
[form]="form"
control="actionMode"
[options]="actionModeOptions"
optionLabel="label"
optionValue="value"
size="small"
[unselectable]="false"
styleClass="w-full"
data-testid="add-member-action-mode"></lfx-select-button>
</div>

@if (actionMode() === 'invite') {
<p class="text-sm text-gray-500">
Invite people by email — one or many at once. Each person gets an invitation to join the group and completes their own profile, so you don't need to enter
their details. They appear as "Pending" until they accept.
</p>
} @else {
<p class="text-sm text-gray-500">
Add people directly to the group roster — one or many at once. No invitation is sent; they appear as active members immediately. Use search to pre-fill
profile details when available.
</p>
}

<!-- ── Find existing people (typeahead autofill, optional) ──────────────────── -->
<div class="flex flex-col gap-2">
Expand Down Expand Up @@ -85,7 +106,7 @@
}

@if (!searchLoading() && searchResults().length === 0 && queryValue().length >= 2) {
<p class="text-xs text-gray-400">No matches for "{{ queryValue() }}". You can still invite them by typing their email below.</p>
<p class="text-xs text-gray-400">No matches for "{{ queryValue() }}". You can still add them by typing their email below.</p>
}
</div>

Expand All @@ -102,9 +123,15 @@

<div class="flex flex-col gap-1 text-xs" data-testid="add-member-preview">
@if (categorized().toInvite.length > 0) {
<span class="text-emerald-600 font-medium">
<i class="fa-light fa-paper-plane" aria-hidden="true"></i> {{ categorized().toInvite.length }} to invite
</span>
@if (actionMode() === 'invite') {
<span class="text-emerald-600 font-medium">
<i class="fa-light fa-paper-plane" aria-hidden="true"></i> {{ categorized().toInvite.length }} to invite
</span>
} @else {
<span class="text-emerald-600 font-medium">
<i class="fa-light fa-user-plus" aria-hidden="true"></i> {{ categorized().toInvite.length }} to add
</span>
}
}
@if (categorized().alreadyMembers.length > 0) {
<span class="text-gray-400">{{ categorized().alreadyMembers.length }} already in the group — will be skipped</span>
Expand Down Expand Up @@ -151,8 +178,8 @@
data-testid="add-member-cancel"></lfx-button>

<lfx-button
label="Send Invites"
icon="fa-light fa-paper-plane"
[label]="submitLabel()"
[icon]="submitIcon()"
[loading]="submitting()"
[disabled]="!canSubmit() || submitting()"
(onClick)="onSubmit()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { ButtonComponent } from '@components/button/button.component';
import { InputTextComponent } from '@components/input-text/input-text.component';
import { SelectButtonComponent } from '@components/select-button/select-button.component';
import { SelectComponent } from '@components/select/select.component';
import { TextareaComponent } from '@components/textarea/textarea.component';
import { COMMITTEE_INVITE_CONCURRENCY, MEMBER_ROLES } from '@lfx-one/shared/constants';
import { ADD_MEMBER_ACTION_OPTIONS, COMMITTEE_INVITE_CONCURRENCY, MEMBER_ROLES } from '@lfx-one/shared/constants';
import { CommitteeMemberRole } from '@lfx-one/shared/enums';
import {
AddMemberActionMode,
CategorizedCommitteeEmails,
Committee,
CommitteeInvite,
CommitteeInviteResult,
CommitteeMember,
CreateCommitteeMemberRequest,
DecoratedCommitteeSearchResult,
EmailListParseResult,
UserSearchResult,
Expand All @@ -32,13 +36,11 @@ import { SkeletonModule } from 'primeng/skeleton';
import { catchError, debounceTime, distinctUntilChanged, from, map, mergeMap, Observable, of, startWith, switchMap, tap, toArray } from 'rxjs';

/**
* Invite people to a committee by email — single or bulk.
* Add people to a committee by email — single or bulk.
*
* The committee/registrant search corpus is not the LF identity directory, so this
* flow does not try to match every person to an existing account. Anyone is added by
* inviting their email (invite-and-forget); the invitee completes their own profile on
* accept, and an LFID is reconciled then. The typeahead is a convenience for finding
* people already known to v2 and appending their email — never a gate.
* Writers choose between adding directly to the roster (`createCommitteeMember`) or
* sending a pending invite (`createCommitteeInvite`). The typeahead appends known
* emails from search; it is never a gate.
*/
@Component({
selector: 'lfx-add-member-dialog',
Expand All @@ -49,6 +51,7 @@ import { catchError, debounceTime, distinctUntilChanged, from, map, mergeMap, Ob
UserAvatarColorPipe,
ButtonComponent,
InputTextComponent,
SelectButtonComponent,
SelectComponent,
TextareaComponent,
SkeletonModule,
Expand All @@ -72,7 +75,11 @@ export class AddMemberDialogComponent {
((this.config.data?.existingInvites as CommitteeInvite[]) ?? []).map((i) => (i.invitee_email ?? '').trim().toLowerCase()).filter(Boolean)
);

/** Search hits keyed by normalized email — used to enrich direct-add payloads. */
private readonly emailProfiles = signal<Map<string, UserSearchResult>>(new Map());

public readonly form = new FormGroup({
actionMode: new FormControl<AddMemberActionMode>('add_directly', { nonNullable: true }),
emails: new FormControl<string>('', { nonNullable: true }),
role: new FormControl<string | null>(null),
});
Comment on lines 81 to 85
Expand All @@ -81,7 +88,12 @@ export class AddMemberDialogComponent {
public submitting = signal(false);
public searchLoading = signal(false);

public readonly actionModeOptions = [...ADD_MEMBER_ACTION_OPTIONS];

private readonly rawEmails = toSignal(this.form.get('emails')!.valueChanges.pipe(startWith(this.form.get('emails')!.value)), { initialValue: '' });
public readonly actionMode = toSignal(this.form.get('actionMode')!.valueChanges.pipe(startWith(this.form.get('actionMode')!.value)), {
initialValue: 'add_directly' as AddMemberActionMode,
});

public readonly parsed: Signal<EmailListParseResult> = computed(() => parseEmailList(this.rawEmails()));
public readonly categorized: Signal<CategorizedCommitteeEmails> = computed(() => {
Expand All @@ -98,8 +110,15 @@ export class AddMemberDialogComponent {
return result;
});
public readonly canSubmit = computed(() => !this.submitting() && this.categorized().toInvite.length > 0);
/** Comma-joined invalid tokens for the preview — precomputed so the template reads a signal, not a function call. */
public readonly invalidSummary = computed(() => this.parsed().invalid.join(', '));
public readonly submitLabel = computed(() => {
const count = this.categorized().toInvite.length;
if (this.actionMode() === 'invite') {
return count === 1 ? 'Send Invite' : 'Send Invites';
}
return count === 1 ? 'Add Member' : 'Add Members';
});
public readonly submitIcon = computed(() => (this.actionMode() === 'invite' ? 'fa-light fa-paper-plane' : 'fa-light fa-user-plus'));

public readonly queryValue = toSignal(
this.searchForm.get('query')!.valueChanges.pipe(
Expand All @@ -121,6 +140,11 @@ export class AddMemberDialogComponent {
if (!email) {
return;
}
const normalized = email.toLowerCase();
const profiles = new Map(this.emailProfiles());
profiles.set(normalized, user);
this.emailProfiles.set(profiles);

const current = this.form.get('emails')!.value.trim();
this.form.get('emails')!.setValue(current ? `${current}\n${email}` : email);
this.searchForm.get('query')!.setValue('');
Expand All @@ -137,11 +161,18 @@ export class AddMemberDialogComponent {
return;
}

if (this.actionMode() === 'invite') {
this.submitInvites(committeeId, emails);
return;
}

this.submitDirectAdds(committeeId, emails);
}

private submitInvites(committeeId: string, emails: string[]): void {
this.submitting.set(true);
const role = this.form.get('role')!.value || null;

// No bulk endpoint upstream — fan out one create-invite per email with bounded
// concurrency, catching per-email so one failure never aborts the rest.
from(emails)
.pipe(
mergeMap(
Expand All @@ -157,14 +188,52 @@ export class AddMemberDialogComponent {
)
.subscribe((results) => {
this.submitting.set(false);
this.summarize(results);
this.summarizeInviteResults(results);
if (results.some((r) => r.success)) {
this.dialogRef.close(true);
}
});
}

private submitDirectAdds(committeeId: string, emails: string[]): void {
this.submitting.set(true);
const role = this.form.get('role')!.value || null;

from(emails)
.pipe(
mergeMap(
(email): Observable<CommitteeInviteResult> =>
this.committeeService.createCommitteeMember(committeeId, this.buildMemberRequest(email, role)).pipe(
map(() => ({ email, success: true })),
catchError((err: HttpErrorResponse) => of({ email, success: false, reason: this.addFailureReason(err) }))
),
COMMITTEE_INVITE_CONCURRENCY
),
toArray(),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((results) => {
this.submitting.set(false);
this.summarizeAddResults(results);
if (results.some((r) => r.success)) {
this.dialogRef.close(true);
}
});
}

private summarize(results: CommitteeInviteResult[]): void {
private buildMemberRequest(email: string, role: string | null): CreateCommitteeMemberRequest {
const profile = this.emailProfiles().get(email);
return {
email,
username: profile?.username ?? null,
first_name: profile?.first_name ?? null,
last_name: profile?.last_name ?? null,
job_title: profile?.job_title ?? null,
role: role ? { name: role as CommitteeMemberRole, start_date: null, end_date: null } : null,
};
}
Comment on lines +224 to +234

private summarizeInviteResults(results: CommitteeInviteResult[]): void {
const succeeded = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);

Expand Down Expand Up @@ -195,6 +264,37 @@ export class AddMemberDialogComponent {
});
}

private summarizeAddResults(results: CommitteeInviteResult[]): void {
const succeeded = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);

if (failed.length === 0) {
this.messageService.add({
severity: 'success',
summary: 'Members Added',
detail: succeeded.length === 1 ? `Added ${succeeded[0].email} to the group.` : `Added ${succeeded.length} people to the group.`,
});
return;
}

if (succeeded.length === 0) {
this.messageService.add({
severity: 'error',
summary: 'Unable to Add Members',
detail: failed.length === 1 ? `Could not add ${failed[0].email}: ${failed[0].reason}.` : `None of the ${failed.length} members could be added.`,
life: 6000,
});
return;
}

this.messageService.add({
severity: 'warn',
summary: 'Some Members Not Added',
detail: `Added ${succeeded.length} of ${results.length}. Could not add: ${failed.map((f) => f.email).join(', ')}.`,
life: 8000,
});
}

private inviteFailureReason(err: HttpErrorResponse): string {
if (err.status === 409) {
return 'already invited or a member';
Expand All @@ -203,6 +303,14 @@ export class AddMemberDialogComponent {
return upstream ?? 'invite failed';
}

private addFailureReason(err: HttpErrorResponse): string {
if (err.status === 409) {
return 'already a member';
}
const upstream = typeof err.error?.message === 'string' ? err.error.message : null;
return upstream ?? 'add failed';
}
Comment on lines +306 to +312

private initSearchResults(): Signal<DecoratedCommitteeSearchResult[]> {
const rawResults = toSignal(
this.searchForm.get('query')!.valueChanges.pipe(
Expand All @@ -217,7 +325,6 @@ export class AddMemberDialogComponent {
this.searchLoading.set(true);
const trimmed = q.trim();
return this.searchService.searchUsers(trimmed, 'committee_member').pipe(
// Re-rank so name matches surface first and incidental email/alias matches are demoted (LFXV2-2058).
map((users) => rankUserSearchResults(users, trimmed)),
tap(() => this.searchLoading.set(false)),
catchError(() => {
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/constants/committees.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,12 @@ export const COMMITTEE_PERMISSION_OPTIONS = [
{ label: 'Manage', value: 'manage' },
] as const;

/** Add-member dialog: invite-by-email vs direct roster add (writers only). */
export const ADD_MEMBER_ACTION_OPTIONS = [
{ label: 'Add directly', value: 'add_directly' },
{ label: 'Send invite', value: 'invite' },
] as const;

/**
* Member visibility options for committee settings
* @description Controls the visibility level of member profiles within a committee
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/interfaces/committee.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export type JoinMode = 'open' | 'invite_only' | 'application' | 'closed';
*/
export type CommitteeInviteStatus = 'pending' | 'accepted' | 'declined' | 'revoked';

/**
* How the add-member dialog adds people when a writer submits the form.
* - invite: create a pending committee_invite (invite-and-forget)
* - add_directly: create a committee_member immediately with no invite
*/
export type AddMemberActionMode = 'invite' | 'add_directly';

/**
* A pending/resolved invitation for a person (by email) to join a committee.
*
Expand Down
Loading