diff --git a/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html b/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html index 7516dbf5a..cbe9463d8 100644 --- a/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html @@ -4,7 +4,7 @@
-
+

{{ isMeLens() ? 'My ' + committeeLabel.plural : committeeLabel.plural }}

@if (!isMeLens() && canWrite()) { @@ -125,200 +125,44 @@

{{ isMeLens() ? 'My ' + committeeLa

} - + @if (isMeLens()) { -
-
-
- -
- @if (showFoundationFilter()) { -
- -
- } - @if (showProjectFilter()) { -
- -
- } -
-
- } - - - @if (myCommitteesLoading()) { -
-
-

My {{ committeeLabel.plural }}

-
-
- @for (_ of [1, 2, 3]; track _) { - -
-
-
- - -
- -
-
- - - - -
-
-
- } -
-
- } @else if (myCommittees().length > 0 && isMeLens()) { -
-
-

My {{ committeeLabel.plural }}

- {{ filteredMyCommittees().length }} -
- @if (filteredMyCommittees().length === 0) { - -
-
- -

No {{ committeeLabel.plural.toLowerCase() }} found matching your filters

-
-
-
- } @else { -
- @for (committee of filteredMyCommittees(); track committee.uid) { -
- -
- -
-
-

{{ committee.display_name || committee.name }}

-
- {{ committee.category }} - @if (!committee.public) { - - } -
-
- - - {{ committee.my_role }} - -
- - -
-
- - {{ committee.total_members || 0 }} members -
- @if (committee.enable_voting) { -
- - Voting -
- } - - @if (committee.mailing_list) { - - - - } @else if (committee.has_mailing_list) { - - - - } @else { - - - - } - @if (committee.chat_channel) { - - - - } @else { - - - - } -
-
-
+ @if (myCommitteesLoading()) { + +
+ @for (_ of [1, 2, 3, 4, 5]; track _) { +
+ + + + + + + +
}
- } -
- } @else if (isMeLens()) { - -
-
-
- -

You are not a member of any {{ committeeLabel.plural.toLowerCase() }} yet.

-
-
-
-
+ + } @else { + + + } } @if (!isMeLens()) { @@ -353,30 +197,19 @@

All {{ committeeLabel.plural }}< @if (!committeesLoading()) {
@if (committees().length === 0 && project()?.uid) { - - -
-
-
- -

Your project has no {{ committeeLabel.plural.toLowerCase() }}, yet.

-
-
-
-
+ + } @else if (filteredCommittees().length === 0) { - - -
-
-
- -
-

No {{ committeeLabel.plural.toLowerCase() }} Found

-

Try adjusting your search or filter criteria

-
-
-
+ + } @else { { return computed(() => { - const committeesData = this.committees(); + const committeesData = this.isMeLens() ? this.myCommittees() : this.committees(); - // Count committees by category + // Count committees by category, falling back to 'Other' when category is absent const categoryCounts = new Map(); committeesData.forEach((committee) => { - if (committee.category) { - categoryCounts.set(committee.category, (categoryCounts.get(committee.category) || 0) + 1); - } + const cat = committee.category || 'Other'; + categoryCounts.set(cat, (categoryCounts.get(cat) || 0) + 1); }); // Get unique categories and sort them @@ -265,13 +250,13 @@ export class CommitteeDashboardComponent { value: cat, })); - return [{ label: `All Types`, value: null }, ...categoryOptions]; + return [{ label: 'All', value: null }, ...categoryOptions]; }); } private initializeVotingStatusOptions(): Signal<{ label: string; value: string | null }[]> { return computed(() => { - const committeesData = this.committees(); + const committeesData = this.isMeLens() ? this.myCommittees() : this.committees(); // Count committees by voting status const votingEnabledCount = committeesData.filter((c) => c.enable_voting === true).length; @@ -335,7 +320,7 @@ export class CommitteeDashboardComponent { // Apply category filter const category = this.categoryFilter(); if (category) { - filtered = filtered.filter((committee) => committee.category === category); + filtered = filtered.filter((committee) => (committee.category || 'Other') === category); } // Apply voting status filter @@ -377,6 +362,20 @@ export class CommitteeDashboardComponent { ); } + // Apply category filter + const category = this.categoryFilter(); + if (category) { + filtered = filtered.filter((c) => (c.category || 'Other') === category); + } + + // Apply voting status filter + const votingStatus = this.votingStatusFilter(); + if (votingStatus === 'enabled') { + filtered = filtered.filter((c) => c.enable_voting === true); + } else if (votingStatus === 'disabled') { + filtered = filtered.filter((c) => c.enable_voting === false); + } + return filtered; }); } diff --git a/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.html b/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.html index 2d85890fc..9e2a08160 100644 --- a/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.html +++ b/apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.html @@ -1,9 +1,20 @@ - - -
+ + + @if (categoryTabOptions().length > 1) { + + + } + + + @if (hasItems()) { +
@@ -17,39 +28,71 @@ data-testid="committee-search-input">
- -
- -
+ + @if (showFoundationFilter()) { +
+ +
+ } + + + @if (showProjectFilter()) { +
+ +
+ } -
+
+ } + @if (hasItems()) { +
+ @if (committees().length === 0) { + + + } @else { 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 { + + + + + Name + Foundation + Group / Meeting + Source + Date + + + + + + + + +
+
+ +
+ {{ doc.name }} +
+ - - + + + {{ 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 @@ -
- -
- - - - - - - - - - - - - Status - - +@if (!loading() && requestsResponse().data.length === 0) { + +} @else { + + + + + + + + + + + + + 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) { - - } -
- +
@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"> - @@ -30,7 +32,7 @@ scope="col" [attr.aria-sort]="sortField() === 'PROJECT_NAME' ? (sortOrder() === 'ASC' ? 'ascending' : 'descending') : 'none'" data-testid="sort-foundation"> - @@ -38,7 +40,7 @@ scope="col" [attr.aria-sort]="sortField() === 'EVENT_START_DATE' ? (sortOrder() === 'ASC' ? 'ascending' : 'descending') : 'none'" data-testid="sort-date"> - @@ -46,17 +48,17 @@ scope="col" [attr.aria-sort]="sortField() === 'EVENT_CITY' ? (sortOrder() === 'ASC' ? 'ascending' : 'descending') : 'none'" data-testid="sort-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 @@

- @if (eventsList.eventsStatsLoading()) { + @if (eventsStatsLoading()) {

} @else { -

{{ eventsList.attendedCount() }}

+

{{ attendedCount() }}

}

Attended Events

@@ -64,10 +62,10 @@

- @if (eventsList.eventsStatsLoading()) { + @if (eventsStatsLoading()) {

} @else { -

{{ eventsList.tabCounts().upcoming }}

+

{{ upcomingCount() }}

}

Upcoming Events

@@ -76,25 +74,48 @@

- -
- -
- - + + + + + @if (isRequestTab()) { + + } + + + + @if (showFiltersBar()) { +
+ +
+ } + + +
+ +
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.ts index de23c6d5b..c377615a5 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.ts @@ -1,10 +1,12 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, signal } from '@angular/core'; +import { Component, computed, signal, viewChild } from '@angular/core'; +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 { MY_EVENT_STATUS_OPTIONS, VISA_REQUEST_STATUS_OPTIONS } from '@lfx-one/shared/constants'; -import { EventTabId, FilterOption } from '@lfx-one/shared/interfaces'; +import { EventTabId, FilterOption, FilterPillOption } from '@lfx-one/shared/interfaces'; import { Tooltip } from 'primeng/tooltip'; import { DiscoverEventsButtonComponent } from '../components/discover-events-button/discover-events-button.component'; import { EventsTopBarComponent } from '../components/events-top-bar/events-top-bar.component'; @@ -12,11 +14,20 @@ import { EventsListComponent } from './components/events-list/events-list.compon @Component({ selector: 'lfx-my-events-dashboard', - imports: [CardComponent, DiscoverEventsButtonComponent, EventsTopBarComponent, EventsListComponent, Tooltip], + imports: [ButtonComponent, CardComponent, CardTabsBarComponent, DiscoverEventsButtonComponent, EventsTopBarComponent, EventsListComponent, Tooltip], templateUrl: './my-events-dashboard.component.html', }) export class MyEventsDashboardComponent { + private readonly eventsListRef = viewChild(EventsListComponent); + protected readonly activeTab = signal('upcoming'); + + protected readonly tabOptions: FilterPillOption[] = [ + { id: 'upcoming', label: 'Upcoming' }, + { id: 'past', label: 'Past' }, + { id: 'visa-letters', label: 'Visa Letters' }, + { id: 'travel-funding', label: 'Travel Funding' }, + ]; protected readonly selectedFoundation = signal(null); protected readonly selectedRole = signal(null); protected readonly selectedStatus = signal(null); @@ -29,6 +40,22 @@ export class MyEventsDashboardComponent { protected readonly currentStatusOptions = computed(() => (this.isRequestTab() ? VISA_REQUEST_STATUS_OPTIONS : MY_EVENT_STATUS_OPTIONS)); + protected readonly searchPlaceholder = computed(() => { + if (this.activeTab() === 'visa-letters') return 'Search visa letters...'; + if (this.activeTab() === 'travel-funding') return 'Search travel funding...'; + return 'Search events...'; + }); + + protected readonly requestButtonLabel = computed(() => (this.activeTab() === 'visa-letters' ? 'New Letter Application' : 'New Funding Application')); + + /** Delegates to EventsListComponent — lifted here to avoid template forward-reference issues. */ + protected readonly showFiltersBar = computed(() => this.eventsListRef()?.showFiltersBar() ?? true); + protected readonly eventsStatsLoading = computed(() => this.eventsListRef()?.eventsStatsLoading() ?? true); + protected readonly registeredCount = computed(() => this.eventsListRef()?.registeredCount() ?? 0); + protected readonly attendedCount = computed(() => this.eventsListRef()?.attendedCount() ?? 0); + protected readonly nextEventName = computed(() => this.eventsListRef()?.nextEventName() ?? ''); + protected readonly upcomingCount = computed(() => this.eventsListRef()?.tabCounts().upcoming ?? 0); + protected onFoundationChange(value: string | null): void { this.selectedFoundation.set(value); } @@ -45,12 +72,23 @@ export class MyEventsDashboardComponent { this.selectedSearchQuery.set(value); } - protected onActiveTabChange(tab: EventTabId): void { - this.activeTab.set(tab); + protected onActiveTabChange(tab: string): void { + this.activeTab.set(tab as EventTabId); // Reset all filters when switching tabs — each tab has different filter sets this.selectedFoundation.set(null); this.selectedRole.set(null); this.selectedStatus.set(null); this.selectedSearchQuery.set(''); } + + protected openCurrentRequestDialog(): void { + this.eventsListRef()?.openCurrentRequestDialog(); + } + + protected resetFilters(): void { + this.selectedFoundation.set(null); + this.selectedRole.set(null); + this.selectedStatus.set(null); + this.selectedSearchQuery.set(''); + } } diff --git a/apps/lfx-one/src/app/modules/mailing-lists/components/mailing-list-table/mailing-list-table.component.html b/apps/lfx-one/src/app/modules/mailing-lists/components/mailing-list-table/mailing-list-table.component.html index 498a4fc51..3be2134db 100644 --- a/apps/lfx-one/src/app/modules/mailing-lists/components/mailing-list-table/mailing-list-table.component.html +++ b/apps/lfx-one/src/app/modules/mailing-lists/components/mailing-list-table/mailing-list-table.component.html @@ -1,7 +1,7 @@ - +
@@ -77,9 +77,12 @@ {{ mailingList.title }} @@ -146,7 +149,7 @@ -
+
{{ (mailingList.description | stripHtml) || '-' }}
@@ -166,7 +169,7 @@
@if (mailingList.committees?.length) { @for (committee of mailingList.committees | sliceLinkedGroups: maxVisibleGroups; track committee.uid) { - + } @if (mailingList.committees.length > maxVisibleGroups) { - {{ mailingList.subscriber_count }} + {{ mailingList.subscriber_count }} } @else { @@ -194,17 +197,17 @@ {{ mailingList.title }} } @else { - + {{ mailingList.title }} } - {{ mailingList | groupEmail }} + {{ mailingList | groupEmail }}
-
+
{{ (mailingList.description | stripHtml) || '-' }}
@@ -214,7 +217,7 @@
@if (mailingList.committees?.length) { @for (committee of mailingList.committees | sliceLinkedGroups: maxVisibleGroups; track committee.uid) { - + } @if (mailingList.committees.length > maxVisibleGroups) { - {{ mailingList.subscriber_count }} + {{ mailingList.subscriber_count }} - - + - @if (!isBoardMember()) { @@ -260,9 +263,25 @@ - - -

No mailing lists found

+ + @if (isFiltered()) { + + + } @else { + + + }
diff --git a/apps/lfx-one/src/app/modules/mailing-lists/components/mailing-list-table/mailing-list-table.component.ts b/apps/lfx-one/src/app/modules/mailing-lists/components/mailing-list-table/mailing-list-table.component.ts index 0af155540..0b0c53a7a 100644 --- a/apps/lfx-one/src/app/modules/mailing-lists/components/mailing-list-table/mailing-list-table.component.ts +++ b/apps/lfx-one/src/app/modules/mailing-lists/components/mailing-list-table/mailing-list-table.component.ts @@ -2,10 +2,13 @@ // SPDX-License-Identifier: MIT import { Component, computed, inject, input, output, Signal } from '@angular/core'; +import { toSignal, toObservable } from '@angular/core/rxjs-interop'; import { ReactiveFormsModule, FormGroup } 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 { 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'; @@ -39,6 +42,7 @@ import { TooltipModule } from 'primeng/tooltip'; RemainingGroupsTooltipPipe, SliceLinkedGroupsPipe, StripHtmlPipe, + EmptyStateComponent, ], templateUrl: './mailing-list-table.component.html', styleUrl: './mailing-list-table.component.scss', @@ -64,17 +68,35 @@ export class MailingListTableComponent { protected readonly maxVisibleGroups = MAILING_LIST_MAX_VISIBLE_GROUPS; protected readonly committeeLabel = COMMITTEE_LABEL; - // State - public isBoardMember: Signal = computed(() => this.personaService.currentPersona() === 'board-member'); - // Outputs public readonly refresh = output(); public readonly rowClick = output(); public readonly foundationFilterChange = output(); public readonly projectFilterChange = output(); + // State + public isBoardMember: Signal = computed(() => this.personaService.currentPersona() === 'board-member'); + + private readonly formValue = toSignal( + toObservable(this.searchForm).pipe(switchMap((form) => form.valueChanges.pipe(startWith(form.value)))), + { initialValue: {} as Record } + ); + + protected readonly isFiltered = computed(() => { + const v = this.formValue(); + return !!v['search'] || !!v['committee'] || !!v['status'] || !!v['foundationFilter'] || !!v['projectFilter']; + }); + + protected readonly rppOptions = computed(() => (this.mailingLists().length > 10 ? [10, 25, 50] : undefined)); + // Event Handlers protected onRowSelect(event: { data: GroupsIOMailingList }): void { this.rowClick.emit(event.data); } + + protected resetFilters(): void { + this.searchForm().patchValue({ search: '', committee: null, status: null, foundationFilter: null, projectFilter: null }); + this.foundationFilterChange.emit(null); + this.projectFilterChange.emit(null); + } } diff --git a/apps/lfx-one/src/app/modules/mailing-lists/mailing-list-dashboard/mailing-list-dashboard.component.html b/apps/lfx-one/src/app/modules/mailing-lists/mailing-list-dashboard/mailing-list-dashboard.component.html index 5524aaa8f..7852896f2 100644 --- a/apps/lfx-one/src/app/modules/mailing-lists/mailing-list-dashboard/mailing-list-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/mailing-lists/mailing-list-dashboard/mailing-list-dashboard.component.html @@ -65,17 +65,19 @@

{{ isMeLens() ? 'My ' + mailingList
@if (!isMeLens() && mailingLists().length === 0 && project()?.uid) { - - -
-
-
- -

Your project has no {{ mailingListLabelPlural.toLowerCase() }}, yet.

-
-
-
-
+ + + } @else if (isMeLens() && !myMailingListsLoading() && filteredMailingLists().length === 0 && !isFiltered()) { + + } @else { = this.initCommitteeOptions(); public readonly statusOptions: Signal = this.initStatusOptions(); public readonly filteredMailingLists: Signal = this.initFilteredMailingLists(); + protected readonly isFiltered = computed(() => + !!this.searchTerm() || !!this.committeeFilter() || !!this.statusFilter() || !!this.foundationFilter() || !!this.projectFilter() + ); public readonly totalMailingLists: Signal = this.initTotalMailingLists(); public readonly publicMailingLists: Signal = this.initPublicMailingLists(); public readonly availableServices: Signal = this.initServices(); diff --git a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/components/meetings-top-bar/meetings-top-bar.component.html b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/components/meetings-top-bar/meetings-top-bar.component.html index adadf349b..5e42b7622 100644 --- a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/components/meetings-top-bar/meetings-top-bar.component.html +++ b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/components/meetings-top-bar/meetings-top-bar.component.html @@ -2,6 +2,13 @@
+ + +
- -
- -
diff --git a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/components/meetings-top-bar/meetings-top-bar.component.ts b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/components/meetings-top-bar/meetings-top-bar.component.ts index 9dfbc0d58..bac262a43 100644 --- a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/components/meetings-top-bar/meetings-top-bar.component.ts +++ b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/components/meetings-top-bar/meetings-top-bar.component.ts @@ -1,51 +1,46 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, input, OnInit, output } from '@angular/core'; +import { Component, effect, input, output } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { FilterPillsComponent } from '@components/filter-pills/filter-pills.component'; import { InputTextComponent } from '@components/input-text/input-text.component'; import { SelectComponent } from '@components/select/select.component'; +import { FilterPillOption } from '@lfx-one/shared/interfaces'; @Component({ selector: 'lfx-meetings-top-bar', - - imports: [ReactiveFormsModule, InputTextComponent, SelectComponent], + imports: [ReactiveFormsModule, InputTextComponent, SelectComponent, FilterPillsComponent], templateUrl: './meetings-top-bar.component.html', }) -export class MeetingsTopBarComponent implements OnInit { +export class MeetingsTopBarComponent { public meetingTypeOptions = input.required<{ label: string; value: string | null }[]>(); public foundationOptions = input<{ label: string; value: string | null }[]>([]); public projectOptions = input<{ label: string; value: string | null }[]>([]); public showFoundationFilter = input(false); public showProjectFilter = input(false); - public readonly initialTimeFilter = input<'upcoming' | 'past'>('upcoming'); + public readonly timeFilter = input<'upcoming' | 'past'>('upcoming'); public readonly meetingTypeChange = output(); public readonly foundationFilterChange = output(); public readonly projectFilterChange = output(); + public readonly searchQuery = input(''); public readonly searchQueryChange = output(); public readonly timeFilterChange = output<'upcoming' | 'past'>(); - public searchForm: FormGroup; - public timeFilterOptions: { label: string; value: 'upcoming' | 'past' }[]; - - public constructor() { - // Initialize time filter options - this.timeFilterOptions = [ - { label: 'Upcoming', value: 'upcoming' }, - { label: 'Past', value: 'past' }, - ]; + public readonly timeTabOptions: FilterPillOption[] = [ + { id: 'upcoming', label: 'Upcoming' }, + { id: 'past', label: 'Past' }, + ]; - // Initialize form - this.searchForm = new FormGroup({ - search: new FormControl(''), - meetingType: new FormControl(null), - foundationFilter: new FormControl(null), - projectFilter: new FormControl(null), - timeFilter: new FormControl<'upcoming' | 'past'>('upcoming'), - }); + public searchForm: FormGroup = new FormGroup({ + search: new FormControl(''), + meetingType: new FormControl(null), + foundationFilter: new FormControl(null), + projectFilter: new FormControl(null), + }); - // Subscribe to form changes and emit events + public constructor() { this.searchForm .get('search') ?.valueChanges.pipe(takeUntilDestroyed()) @@ -53,24 +48,12 @@ export class MeetingsTopBarComponent implements OnInit { this.searchQueryChange.emit(value || ''); }); - // Subscribe to time filter changes - this.searchForm - .get('timeFilter') - ?.valueChanges.pipe(takeUntilDestroyed()) - .subscribe((value) => { - if (value) { - this.timeFilterChange.emit(value); - this.searchForm.get('foundationFilter')?.setValue(null, { emitEvent: false }); - this.searchForm.get('projectFilter')?.setValue(null, { emitEvent: false }); - } - }); - } - - public ngOnInit(): void { - const initial = this.initialTimeFilter(); - if (initial !== 'upcoming') { - this.searchForm.get('timeFilter')?.setValue(initial, { emitEvent: false }); - } + effect(() => { + const query = this.searchQuery(); + if (this.searchForm.get('search')?.value !== query) { + this.searchForm.get('search')?.setValue(query, { emitEvent: false }); + } + }); } public onMeetingTypeChange(value: string | null): void { @@ -79,7 +62,6 @@ export class MeetingsTopBarComponent implements OnInit { public onFoundationFilterChange(value: string | null): void { this.foundationFilterChange.emit(value); - // Reset project filter when foundation changes this.searchForm.get('projectFilter')?.setValue(null, { emitEvent: false }); this.projectFilterChange.emit(null); } @@ -88,7 +70,7 @@ export class MeetingsTopBarComponent implements OnInit { this.projectFilterChange.emit(value); } - public onTimeFilterChange(value: 'upcoming' | 'past'): void { - this.timeFilterChange.emit(value); + public onTimeTabChange(value: string): void { + this.timeFilterChange.emit(value as 'upcoming' | 'past'); } } diff --git a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.html b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.html index ab2c6f01a..38fb943be 100644 --- a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.html @@ -157,7 +157,7 @@

{{ activeLens() === 'me' ? 'My Meet

} - +
@if (upcomingMeetings() || pastMeetings()) { {{ activeLens() === 'me' ? 'My Meet [projectOptions]="projectOptions()" [showFoundationFilter]="showFoundationFilter()" [showProjectFilter]="showProjectFilter()" - [initialTimeFilter]="timeFilter()" + [timeFilter]="timeFilter()" (meetingTypeChange)="onMeetingTypeChange($event)" (foundationFilterChange)="onFoundationFilterChange($event)" (projectFilterChange)="onProjectFilterChange($event)" + [searchQuery]="searchQuery()" (searchQueryChange)="searchQuery.set($event)" (timeFilterChange)="onTimeFilterChange($event)" /> } @@ -191,29 +192,30 @@

{{ activeLens() === 'me' ? 'My Meet

}

- } @else if (upcomingMeetings().length === 0 && timeFilter() === 'upcoming') { - -
-
-
- -

{{ activeLens() === 'me' ? 'You have no upcoming meetings.' : 'Your project has no meetings, yet.' }}

-
-
-
-
+ } @else if (upcomingMeetings().length === 0 && timeFilter() === 'upcoming' && !isFiltered()) { + + + } @else if (pastMeetings().length === 0 && timeFilter() === 'past' && !isFiltered()) { + + } @else if (filteredMeetings().length === 0 && (project()?.uid || activeLens() === 'me')) { - -
-
-
- -
-

No Meetings Found

-

Try adjusting your search or filter criteria

-
-
-
+ + } @else if (filteredMeetings().length > 0) {
@for (meeting of filteredMeetings(); track meeting.id; let i = $index) { diff --git a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts index ca328121b..e25d2666b 100644 --- a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts @@ -36,11 +36,12 @@ import { toArray, } from 'rxjs'; +import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; import { MeetingsTopBarComponent } from './components/meetings-top-bar/meetings-top-bar.component'; @Component({ selector: 'lfx-meetings-dashboard', - imports: [MeetingCardComponent, MeetingsTopBarComponent, ButtonComponent, CardComponent, OnRenderDirective], + imports: [MeetingCardComponent, MeetingsTopBarComponent, ButtonComponent, CardComponent, OnRenderDirective, EmptyStateComponent], templateUrl: './meetings-dashboard.component.html', styleUrl: './meetings-dashboard.component.scss', }) @@ -74,6 +75,7 @@ export class MeetingsDashboardComponent { public projectOptions: Signal<{ label: string; value: string | null }[]>; public project: Signal; protected readonly canWrite = this.projectContextService.canWrite; + protected readonly isFiltered = this.initIsFiltered(); public loadingMore = signal(false); public hasMore: Signal; public autoLoadTriggerIndex: Signal; @@ -215,6 +217,13 @@ export class MeetingsDashboardComponent { }); } + public resetFilters(): void { + this.searchQuery.set(''); + this.meetingTypeFilter.set(null); + this.foundationFilter.set(null); + this.projectFilter.set(null); + } + public loadMore(): void { const isPast = this.timeFilter() === 'past'; const pageToken = isPast ? this.pastPageToken() : this.upcomingPageToken(); @@ -588,7 +597,8 @@ export class MeetingsDashboardComponent { const recurring = this.sortedUpcomingUserMeetings().filter((m) => m.recurrence !== null); const uniqueProjects = new Set(recurring.map((m) => m.project_name).filter(Boolean)); const count = uniqueProjects.size; - return count > 0 ? `Across ${count} ${count === 1 ? 'project' : 'projects'}` : ''; + const projectWord = count === 1 ? 'project' : 'projects'; + return count > 0 ? `Across ${count} ${projectWord}` : ''; }); } @@ -660,4 +670,8 @@ export class MeetingsDashboardComponent { } return [`meeting_type:${meetingType}`]; } + + private initIsFiltered(): Signal { + return computed(() => !!this.debouncedSearchQuery() || !!this.meetingTypeFilter() || !!this.foundationFilter() || !!this.projectFilter()); + } } diff --git a/apps/lfx-one/src/app/modules/my-activity/components/surveys-table/surveys-table.component.html b/apps/lfx-one/src/app/modules/my-activity/components/surveys-table/surveys-table.component.html index 8c6d53ff5..a668cef7e 100644 --- a/apps/lfx-one/src/app/modules/my-activity/components/surveys-table/surveys-table.component.html +++ b/apps/lfx-one/src/app/modules/my-activity/components/surveys-table/surveys-table.component.html @@ -64,7 +64,7 @@ - + {{ survey.survey_title }} diff --git a/apps/lfx-one/src/app/modules/my-activity/components/votes-table/votes-table.component.html b/apps/lfx-one/src/app/modules/my-activity/components/votes-table/votes-table.component.html index f9353689c..b2b98bd4d 100644 --- a/apps/lfx-one/src/app/modules/my-activity/components/votes-table/votes-table.component.html +++ b/apps/lfx-one/src/app/modules/my-activity/components/votes-table/votes-table.component.html @@ -63,7 +63,7 @@ - + {{ vote.poll_name }} diff --git a/apps/lfx-one/src/app/modules/my-activity/my-activity-dashboard/my-activity-dashboard.component.html b/apps/lfx-one/src/app/modules/my-activity/my-activity-dashboard/my-activity-dashboard.component.html index ca53fbd77..22bea747f 100644 --- a/apps/lfx-one/src/app/modules/my-activity/my-activity-dashboard/my-activity-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/my-activity/my-activity-dashboard/my-activity-dashboard.component.html @@ -14,9 +14,27 @@

{{ activityLabel.singular }}

@if (isVotesTab()) { - + @if (votes().length === 0) { + + + } @else { + + } } @else if (isSurveysTab()) { - + @if (surveys().length === 0) { + + + } @else { + + } }
diff --git a/apps/lfx-one/src/app/modules/my-activity/my-activity-dashboard/my-activity-dashboard.component.ts b/apps/lfx-one/src/app/modules/my-activity/my-activity-dashboard/my-activity-dashboard.component.ts index 606284ffa..e21c63d68 100644 --- a/apps/lfx-one/src/app/modules/my-activity/my-activity-dashboard/my-activity-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/my-activity/my-activity-dashboard/my-activity-dashboard.component.ts @@ -15,13 +15,14 @@ import { } from '@lfx-one/shared'; import { MyActivityTab, UserSurvey, UserVote } from '@lfx-one/shared/interfaces'; +import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; import { ActivityTopBarComponent } from '../components/activity-top-bar/activity-top-bar.component'; import { SurveysTableComponent } from '../components/surveys-table/surveys-table.component'; import { VotesTableComponent } from '../components/votes-table/votes-table.component'; @Component({ selector: 'lfx-my-activity-dashboard', - imports: [ActivityTopBarComponent, VotesTableComponent, SurveysTableComponent], + imports: [ActivityTopBarComponent, VotesTableComponent, SurveysTableComponent, EmptyStateComponent], templateUrl: './my-activity-dashboard.component.html', }) export class MyActivityDashboardComponent { diff --git a/apps/lfx-one/src/app/modules/surveys/components/surveys-table/surveys-table.component.html b/apps/lfx-one/src/app/modules/surveys/components/surveys-table/surveys-table.component.html index d023db05c..974b9fb0b 100644 --- a/apps/lfx-one/src/app/modules/surveys/components/surveys-table/surveys-table.component.html +++ b/apps/lfx-one/src/app/modules/surveys/components/surveys-table/surveys-table.component.html @@ -1,85 +1,98 @@ - -
-
-
- -
+ + + + + @if (loading() || surveys().length > 0) { +
+
+
+ +
+ + @if (showFoundationFilter()) { +
+ +
+ } + + @if (showProjectFilter()) { +
+ +
+ } - @if (showFoundationFilter()) { -
+
+ [options]="groupOptions()" + (valueChange)="onGroupChange($event)" + data-testid="surveys-group-filter">
- } - @if (showProjectFilter()) { -
+
+ [options]="typeOptions()" + (valueChange)="onTypeChange($event)" + data-testid="surveys-type-filter">
- } - -
- -
- -
- -
-
- +
- -
-
+
+ @if (!loading() && filteredSurveys().length === 0) { + + + } @else { {{ surveyLabel.singular }} Name Survey Type Group - Status Responses Due + Status - +
} @else if (!isMeLens() && !loading() && votes().length === 0) { - - -
-
-
-
- -
-
-

No {{ voteLabelPlural | lowercase }} yet

-

Create a {{ voteLabel | lowercase }} to gather decisions from project groups.

-
-
-
+ + } @else { + + +
+ + +
diff --git a/apps/lfx-one/src/app/shared/components/card-tabs-bar/card-tabs-bar.component.ts b/apps/lfx-one/src/app/shared/components/card-tabs-bar/card-tabs-bar.component.ts new file mode 100644 index 000000000..3ce0e456e --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/card-tabs-bar/card-tabs-bar.component.ts @@ -0,0 +1,36 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, input, output } from '@angular/core'; +import { FilterPillOption } from '@lfx-one/shared/interfaces'; +import { FilterPillsComponent } from '../filter-pills/filter-pills.component'; + +/** + * A standardised top-bar that lives at the top of a `` with `p-0` body padding. + * It renders the tab pills on the left and projects any action elements (buttons, etc.) on the right. + * Includes a bottom border separator matching the card's inner divider style. + * + * Usage: + * ```html + * + * + * + * + * + * + * + * ``` + */ +@Component({ + selector: 'lfx-card-tabs-bar', + imports: [FilterPillsComponent], + templateUrl: './card-tabs-bar.component.html', +}) +export class CardTabsBarComponent { + public readonly options = input.required(); + public readonly selectedFilter = input.required(); + public readonly filterChange = output(); +} diff --git a/apps/lfx-one/src/app/shared/components/empty-state/empty-state.component.html b/apps/lfx-one/src/app/shared/components/empty-state/empty-state.component.html new file mode 100644 index 000000000..810b69b8e --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/empty-state/empty-state.component.html @@ -0,0 +1,36 @@ + + + +@if (withCard()) { + + + +} @else { + +} + + +
+
+ +
+ +
+ + +

{{ title() }}

+ + + @if (subtitle()) { +

{{ subtitle() }}

+ } + + + @if (ctaLabel() && ctaRoute()) { + + } @else if (ctaLabel()) { + + } +
+
+
diff --git a/apps/lfx-one/src/app/shared/components/empty-state/empty-state.component.ts b/apps/lfx-one/src/app/shared/components/empty-state/empty-state.component.ts new file mode 100644 index 000000000..b455e1558 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/empty-state/empty-state.component.ts @@ -0,0 +1,29 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { NgTemplateOutlet } from '@angular/common'; +import { Component, input, output } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +import { ButtonComponent } from '@components/button/button.component'; +import { CardComponent } from '@components/card/card.component'; + +@Component({ + selector: 'lfx-empty-state', + imports: [NgTemplateOutlet, CardComponent, ButtonComponent, RouterLink], + templateUrl: './empty-state.component.html', +}) +export class EmptyStateComponent { + // === Inputs === + public readonly icon = input.required(); + public readonly title = input.required(); + public readonly subtitle = input(''); + public readonly ctaLabel = input(undefined); + public readonly ctaRoute = input(undefined); + public readonly ctaIcon = input(undefined); + /** Set to false when the component is already inside a card-like container */ + public readonly withCard = input(true); + + // === Outputs === + public readonly ctaClick = output(); +} diff --git a/apps/lfx-one/src/app/shared/components/table/table.component.html b/apps/lfx-one/src/app/shared/components/table/table.component.html index 3955a05ae..476df12e7 100644 --- a/apps/lfx-one/src/app/shared/components/table/table.component.html +++ b/apps/lfx-one/src/app/shared/components/table/table.component.html @@ -6,9 +6,9 @@ [columns]="columns()" [dataKey]="dataKey()" [style]="style()" - [rowHover]="rowHover()" + [rowHover]="rowHover() && !isEmpty()" (click)="onHostClick($event)" - [styleClass]="(styleClass() || '') + (selectionMode() ? ' lfx-table-selectable' : '')" + [styleClass]="(styleClass() || '') + (!isEmpty() && selectionMode() ? ' lfx-table-selectable' : '')" [tableStyle]="tableStyle()" [tableStyleClass]="tableStyleClass()" [paginator]="paginator()" @@ -19,7 +19,7 @@ [rowsPerPageOptions]="rowsPerPageOptions()" [alwaysShowPaginator]="alwaysShowPaginator()" [paginatorPosition]="paginatorPosition()" - [paginatorStyleClass]="paginatorStyleClass()" + [paginatorStyleClass]="(paginatorStyleClass() ?? '') + (!rowsPerPageOptions() ? ' lfx-paginator-no-rpp' : '') + (isSinglePage() ? ' lfx-paginator-single-page' : '')" [paginatorDropdownAppendTo]="paginatorDropdownAppendTo()" [paginatorDropdownScrollHeight]="paginatorDropdownScrollHeight()" [currentPageReportTemplate]="currentPageReportTemplate()" @@ -94,8 +94,8 @@ } - - @if (headerTemplate) { + + @if (headerTemplate && !isEmpty()) { diff --git a/apps/lfx-one/src/app/shared/components/table/table.component.scss b/apps/lfx-one/src/app/shared/components/table/table.component.scss index f2eaaa37d..ab5a90adf 100644 --- a/apps/lfx-one/src/app/shared/components/table/table.component.scss +++ b/apps/lfx-one/src/app/shared/components/table/table.component.scss @@ -30,15 +30,40 @@ a { @apply text-blue-500 hover:text-blue-600 hover:underline cursor-pointer text-sm font-medium; } + + // ─── Name-column link pattern ──────────────────────────────────── + // Apply .lfx-table-name-link to the clickable name/title element in + // the first column of any table (works on ,