10"
+ [paginator]="committees().length > 0"
[rows]="10"
- [rowsPerPageOptions]="[10, 25, 50]"
+ [rowsPerPageOptions]="rppOptions()"
+ [showFirstLastIcon]="false"
+ [showCurrentPageReport]="true"
+ currentPageReportTemplate="Showing {first} to {last} of {totalRecords}"
sortField="name"
[sortOrder]="1"
selectionMode="single"
@@ -64,9 +107,6 @@
Channels
Voting
Last Updated
- @if (!canManageCommittee()) {
- Status
- }
@if (canManageCommittee()) {
}
@@ -79,7 +119,7 @@
@if (isBoardMember()) {
{{ committee.name || '-' }}
} @else {
-
+
{{ committee.name || '-' }}
}
@@ -90,10 +130,10 @@
@let category = committee.category || 'Other';
-
+
-
+
@if (committee.description) {
{{ committee.description }}
} @else {
@@ -102,10 +142,7 @@
-
-
- {{ (committee.total_members ?? 0) | number }}
-
+ {{ (committee.total_members ?? 0) | number }}
@@ -159,48 +196,13 @@
@if (committee.enable_voting) {
-
+
} @else {
—
}
- {{ committee.updated_at | date: 'MMM d, y' }}
- @if (!canManageCommittee()) {
-
- @if (myCommitteeUids().has(committee.uid)) {
-
-
- @if (committee.join_mode === 'open' || committee.join_mode === 'invite_only') {
-
-
- }
-
- } @else if (committee.public && (committee.join_mode === 'open' || committee.join_mode === 'application')) {
-
-
- } @else if (committee.public && committee.join_mode === 'invite_only') {
- Invite only
- } @else {
- {{ committee.public ? 'Closed' : 'Private' }}
- }
-
- }
+ {{ committee.updated_at | date: 'MMM d, y' }}
@if (canManageCommittee()) {
@@ -233,13 +235,17 @@
}
-
-
-
-
- No {{ committeeLabel.plural.toLowerCase() }} found
-
-
-
+ }
+
+ } @else {
+
+
+
+
+ }
diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.ts b/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.ts
index 1ea1f8c1e..1a8db8329 100644
--- a/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.ts
+++ b/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.ts
@@ -2,22 +2,24 @@
// SPDX-License-Identifier: MIT
import { DatePipe, DecimalPipe } from '@angular/common';
-import { Component, computed, inject, input, output, Signal } from '@angular/core';
+import { Component, computed, inject, input, output, signal } from '@angular/core';
+import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { switchMap, startWith } from 'rxjs';
import { RouterLink } from '@angular/router';
import { ButtonComponent } from '@components/button/button.component';
import { CardComponent } from '@components/card/card.component';
+import { CardTabsBarComponent } from '@components/card-tabs-bar/card-tabs-bar.component';
+import { EmptyStateComponent } from '@components/empty-state/empty-state.component';
import { InputTextComponent } from '@components/input-text/input-text.component';
import { SelectComponent } from '@components/select/select.component';
import { TableComponent } from '@components/table/table.component';
import { TagComponent } from '@components/tag/tag.component';
import { Committee, COMMITTEE_LABEL } from '@lfx-one/shared';
-import { CommitteeCategorySeverityPipe } from '@pipes/committee-category-severity.pipe';
+import { FilterPillOption } from '@lfx-one/shared/interfaces';
import { PlatformIconPipe } from '@app/shared/pipes/platform-icon.pipe';
import { PlatformLabelPipe } from '@app/shared/pipes/platform-label.pipe';
-import { CommitteeService } from '@services/committee.service';
import { PersonaService } from '@services/persona.service';
-import { MessageService } from 'primeng/api';
import { TooltipModule } from 'primeng/tooltip';
@@ -29,93 +31,81 @@ import { TooltipModule } from 'primeng/tooltip';
ReactiveFormsModule,
RouterLink,
CardComponent,
+ CardTabsBarComponent,
ButtonComponent,
TableComponent,
TagComponent,
InputTextComponent,
SelectComponent,
TooltipModule,
- CommitteeCategorySeverityPipe,
PlatformIconPipe,
PlatformLabelPipe,
+ EmptyStateComponent,
],
templateUrl: './committee-table.component.html',
styleUrl: './committee-table.component.scss',
})
export class CommitteeTableComponent {
// Injected services
- private readonly committeeService = inject(CommitteeService);
- private readonly messageService = inject(MessageService);
private readonly personaService = inject(PersonaService);
// Inputs
public committees = input.required();
+ public hasItems = input(true);
public canManageCommittee = input(false);
public myCommitteeUids = input>(new Set());
public readonly committeeLabel = COMMITTEE_LABEL;
public searchForm = input.required();
public categoryOptions = input.required<{ label: string; value: string | null }[]>();
public votingStatusOptions = input.required<{ label: string; value: string | null }[]>();
-
- // State
- public isBoardMember: Signal = computed(() => this.personaService.currentPersona() === 'board-member');
+ public showFoundationFilter = input(false);
+ public showProjectFilter = input(false);
+ public foundationOptions = input<{ label: string; value: string | null }[]>([]);
+ public projectOptions = input<{ label: string; value: string | null }[]>([]);
// Outputs
public readonly refresh = output();
public readonly rowClick = output();
+ public readonly foundationFilterChange = output();
+ public readonly projectFilterChange = output();
- public joinGroup(committee: Committee): void {
- const joinMode = committee.join_mode || 'closed';
+ // Writable signals
+ protected readonly categoryTab = signal('all');
- switch (joinMode) {
- case 'open':
- this.committeeService.joinCommittee(committee.uid).subscribe({
- next: () => {
- this.messageService.add({
- severity: 'success',
- summary: 'Joined',
- detail: `You have joined "${committee.name}"`,
- });
- this.refresh.emit();
- },
- error: () => {
- this.messageService.add({
- severity: 'error',
- summary: 'Error',
- detail: `Failed to join "${committee.name}"`,
- });
- },
- });
- break;
+ // State
+ protected readonly isBoardMember = computed(() => this.personaService.currentPersona() === 'board-member');
- case 'application':
- this.messageService.add({
- severity: 'info',
- summary: 'Apply to Join',
- detail: `"${committee.name}" requires an application. This feature is coming soon.`,
- });
- break;
+ private readonly formValue = toSignal(toObservable(this.searchForm).pipe(switchMap((form) => form.valueChanges.pipe(startWith(form.value)))), {
+ initialValue: {} as Record,
+ });
- case 'invite_only':
- this.messageService.add({
- severity: 'info',
- summary: 'Invite Only',
- detail: `"${committee.name}" is invite-only. Ask an existing member to invite you.`,
- });
- break;
+ protected readonly isFiltered = computed(() => {
+ const v = this.formValue();
+ return !!v['search'] || !!v['category'] || !!v['votingStatus'] || !!v['foundationFilter'] || !!v['projectFilter'];
+ });
- case 'closed':
- default:
- this.messageService.add({
- severity: 'warn',
- summary: 'Closed',
- detail: `"${committee.name}" is not currently accepting new members.`,
- });
- break;
- }
- }
+ protected readonly rppOptions = computed(() => (this.committees().length > 10 ? [10, 25, 50] : undefined));
+
+ protected readonly categoryTabOptions = computed(() =>
+ this.categoryOptions().map((opt) => ({
+ id: opt.value ?? 'all',
+ label: opt.label ?? 'All',
+ }))
+ );
protected onRowSelect(event: { data: Committee }): void {
this.rowClick.emit(event.data);
}
+
+ protected onCategoryTabChange(tab: string): void {
+ this.categoryTab.set(tab);
+ this.searchForm().patchValue({ category: tab === 'all' ? null : tab });
+ }
+
+ protected resetFilters(): void {
+ this.categoryTab.set('all');
+ this.searchForm().patchValue({ search: '', category: null, votingStatus: null, foundationFilter: null, projectFilter: null });
+ this.foundationFilterChange.emit(null);
+ this.projectFilterChange.emit(null);
+ }
}
diff --git a/apps/lfx-one/src/app/modules/documents/documents-dashboard/documents-dashboard.component.html b/apps/lfx-one/src/app/modules/documents/documents-dashboard/documents-dashboard.component.html
index 967e31725..5beecf2f4 100644
--- a/apps/lfx-one/src/app/modules/documents/documents-dashboard/documents-dashboard.component.html
+++ b/apps/lfx-one/src/app/modules/documents/documents-dashboard/documents-dashboard.component.html
@@ -2,16 +2,28 @@
+
-
-
{{ pageTitle() }}
-
{{ pageDescription() }}
+
+
+
{{ documentLabel.plural }}
+
{{ documentLabel.plural }}, links, and attachments across groups, meetings, and foundations.
+
-
+
+
+
+
+
-
+ @if (loading() || documents().length > 0) {
+
@@ -26,23 +38,21 @@
-
-
-
- }
+
+
+
+
+
@@ -91,24 +101,137 @@
-
-
-
-
-
-
+ }
+
+
+
+ @if (!loading() && documents().length === 0) {
+
+
+
+ } @else if (!loading() && filteredDocuments().length === 0) {
+
+
+ } @else {
+
+
0"
+ [rows]="10"
+ [rowsPerPageOptions]="rppOptions()"
+ [loading]="loading()"
+ [showFirstLastIcon]="false"
+ [showCurrentPageReport]="true"
+ currentPageReportTemplate="Showing {first} to {last} of {totalRecords}"
+ data-testid="documents-table">
+
+
+ Name
+ Foundation
+ Group / Meeting
+ Source
+ Date
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+ {{ doc.foundationName || '—' }}
+
+
+
+
+ {{ doc.groupOrMeetingName || '—' }}
+
+
+
+
+
+
+
+
+
+
+ {{ doc.date | date: 'MMM d, y' }}
+
+
+
+
+
+ @if (doc.source === 'file' && doc.attachmentUid) {
+
+
+ } @else if (doc.url) {
+ @if (doc.source === 'recording') {
+
+
+ } @else {
+
+
+ }
+ }
+
+
+
+
+
+ }
+
+
diff --git a/apps/lfx-one/src/app/modules/documents/documents-dashboard/documents-dashboard.component.ts b/apps/lfx-one/src/app/modules/documents/documents-dashboard/documents-dashboard.component.ts
index acb18e046..19e2eedd8 100644
--- a/apps/lfx-one/src/app/modules/documents/documents-dashboard/documents-dashboard.component.ts
+++ b/apps/lfx-one/src/app/modules/documents/documents-dashboard/documents-dashboard.component.ts
@@ -1,39 +1,55 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
-import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, Signal } from '@angular/core';
+import { DatePipe } from '@angular/common';
+import { ChangeDetectionStrategy, Component, computed, inject, signal, Signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { ButtonComponent } from '@components/button/button.component';
import { CardComponent } from '@components/card/card.component';
+import { CardTabsBarComponent } from '@components/card-tabs-bar/card-tabs-bar.component';
import { InputTextComponent } from '@components/input-text/input-text.component';
import { SelectComponent } from '@components/select/select.component';
-import { DocumentsTableComponent } from '@components/documents-table/documents-table.component';
+import { TableComponent } from '@components/table/table.component';
+import { TagComponent } from '@components/tag/tag.component';
import { DOCUMENT_LABEL, MEETING_GROUP_SOURCES } from '@lfx-one/shared/constants';
-import { MyDocumentItem, MyDocumentSource } from '@lfx-one/shared/interfaces';
+import { FilterPillOption, MyDocumentItem, MyDocumentSource } from '@lfx-one/shared/interfaces';
import { DocumentService } from '@services/document.service';
-import { LensService } from '@services/lens.service';
import { ProjectContextService } from '@services/project-context.service';
-import { catchError, combineLatest, debounceTime, distinctUntilChanged, finalize, map, of, startWith, switchMap } from 'rxjs';
+import { catchError, debounceTime, distinctUntilChanged, finalize, map, of, startWith, switchMap } from 'rxjs';
+import { EmptyStateComponent } from '@components/empty-state/empty-state.component';
+import { MyDocumentSourceTagPipe } from '@app/shared/pipes/my-document-source-tag.pipe';
@Component({
selector: 'lfx-documents-dashboard',
- imports: [CardComponent, InputTextComponent, SelectComponent, DocumentsTableComponent, ReactiveFormsModule],
+ imports: [
+ CardComponent,
+ CardTabsBarComponent,
+ ButtonComponent,
+ InputTextComponent,
+ SelectComponent,
+ TableComponent,
+ TagComponent,
+ ReactiveFormsModule,
+ DatePipe,
+ MyDocumentSourceTagPipe,
+ EmptyStateComponent,
+ ],
templateUrl: './documents-dashboard.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DocumentsDashboardComponent {
// === Services ===
private readonly documentService = inject(DocumentService);
- private readonly lensService = inject(LensService);
private readonly projectContextService = inject(ProjectContextService);
// === Constants ===
protected readonly documentLabel = DOCUMENT_LABEL;
- protected readonly sourceOptions: { label: string; value: MyDocumentSource | null }[] = [
- { label: 'All Sources', value: null },
- { label: 'Link', value: 'link' },
- { label: 'Meeting', value: 'meeting' },
- { label: 'Mailing List', value: 'mailing_list' },
+ protected readonly sourceTabOptions: FilterPillOption[] = [
+ { id: 'all', label: 'All Sources' },
+ { id: 'link', label: 'Links' },
+ { id: 'meeting', label: 'Meetings' },
+ { id: 'mailing_list', label: 'Mailing Lists' },
];
// === Forms ===
@@ -43,42 +59,47 @@ export class DocumentsDashboardComponent {
group: new FormControl
(null),
meeting: new FormControl(null),
mailingList: new FormControl(null),
- source: new FormControl(null),
});
// === Writable Signals ===
protected readonly loading = signal(true);
+ protected readonly sourceTab = signal('all');
// === Computed Signals ===
- protected readonly isMeLens: Signal = computed(() => this.lensService.activeLens() === 'me');
- protected readonly pageTitle = computed(() => (this.isMeLens() ? `My ${this.documentLabel.plural}` : this.documentLabel.plural));
- protected readonly pageDescription = computed(() =>
- this.isMeLens()
- ? 'Documents, links, and attachments from your groups and meetings across all foundations.'
- : 'Documents, links, and attachments for this context.'
- );
protected readonly project = this.projectContextService.activeContext;
protected readonly searchQuery: Signal = this.initSearchQuery();
protected readonly foundationFilter: Signal = this.initFoundationFilter();
protected readonly groupFilter: Signal = this.initGroupFilter();
protected readonly meetingFilter: Signal = this.initMeetingFilter();
protected readonly mailingListFilter: Signal = this.initMailingListFilter();
- protected readonly sourceFilter: Signal = this.initSourceFilter();
protected readonly documents: Signal = this.initDocuments();
protected readonly filteredDocuments: Signal = this.initFilteredDocuments();
+ protected readonly rppOptions = computed(() => (this.filteredDocuments().length > 10 ? [10, 25, 50] : undefined));
protected readonly foundationOptions: Signal<{ label: string; value: string | null }[]> = this.initFoundationOptions();
protected readonly groupOptions: Signal<{ label: string; value: string | null }[]> = this.initGroupOptions();
protected readonly meetingOptions: Signal<{ label: string; value: string | null }[]> = this.initMeetingOptions();
protected readonly mailingListOptions: Signal<{ label: string; value: string | null }[]> = this.initMailingListOptions();
- // === Constructor ===
- public constructor() {
- // Reset Me-lens-only filters when switching away from Me lens
- effect(() => {
- if (!this.isMeLens()) {
- this.filterForm.controls.foundation.reset(null);
+ // === Protected Methods ===
+ protected onSourceTabChange(tab: string): void {
+ this.sourceTab.set(tab);
+ }
+
+ protected resetFilters(): void {
+ this.filterForm.reset({ search: '', foundation: null, group: null, meeting: null, mailingList: null });
+ this.sourceTab.set('all');
+ }
+
+ protected openDocument(doc: MyDocumentItem): void {
+ if (!doc.url) return;
+ try {
+ const url = new URL(doc.url);
+ if (['http:', 'https:'].includes(url.protocol)) {
+ window.open(doc.url, '_blank', 'noopener,noreferrer');
}
- });
+ } catch {
+ // Invalid URL — silently ignore
+ }
}
// === Private Initializers ===
@@ -110,28 +131,13 @@ export class DocumentsDashboardComponent {
return toSignal(this.filterForm.controls.mailingList.valueChanges.pipe(startWith(null)), { initialValue: null });
}
- private initSourceFilter(): Signal {
- return toSignal(this.filterForm.controls.source.valueChanges.pipe(startWith(null)), { initialValue: null });
- }
-
private initDocuments(): Signal {
- const lens$ = toObservable(this.lensService.activeLens);
-
return toSignal(
- combineLatest([toObservable(this.project), lens$]).pipe(
- switchMap(([project, lens]) => {
- // On non-Me lenses, require a project/foundation selection
- if (lens !== 'me' && !project?.uid) {
- this.loading.set(false);
- return of([] as MyDocumentItem[]);
- }
-
+ toObservable(this.project).pipe(
+ switchMap((project) => {
this.loading.set(true);
- // Me lens: fetch all documents (no project filter)
- // Foundation/Project lens: scope to selected project
- const projectUid = lens === 'me' ? undefined : project?.uid;
- return this.documentService.getMyDocuments(projectUid).pipe(
+ return this.documentService.getMyDocuments(project?.uid).pipe(
catchError(() => of([] as MyDocumentItem[])),
finalize(() => this.loading.set(false))
);
@@ -149,22 +155,24 @@ export class DocumentsDashboardComponent {
const group = this.groupFilter();
const meeting = this.meetingFilter();
const mailingList = this.mailingListFilter();
- const source = this.sourceFilter();
+ const sourceTab = this.sourceTab();
return docs.filter((doc) => {
if (
query &&
!doc.name.toLowerCase().includes(query) &&
- !(doc.foundationName ?? '').toLowerCase().includes(query) &&
- !(doc.groupOrMeetingName ?? '').toLowerCase().includes(query)
+ !doc.foundationName.toLowerCase().includes(query) &&
+ !doc.groupOrMeetingName.toLowerCase().includes(query)
) {
return false;
}
- if (this.isMeLens() && foundation && doc.foundationUid !== foundation) return false;
+ if (foundation && doc.foundationUid !== foundation) return false;
if (group && doc.groupOrMeetingUid !== group) return false;
if (meeting && doc.meetingId !== meeting && doc.pastMeetingId !== meeting) return false;
if (mailingList && doc.mailingListId !== mailingList) return false;
- if (source && doc.source !== source && !(source === 'meeting' && MEETING_GROUP_SOURCES.includes(doc.source))) return false;
+ if (sourceTab !== 'all') {
+ if (doc.source !== sourceTab && !(sourceTab === 'meeting' && MEETING_GROUP_SOURCES.includes(doc.source))) return false;
+ }
return true;
});
});
diff --git a/apps/lfx-one/src/app/modules/events/components/discover-events-button/discover-events-button.component.html b/apps/lfx-one/src/app/modules/events/components/discover-events-button/discover-events-button.component.html
index 24f9ad334..54af7ca4a 100644
--- a/apps/lfx-one/src/app/modules/events/components/discover-events-button/discover-events-button.component.html
+++ b/apps/lfx-one/src/app/modules/events/components/discover-events-button/discover-events-button.component.html
@@ -6,7 +6,7 @@
icon="fa-light fa-arrow-up-right-from-square"
[text]="true"
severity="primary"
- [attr.data-testid]="testId()"
+[attr.data-testid]="testId()"
target="_blank"
rel="noopener noreferrer"
[href]="discoverUrl" />
diff --git a/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.html b/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.html
index d55cfc6f5..fece41bc9 100644
--- a/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.html
+++ b/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.html
@@ -7,7 +7,7 @@
(undefined);
/** When true, foundation options are scoped to the user's registered past events */
public readonly isPast = input(false);
+ public readonly searchPlaceholder = input('Search events...');
+ public readonly searchQuery = input('');
public readonly searchQueryChange = output();
public readonly searchForm: FormGroup = new FormGroup({
@@ -81,6 +83,17 @@ export class EventsTopBarComponent {
this.searchForm.get('role')?.setValue(null);
}
});
+
+ // Sync the searchQuery input into the form control without triggering valueChanges debounce.
+ toObservable(this.searchQuery)
+ .pipe(takeUntilDestroyed())
+ .subscribe((query) => {
+ const normalizedQuery = query ?? '';
+ if (searchControl?.value !== normalizedQuery) {
+ searchControl?.setValue(normalizedQuery, { emitEvent: false });
+ this.searchValue.set(normalizedQuery);
+ }
+ });
}
public clearSearch(): void {
diff --git a/apps/lfx-one/src/app/modules/events/foundation-event-dashboard/components/events-table/events-table.component.html b/apps/lfx-one/src/app/modules/events/foundation-event-dashboard/components/events-table/events-table.component.html
index 3d844f3f6..8937711b4 100644
--- a/apps/lfx-one/src/app/modules/events/foundation-event-dashboard/components/events-table/events-table.component.html
+++ b/apps/lfx-one/src/app/modules/events/foundation-event-dashboard/components/events-table/events-table.component.html
@@ -60,7 +60,7 @@
[href]="event.url"
target="_blank"
rel="noopener noreferrer"
- class="text-sm font-medium text-blue-600 hover:text-blue-700 hover:underline"
+ class="lfx-table-name-link text-sm"
[attr.data-testid]="'event-name-' + event.id">
{{ event.name }}
@@ -71,24 +71,24 @@
@if (event.category) {
} @else {
- —
+ —
}
- {{ event.date }}
+ {{ event.date }}
- {{ event.location }}
+ {{ event.location }}
@if (isPastEvents()) {
-
+
{{ event.attendees !== null ? (event.attendees | number) : '—' }}
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.html
index 1dd934e3c..86ae361e9 100644
--- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.html
+++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.html
@@ -1,107 +1,87 @@
-
-
-
- 0"
- [alwaysShowPaginator]="true"
- [rows]="requestsResponse().pageSize"
- [first]="requestsResponse().offset"
- [totalRecords]="requestsResponse().total"
- [rowsPerPageOptions]="[10, 25, 50]"
- [showCurrentPageReport]="true"
- currentPageReportTemplate="Showing {first} to {last} of {totalRecords}"
- paginatorPosition="bottom"
- (onPage)="onPageChange($event)"
- [attr.data-testid]="config().testIdPrefix + '-data-table'">
-
-
-
-
- Event Name
-
-
-
-
- Location
-
-
-
-
- Application Date
-
-
- Status
-
-
+@if (!loading() && requestsResponse().data.length === 0) {
+
+} @else {
+ 0"
+ [alwaysShowPaginator]="true"
+ [rows]="requestsResponse().pageSize"
+ [first]="requestsResponse().offset"
+ [totalRecords]="requestsResponse().total"
+ [showFirstLastIcon]="false"
+ [rowsPerPageOptions]="rppOptions()"
+ [showCurrentPageReport]="true"
+ currentPageReportTemplate="Showing {first} to {last} of {totalRecords}"
+ paginatorPosition="bottom"
+ (onPage)="onPageChange($event)"
+ [attr.data-testid]="config().testIdPrefix + '-data-table'">
+
+
+
+
+ Event Name
+
+
+
+
+ Location
+
+
+
+
+ Application Date
+
+
+ Status
+
+
-
-
-
-
-
- {{ request.name }}
-
-
-
-
-
- {{ request.location }}
-
-
-
-
- {{ request.applicationDate }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ config().emptyMessage }}
-
+
+
+
+
+ {{ request.name }}
+
+
+
+ {{ request.location }}
+
+
+ {{ request.applicationDate }}
+
+
+
+
-
-
-
-
-
+
+
+
+
+}
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.ts
index d25007eb3..0ebd44bc5 100644
--- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.ts
+++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.ts
@@ -5,7 +5,6 @@ import { ChangeDetectionStrategy, Component, Type, computed, inject, input, Sign
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { EventsService } from '@app/shared/services/events.service';
import { UserService } from '@app/shared/services/user.service';
-import { ButtonComponent } from '@components/button/button.component';
import { EventRequestStatusSeverityPipe } from '@app/shared/pipes/event-request-status-severity.pipe';
import { TableComponent } from '@components/table/table.component';
import { TagComponent } from '@components/tag/tag.component';
@@ -14,11 +13,12 @@ import { PageChangeEvent, RequestType, VisaRequestsResponse } from '@lfx-one/sha
import { MessageService } from 'primeng/api';
import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog';
import { catchError, combineLatest, defer, finalize, map, of, skip, switchMap, tap } from 'rxjs';
+import { EmptyStateComponent } from '@components/empty-state/empty-state.component';
import { TravelFundApplicationDialogComponent } from '../travel-fund-application-dialog/travel-fund-application-dialog.component';
import { VisaRequestApplicationDialogComponent } from '../visa-request-application-dialog/visa-request-application-dialog.component';
@Component({
selector: 'lfx-event-request-list',
- imports: [TableComponent, TagComponent, ButtonComponent, DynamicDialogModule, EventRequestStatusSeverityPipe],
+ imports: [TableComponent, TagComponent, DynamicDialogModule, EventRequestStatusSeverityPipe, EmptyStateComponent],
providers: [DialogService],
templateUrl: './event-request-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -41,13 +41,22 @@ export class EventRequestListComponent {
protected readonly requestsResponse: Signal = this.initRequests();
protected readonly isCreateEnabled: Signal = this.initIsCreateEnabled();
+ protected readonly rppOptions = computed(() =>
+ this.requestsResponse().total > 10 ? [10, 25, 50] : undefined
+ );
+
+ /** True while loading or when at least one result exists — parent uses this to decide whether to show the filter bar. */
+ public readonly hasData = computed(() => this.loading() || this.requestsResponse().data.length > 0);
+
protected readonly config = computed(() => {
const isVisa = this.requestType() === 'visa';
return {
dialogComponent: (isVisa ? VisaRequestApplicationDialogComponent : TravelFundApplicationDialogComponent) as Type,
dialogHeader: isVisa ? 'Visa Letter Application' : 'Travel Funding Application',
buttonLabel: isVisa ? 'New Letter Application' : 'New Funding Application',
- emptyMessage: isVisa ? 'No visa requests found' : 'No travel fund requests found',
+ emptyIcon: isVisa ? 'fa-light fa-passport' : 'fa-light fa-plane',
+ emptyTitle: isVisa ? 'No visa letter requests yet' : 'No travel funding requests yet',
+ emptySubtitle: isVisa ? 'Submit a request to get a visa support letter for an LF event.' : 'Submit an application to get travel funding for an LF event.',
testIdPrefix: isVisa ? 'visa-request' : 'travel-funding',
};
});
@@ -75,6 +84,7 @@ export class EventRequestListComponent {
}
public openApplicationDialog(): void {
+ if (!this.isCreateEnabled()) return;
this.dialogService.open(this.config().dialogComponent, {
header: this.config().dialogHeader,
width: '800px',
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.html
index edcdc3fdf..1fc80b01b 100644
--- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.html
+++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.html
@@ -1,47 +1,69 @@
-
-
-
- @for (tab of tabs; track tab.id) {
-
- {{ tab.label }}
- @if (tab.countKey && !upcomingEventsLoading() && !pastEventsLoading()) {
- ({{ tabCounts()[tab.countKey] }})
- }
-
- }
-
-
+
@if (activeTab() === 'upcoming') {
-
+ @if (!upcomingEventsLoading() && upcomingEvents().data.length === 0) {
+ @if (isFiltered()) {
+
+ } @else {
+
+ }
+ } @else {
+
+ }
} @else if (activeTab() === 'past') {
-
+ @if (!pastEventsLoading() && pastEvents().data.length === 0) {
+ @if (isFiltered()) {
+
+ } @else {
+
+ }
+ } @else {
+
+ }
} @else if (activeTab() === 'visa-letters') {
} @else if (activeTab() === 'travel-funding') {
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.ts
index 63bc39bed..820a16c49 100644
--- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.ts
+++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.ts
@@ -1,38 +1,40 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
-import { NgClass } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, input, output, Signal, signal, WritableSignal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, input, output, Signal, signal, viewChild, WritableSignal } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { EventsService } from '@app/shared/services/events.service';
import { DEFAULT_EVENTS_PAGE_SIZE, EMPTY_MY_EVENTS_RESPONSE } from '@lfx-one/shared/constants';
-import { EventTab, EventTabId, MyEventsResponse, PageChangeEvent, SortChangeEvent } from '@lfx-one/shared/interfaces';
+import { EventTabId, MyEventsResponse, PageChangeEvent, SortChangeEvent } from '@lfx-one/shared/interfaces';
import { MessageService } from 'primeng/api';
import { catchError, combineLatest, debounceTime, finalize, of, skip, switchMap, tap } from 'rxjs';
+import { EmptyStateComponent } from '@components/empty-state/empty-state.component';
import { EventRequestListComponent } from '../event-request-list/event-request-list.component';
import { EventsTableComponent } from '../events-table/events-table.component';
@Component({
selector: 'lfx-events-list',
- imports: [NgClass, EventsTableComponent, EventRequestListComponent],
+ imports: [EventsTableComponent, EventRequestListComponent, EmptyStateComponent],
templateUrl: './events-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventsListComponent {
+ private readonly requestListRef = viewChild(EventRequestListComponent);
+
private readonly eventsService = inject(EventsService);
private readonly messageService = inject(MessageService);
+ public readonly activeTab = input
('upcoming');
public readonly foundation = input(null);
public readonly searchQuery = input('');
public readonly role = input(null);
public readonly status = input(null);
- public readonly activeTabChange = output();
-
- protected readonly activeTab = signal('upcoming');
-
protected readonly upcomingEventsLoading = signal(true);
protected readonly pastEventsLoading = signal(true);
+ private readonly statsUpcomingAllLoading = signal(true);
+ private readonly statsUpcomingRegisteredLoading = signal(true);
+ private readonly statsPastLoading = signal(true);
protected readonly upcomingEventsPage = signal({ offset: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE });
protected readonly pastEventsPage = signal({ offset: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE });
@@ -45,24 +47,39 @@ export class EventsListComponent {
protected readonly upcomingEvents: Signal = this.initializeUpcomingEvents();
protected readonly pastEvents: Signal = this.initializePastEvents();
- protected readonly tabs: EventTab[] = [
- { id: 'upcoming', label: 'Upcoming', countKey: 'upcoming' },
- { id: 'past', label: 'Past', countKey: 'past' },
- { id: 'visa-letters', label: 'Visa Letters' },
- { id: 'travel-funding', label: 'Travel Funding' },
- ];
-
+ // Unfiltered stats signals — fetched once with pageSize=1, used only for totals and next-event name
+ private readonly statsUpcomingAll: Signal = this.initializeStatsUpcomingAll();
+ private readonly statsUpcomingRegistered: Signal = this.initializeStatsUpcomingRegistered();
+ private readonly statsPast: Signal = this.initializeStatsPast();
+
+ // Me lens stat cards — derived from server-reported totals so counts stay accurate regardless of page size
+ public readonly eventsStatsLoading = computed(() => this.statsUpcomingAllLoading() || this.statsUpcomingRegisteredLoading() || this.statsPastLoading());
+ public readonly registeredCount = computed(() => this.statsUpcomingRegistered().total);
+ public readonly attendedCount = computed(() => this.statsPast().total);
+ public readonly nextEventName = computed(() => this.statsUpcomingRegistered().data[0]?.name ?? '');
+ public readonly availableToJoinCount = computed(() => Math.max(0, this.statsUpcomingAll().total - this.statsUpcomingRegistered().total));
public readonly tabCounts = computed(() => ({
- upcoming: this.upcomingEvents().total,
- past: this.pastEvents().total,
+ upcoming: this.statsUpcomingAll().total,
+ past: this.statsPast().total,
}));
- // Me lens stat cards (public so parent can render them above filters)
- public readonly eventsStatsLoading = computed(() => this.upcomingEventsLoading() || this.pastEventsLoading());
- public readonly registeredCount = computed(() => this.upcomingEvents().data.filter((e) => e.isRegistered).length);
- public readonly attendedCount = computed(() => this.pastEvents().total);
- public readonly nextEventName = computed(() => this.upcomingEvents().data[0]?.name ?? '');
- public readonly availableToJoinCount = computed(() => this.upcomingEvents().data.filter((e) => !e.isRegistered).length);
+ /**
+ * True when the filter/search bar should be visible:
+ * always show when filters are active; hide only on a true empty state (no data + no filters).
+ */
+ public readonly showFiltersBar = computed(() => {
+ const tab = this.activeTab();
+ const hasFilters = !!(this.foundation() || this.searchQuery() || this.role() || this.status());
+ if (hasFilters) return true;
+ if (tab === 'upcoming') return this.upcomingEventsLoading() || this.upcomingEvents().data.length > 0;
+ if (tab === 'past') return this.pastEventsLoading() || this.pastEvents().data.length > 0;
+ // For visa-letters / travel-funding tabs — delegate to the rendered request list
+ return this.requestListRef()?.hasData() ?? true;
+ });
+
+ public readonly resetFilters = output();
+
+ protected readonly isFiltered = computed(() => !!(this.foundation() || this.searchQuery() || this.role() || this.status()));
public constructor() {
// Reset both tabs to page 1 when shared filters change
@@ -74,9 +91,9 @@ export class EventsListComponent {
});
}
- protected setActiveTab(tab: EventTabId): void {
- this.activeTab.set(tab);
- this.activeTabChange.emit(tab);
+ /** Delegates to the currently rendered EventRequestListComponent (visa-letters / travel-funding tabs). */
+ public openCurrentRequestDialog(): void {
+ this.requestListRef()?.openApplicationDialog();
}
protected onUpcomingPageChange(event: PageChangeEvent): void {
@@ -117,6 +134,36 @@ export class EventsListComponent {
return this.initializeEvents(true, this.pastEventsPage, this.pastEventsLoading, this.pastSortField, this.pastSortOrder);
}
+ private initializeStatsUpcomingAll(): Signal {
+ return toSignal(
+ this.eventsService.getMyEvents({ isPast: false, offset: 0, pageSize: 1 }).pipe(
+ catchError(() => of(EMPTY_MY_EVENTS_RESPONSE)),
+ finalize(() => this.statsUpcomingAllLoading.set(false))
+ ),
+ { initialValue: EMPTY_MY_EVENTS_RESPONSE }
+ );
+ }
+
+ private initializeStatsUpcomingRegistered(): Signal {
+ return toSignal(
+ this.eventsService.getMyEvents({ isPast: false, offset: 0, pageSize: 1, registeredOnly: true, sortField: 'EVENT_START_DATE', sortOrder: 'ASC' }).pipe(
+ catchError(() => of(EMPTY_MY_EVENTS_RESPONSE)),
+ finalize(() => this.statsUpcomingRegisteredLoading.set(false))
+ ),
+ { initialValue: EMPTY_MY_EVENTS_RESPONSE }
+ );
+ }
+
+ private initializeStatsPast(): Signal {
+ return toSignal(
+ this.eventsService.getMyEvents({ isPast: true, offset: 0, pageSize: 1 }).pipe(
+ catchError(() => of(EMPTY_MY_EVENTS_RESPONSE)),
+ finalize(() => this.statsPastLoading.set(false))
+ ),
+ { initialValue: EMPTY_MY_EVENTS_RESPONSE }
+ );
+ }
+
private initializeEvents(
isPast: boolean,
pageSignal: WritableSignal,
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-table/events-table.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-table/events-table.component.html
index c094c14be..c9c26551d 100644
--- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-table/events-table.component.html
+++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-table/events-table.component.html
@@ -6,15 +6,17 @@
[loading]="loading()"
[lazy]="true"
[paginator]="eventsResponse().total > 0"
- [alwaysShowPaginator]="true"
[rows]="eventsResponse().pageSize"
[first]="eventsResponse().offset"
[totalRecords]="eventsResponse().total"
- [rowsPerPageOptions]="[10, 25, 50]"
+ [rowsPerPageOptions]="rppOptions()"
[showCurrentPageReport]="true"
+ [showFirstLastIcon]="false"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords}"
paginatorPosition="bottom"
+ selectionMode="single"
(onPage)="onPageChange($event)"
+ (onRowSelect)="onTableRowSelect($event)"
data-testid="events-data-table">
@@ -22,7 +24,7 @@
scope="col"
[attr.aria-sort]="sortField() === 'EVENT_NAME' ? (sortOrder() === 'ASC' ? 'ascending' : 'descending') : 'none'"
data-testid="sort-event-name">
-
+
Event Name
@@ -30,7 +32,7 @@
scope="col"
[attr.aria-sort]="sortField() === 'PROJECT_NAME' ? (sortOrder() === 'ASC' ? 'ascending' : 'descending') : 'none'"
data-testid="sort-foundation">
-
+
Foundation
@@ -38,7 +40,7 @@
scope="col"
[attr.aria-sort]="sortField() === 'EVENT_START_DATE' ? (sortOrder() === 'ASC' ? 'ascending' : 'descending') : 'none'"
data-testid="sort-date">
-
+
Date
@@ -46,17 +48,17 @@
scope="col"
[attr.aria-sort]="sortField() === 'EVENT_CITY' ? (sortOrder() === 'ASC' ? 'ascending' : 'descending') : 'none'"
data-testid="sort-location">
-
+
Location
Role
Status
@if (isPastEvents()) {
- Certificate
+
}
@if (!isPastEvents()) {
- Action
+
}
@@ -65,25 +67,14 @@
-
+
{{ event.name }}
-
+
-
-
-
+
@@ -99,9 +90,9 @@
@if (event.role) {
-
+
} @else {
- —
+ —
}
@@ -113,30 +104,35 @@
@if (isPastEvents()) {
-
+
+
+
}
@if (!isPastEvents()) {
-
+
+ @if (!event.isRegistered && event.registrationUrl) {
+
+ }
+
}
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-table/events-table.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-table/events-table.component.ts
index d627ed940..5c9cc0393 100644
--- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-table/events-table.component.ts
+++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-table/events-table.component.ts
@@ -8,12 +8,11 @@ import { TableComponent } from '@components/table/table.component';
import { TagComponent } from '@components/tag/tag.component';
import { MyEventsResponse, PageChangeEvent, SortChangeEvent, TagSeverity } from '@lfx-one/shared/interfaces';
import { MessageService } from 'primeng/api';
-import { TooltipModule } from 'primeng/tooltip';
import { take } from 'rxjs/operators';
@Component({
selector: 'lfx-events-table',
- imports: [TableComponent, TagComponent, ButtonComponent, TooltipModule],
+ imports: [TableComponent, TagComponent, ButtonComponent],
templateUrl: './events-table.component.html',
})
export class EventsTableComponent {
@@ -43,6 +42,8 @@ export class EventsTableComponent {
Cancelled: 'danger',
};
+ protected readonly rppOptions = computed(() => (this.eventsResponse().total > 10 ? [10, 25, 50] : undefined));
+
protected readonly sortIcons = computed(() => {
const field = this.sortField();
const order = this.sortOrder();
@@ -66,6 +67,22 @@ export class EventsTableComponent {
this.sortChange.emit({ field });
}
+ protected onTableRowSelect(event: { data: { url?: string } }): void {
+ if (event.data?.url) {
+ this.openUrl(event.data.url);
+ }
+ }
+
+ protected openUrl(url: string): void {
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
+ window.open(parsed.href, '_blank', 'noopener,noreferrer');
+ } catch {
+ // invalid URL — no-op
+ }
+ }
+
protected downloadCertificate(eventId: string): void {
this.eventsService
.getCertificate({ eventId })
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.html
index 0d1e420a8..69a4e4ef2 100644
--- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.html
+++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.html
@@ -33,16 +33,14 @@
- @if (eventsList.eventsStatsLoading()) {
+ @if (eventsStatsLoading()) {
—
} @else {
-
{{ eventsList.registeredCount() }}
+
{{ registeredCount() }}
}
Registered Events
- @if (!eventsList.eventsStatsLoading() && eventsList.nextEventName()) {
-
- Next: {{ eventsList.nextEventName() }}
-
+ @if (!eventsStatsLoading() && nextEventName()) {
+
Next: {{ nextEventName() }}
}
@@ -51,10 +49,10 @@