From 9afac54aa017ea42669abe21f854b96b917655e2 Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Fri, 10 Apr 2026 13:16:26 +0800 Subject: [PATCH 01/12] feat: partial work on visa request view Signed-off-by: Efren Lim --- .../events-table/events-table.component.html | 5 +- .../events-list/events-list.component.html | 2 + .../events-list/events-list.component.ts | 3 +- .../visa-request/visa-request.component.html | 97 +++++++++++++++++++ .../visa-request/visa-request.component.scss | 2 + .../visa-request/visa-request.component.ts | 52 ++++++++++ .../server/controllers/events.controller.ts | 8 +- .../visa-letter-manual/template.html | 1 + .../src/server/services/events.service.ts | 6 +- .../shared/src/constants/events.constants.ts | 5 + .../src/interfaces/my-event.interface.ts | 23 +++++ 11 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts 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 9eef98cd7..3d844f3f6 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 @@ -94,7 +94,10 @@ } @else { - + } 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 6eeb34755..e30382fca 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 @@ -42,6 +42,8 @@ (pageChange)="onPastPageChange($event)" (sortChange)="onPastSortChange($event)" data-testid="events-past-table" /> + } @else if (activeTab() === 'visa-letters') { + } @else {
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 bbc8dc328..d44bf5a64 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 @@ -10,10 +10,11 @@ import { EventTab, EventTabId, MyEventsResponse, PageChangeEvent, SortChangeEven import { MessageService } from 'primeng/api'; import { catchError, combineLatest, debounceTime, finalize, of, skip, switchMap, tap } from 'rxjs'; import { EventsTableComponent } from '../events-table/events-table.component'; +import { VisaRequestComponent } from '../visa-request/visa-request.component'; @Component({ selector: 'lfx-events-list', - imports: [NgClass, EventsTableComponent], + imports: [NgClass, EventsTableComponent, VisaRequestComponent], templateUrl: './events-list.component.html', }) export class EventsListComponent { diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.html new file mode 100644 index 000000000..e7909b966 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.html @@ -0,0 +1,97 @@ + + + +
+ +
+ + + + + + + + + + + + + Status + + + + + + + + + {{ request.name }} + + + + + + {{ request.location }} + + + + + {{ request.applicationDate }} + + + + + + + + + + + + +
+
+
+ +

No visa requests found

+
+
+
+ + +
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.scss new file mode 100644 index 000000000..c32094b45 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.scss @@ -0,0 +1,2 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts new file mode 100644 index 000000000..22020ea16 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts @@ -0,0 +1,52 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, signal } from '@angular/core'; +import { TableComponent } from '@components/table/table.component'; +import { TagComponent } from '@components/tag/tag.component'; +import { TagSeverity, VisaRequestsResponse } from '@lfx-one/shared/interfaces'; +import { ButtonComponent } from '@app/shared/components/button/button.component'; + +@Component({ + selector: 'lfx-visa-request', + imports: [TableComponent, TagComponent, ButtonComponent], + templateUrl: './visa-request.component.html', +}) +export class VisaRequestComponent { + // public readonly pageChange = output(); + // public readonly sortChange = output(); + + protected readonly visaRequestsResponse = signal({ data: [], total: 0, pageSize: 10, offset: 0 }); + protected readonly loading = signal(false); + protected readonly sortField = signal('APPLICATION_DATE'); + protected readonly sortOrder = signal<'ASC' | 'DESC'>('DESC'); + + protected readonly statusSeverityMap: Partial> = { + Pending: 'warn', + Submitted: 'info', + Approved: 'success', + Denied: 'danger', + }; + + protected readonly sortIcons = computed(() => { + const field = this.sortField(); + const order = this.sortOrder(); + const getIcon = (f: string): string => { + if (field !== f) return 'fa-light fa-sort text-gray-300'; + return order === 'ASC' ? 'fa-solid fa-caret-up text-blue-500' : 'fa-solid fa-caret-down text-blue-500'; + }; + return { + EVENT_NAME: getIcon('EVENT_NAME'), + EVENT_CITY: getIcon('EVENT_CITY'), + APPLICATION_DATE: getIcon('APPLICATION_DATE'), + }; + }); + + protected onPageChange(event: { first: number; rows: number }): void { + // this.pageChange.emit({ offset: event.first, pageSize: event.rows }); + } + + protected onHeaderClick(field: string): void { + // this.sortChange.emit({ field }); + } +} diff --git a/apps/lfx-one/src/server/controllers/events.controller.ts b/apps/lfx-one/src/server/controllers/events.controller.ts index e7f9aef16..54be398f2 100644 --- a/apps/lfx-one/src/server/controllers/events.controller.ts +++ b/apps/lfx-one/src/server/controllers/events.controller.ts @@ -8,7 +8,13 @@ import { NextFunction, Request, Response } from 'express'; import { AuthenticationError, ServiceValidationError } from '../errors'; import { logger } from '../services/logger.service'; import { CertificateService } from '../services/certificate.service'; -import { DEFAULT_EVENTS_PAGE_SIZE, MAX_EVENTS_PAGE_SIZE, VALID_EVENT_SORT_ORDERS, VALID_EVENT_STATUS_VALUES, VALID_MY_EVENT_STATUS_VALUES } from '@lfx-one/shared/constants'; +import { + DEFAULT_EVENTS_PAGE_SIZE, + MAX_EVENTS_PAGE_SIZE, + VALID_EVENT_SORT_ORDERS, + VALID_EVENT_STATUS_VALUES, + VALID_MY_EVENT_STATUS_VALUES, +} from '@lfx-one/shared/constants'; import { EventSortOrder, EventStatusFilter, GetEventOrganizationsOptions, GetEventsOptions } from '@lfx-one/shared/interfaces'; import { EventsService } from '../services/events.service'; diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html index 36195b2a1..c76c44bd2 100644 --- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html +++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html @@ -102,6 +102,7 @@ + }}{{ event.state }}, {{/if}} {{#if event.country }}{{ event.country }}{{/if}} diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts index c6bbfa775..f330b9495 100644 --- a/apps/lfx-one/src/server/services/events.service.ts +++ b/apps/lfx-one/src/server/services/events.service.ts @@ -316,11 +316,7 @@ export class EventsService { return { data, total, pageSize: normalizedPageSize, offset: normalizedOffset }; } - public async getEventOrganizations( - req: Request, - userEmail: string, - options: GetEventOrganizationsOptions - ): Promise { + public async getEventOrganizations(req: Request, userEmail: string, options: GetEventOrganizationsOptions): Promise { const { projectName, isPast } = options; logger.debug(req, 'get_event_organizations', 'Building organizations query', { diff --git a/packages/shared/src/constants/events.constants.ts b/packages/shared/src/constants/events.constants.ts index 5f4c943e8..da92610e1 100644 --- a/packages/shared/src/constants/events.constants.ts +++ b/packages/shared/src/constants/events.constants.ts @@ -1,8 +1,12 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +<<<<<<< Updated upstream import { FoundationEventStatus } from '../enums'; import { FilterOption, MyEventsResponse, EventsResponse, MyEventOrganizationsResponse, TagSeverity } from '../interfaces'; +======= +import { FilterOption, MyEventsResponse, EventsResponse, MyEventOrganizationsResponse, VisaRequestsResponse } from '../interfaces'; +>>>>>>> Stashed changes export const EVENT_ROLE_OPTIONS: FilterOption[] = [ { label: 'All Roles', value: null }, @@ -52,3 +56,4 @@ export const MAX_EVENTS_PAGE_SIZE = 100; export const EMPTY_MY_EVENTS_RESPONSE: MyEventsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; export const EMPTY_EVENTS_RESPONSE: EventsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; export const EMPTY_ORGANIZATIONS_RESPONSE: MyEventOrganizationsResponse = { data: [] }; +export const EMPTY_VISA_REQUESTS_RESPONSE: VisaRequestsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; diff --git a/packages/shared/src/interfaces/my-event.interface.ts b/packages/shared/src/interfaces/my-event.interface.ts index 1e9b04897..e68855966 100644 --- a/packages/shared/src/interfaces/my-event.interface.ts +++ b/packages/shared/src/interfaces/my-event.interface.ts @@ -252,6 +252,29 @@ export interface CertificateData { userName: string; } +/** + * Visa letter request item for the My Events visa-letters tab + */ +export interface VisaRequest { + /** Unique event identifier */ + id: string; + /** Event display name */ + name: string; + /** External event URL */ + url: string; + /** Human-readable location string (e.g. "Salt Lake City, UT") */ + location: string; + /** Human-readable application date string (e.g. "Jan 15, 2026") */ + applicationDate: string; + /** Visa letter request status (e.g. "Pending", "Approved", "Denied") */ + status: string; +} + +/** + * Paginated API response for visa letter requests + */ +export type VisaRequestsResponse = OffsetPaginatedResponse; + /** * Valid sort order values for event queries */ From 5ce8e26b9b36f3cb3889091e6f53a601c21ae803 Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Fri, 10 Apr 2026 15:23:22 +0800 Subject: [PATCH 02/12] feat: added endpoint and ui for visa and travel fund requests list Signed-off-by: Efren Lim --- .../events-top-bar.component.ts | 17 +++ .../events-list/events-list.component.html | 9 +- .../events-list/events-list.component.ts | 3 +- .../travel-funding.component.html | 97 +++++++++++++++ .../travel-funding.component.scss | 2 + .../travel-funding.component.ts | 100 ++++++++++++++++ .../visa-request/visa-request.component.ts | 64 ++++++++-- .../my-events-dashboard.component.html | 4 +- .../my-events-dashboard.component.ts | 13 +- .../src/app/shared/services/events.service.ts | 33 ++++- .../server/controllers/events.controller.ts | 81 ++++++++++++- .../visa-letter-manual/template.html | 5 + .../lfx-one/src/server/routes/events.route.ts | 2 + .../src/server/services/events.service.ts | 113 +++++++++++++++++- .../shared/src/constants/events.constants.ts | 25 +++- .../src/interfaces/my-event.interface.ts | 53 ++++++++ 16 files changed, 593 insertions(+), 28 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts diff --git a/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.ts b/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.ts index be6bf310c..e145cd91d 100644 --- a/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.ts +++ b/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.ts @@ -64,6 +64,23 @@ export class EventsTopBarComponent { .subscribe(() => { this.searchForm.get('foundation')?.setValue(null, { emitEvent: false }); }); + + // Clear status when the available options change (e.g. switching between event tabs + // and visa/TF tabs which have different status sets). + toObservable(this.statusOptions) + .pipe(skip(1), takeUntilDestroyed()) + .subscribe(() => { + this.searchForm.get('status')?.setValue(null); + }); + + // Clear role when the role filter is hidden so stale values don't persist. + toObservable(this.showRoleFilter) + .pipe(skip(1), takeUntilDestroyed()) + .subscribe((show) => { + if (!show) { + this.searchForm.get('role')?.setValue(null); + } + }); } public clearSearch(): void { 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 e30382fca..abd1cf18f 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 @@ -43,11 +43,8 @@ (sortChange)="onPastSortChange($event)" data-testid="events-past-table" /> } @else if (activeTab() === 'visa-letters') { - - } @else { -
- -

Coming soon

-
+ + } @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 d44bf5a64..32a3f14c6 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 @@ -10,11 +10,12 @@ import { EventTab, EventTabId, MyEventsResponse, PageChangeEvent, SortChangeEven import { MessageService } from 'primeng/api'; import { catchError, combineLatest, debounceTime, finalize, of, skip, switchMap, tap } from 'rxjs'; import { EventsTableComponent } from '../events-table/events-table.component'; +import { TravelFundingComponent } from '../travel-funding/travel-funding.component'; import { VisaRequestComponent } from '../visa-request/visa-request.component'; @Component({ selector: 'lfx-events-list', - imports: [NgClass, EventsTableComponent, VisaRequestComponent], + imports: [NgClass, EventsTableComponent, TravelFundingComponent, VisaRequestComponent], templateUrl: './events-list.component.html', }) export class EventsListComponent { diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.html new file mode 100644 index 000000000..aaa1290dc --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.html @@ -0,0 +1,97 @@ + + + +
+ +
+ + + + + + + + + + + + + Status + + + + + + + + + {{ request.name }} + + + + + + {{ request.location }} + + + + + {{ request.applicationDate }} + + + + + + + + + + + + +
+
+
+ +

No travel fund requests found

+
+
+
+ + +
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.scss new file mode 100644 index 000000000..c32094b45 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.scss @@ -0,0 +1,2 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts new file mode 100644 index 000000000..6ba1cca26 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts @@ -0,0 +1,100 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { Component, computed, inject, input, Signal, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { EventsService } from '@app/shared/services/events.service'; +import { ButtonComponent } from '@components/button/button.component'; +import { TableComponent } from '@components/table/table.component'; +import { TagComponent } from '@components/tag/tag.component'; +import { DEFAULT_EVENTS_PAGE_SIZE, EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE } from '@lfx-one/shared/constants'; +import { PageChangeEvent, TagSeverity, TravelFundRequestsResponse } from '@lfx-one/shared/interfaces'; +import { catchError, combineLatest, finalize, of, skip, switchMap, tap } from 'rxjs'; + +@Component({ + selector: 'lfx-travel-funding', + imports: [TableComponent, TagComponent, ButtonComponent], + templateUrl: './travel-funding.component.html', +}) +export class TravelFundingComponent { + private readonly eventsService = inject(EventsService); + + public readonly searchQuery = input(''); + public readonly status = input(null); + + protected readonly loading = signal(false); + protected readonly sortField = signal('APPLICATION_DATE'); + protected readonly sortOrder = signal<'ASC' | 'DESC'>('DESC'); + protected readonly page = signal({ offset: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE }); + + protected readonly travelFundRequestsResponse: Signal = this.initTravelFundRequests(); + + protected readonly statusSeverityMap: Partial> = { + Submitted: 'info', + Approved: 'success', + Denied: 'danger', + Expired: 'secondary', + }; + + protected readonly sortIcons = computed(() => { + const field = this.sortField(); + const order = this.sortOrder(); + const getIcon = (f: string): string => { + if (field !== f) return 'fa-light fa-sort text-gray-300'; + return order === 'ASC' ? 'fa-solid fa-caret-up text-blue-500' : 'fa-solid fa-caret-down text-blue-500'; + }; + return { + EVENT_NAME: getIcon('EVENT_NAME'), + EVENT_CITY: getIcon('EVENT_CITY'), + APPLICATION_DATE: getIcon('APPLICATION_DATE'), + }; + }); + + public constructor() { + combineLatest([toObservable(this.searchQuery), toObservable(this.status)]) + .pipe(skip(1), takeUntilDestroyed()) + .subscribe(() => { + this.page.set({ offset: 0, pageSize: this.page().pageSize }); + }); + } + + protected onPageChange(event: { first: number; rows: number }): void { + this.loading.set(true); + this.page.set({ offset: event.first, pageSize: event.rows }); + } + + protected onHeaderClick(field: string): void { + if (this.sortField() === field) { + this.sortOrder.set(this.sortOrder() === 'ASC' ? 'DESC' : 'ASC'); + } else { + this.sortField.set(field); + this.sortOrder.set('ASC'); + } + this.page.set({ offset: 0, pageSize: this.page().pageSize }); + } + + private initTravelFundRequests(): Signal { + return toSignal( + toObservable( + computed(() => ({ + ...this.page(), + searchQuery: this.searchQuery() || undefined, + status: this.status() ?? undefined, + sortField: this.sortField(), + sortOrder: this.sortOrder(), + })) + ).pipe( + tap(() => this.loading.set(true)), + switchMap(({ offset, pageSize, searchQuery, status, sortField, sortOrder }) => + this.eventsService.getTravelFundRequests({ offset, pageSize, searchQuery, status, sortField, sortOrder }).pipe( + catchError(() => of(EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE)), + finalize(() => this.loading.set(false)) + ) + ) + ), + { initialValue: EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE } + ); + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts index 22020ea16..85d001eb3 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts @@ -1,11 +1,15 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, signal } from '@angular/core'; +import { Component, computed, inject, input, Signal, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { EventsService } from '@app/shared/services/events.service'; +import { ButtonComponent } from '@components/button/button.component'; import { TableComponent } from '@components/table/table.component'; import { TagComponent } from '@components/tag/tag.component'; -import { TagSeverity, VisaRequestsResponse } from '@lfx-one/shared/interfaces'; -import { ButtonComponent } from '@app/shared/components/button/button.component'; +import { DEFAULT_EVENTS_PAGE_SIZE, EMPTY_VISA_REQUESTS_RESPONSE } from '@lfx-one/shared/constants'; +import { PageChangeEvent, TagSeverity, VisaRequestsResponse } from '@lfx-one/shared/interfaces'; +import { catchError, combineLatest, finalize, of, skip, switchMap, tap } from 'rxjs'; @Component({ selector: 'lfx-visa-request', @@ -13,19 +17,23 @@ import { ButtonComponent } from '@app/shared/components/button/button.component' templateUrl: './visa-request.component.html', }) export class VisaRequestComponent { - // public readonly pageChange = output(); - // public readonly sortChange = output(); + private readonly eventsService = inject(EventsService); + + public readonly searchQuery = input(''); + public readonly status = input(null); - protected readonly visaRequestsResponse = signal({ data: [], total: 0, pageSize: 10, offset: 0 }); protected readonly loading = signal(false); protected readonly sortField = signal('APPLICATION_DATE'); protected readonly sortOrder = signal<'ASC' | 'DESC'>('DESC'); + protected readonly page = signal({ offset: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE }); + + protected readonly visaRequestsResponse: Signal = this.initVisaRequests(); protected readonly statusSeverityMap: Partial> = { - Pending: 'warn', Submitted: 'info', Approved: 'success', Denied: 'danger', + Expired: 'secondary', }; protected readonly sortIcons = computed(() => { @@ -42,11 +50,49 @@ export class VisaRequestComponent { }; }); + public constructor() { + combineLatest([toObservable(this.searchQuery), toObservable(this.status)]) + .pipe(skip(1), takeUntilDestroyed()) + .subscribe(() => { + this.page.set({ offset: 0, pageSize: this.page().pageSize }); + }); + } + protected onPageChange(event: { first: number; rows: number }): void { - // this.pageChange.emit({ offset: event.first, pageSize: event.rows }); + this.loading.set(true); + this.page.set({ offset: event.first, pageSize: event.rows }); } protected onHeaderClick(field: string): void { - // this.sortChange.emit({ field }); + if (this.sortField() === field) { + this.sortOrder.set(this.sortOrder() === 'ASC' ? 'DESC' : 'ASC'); + } else { + this.sortField.set(field); + this.sortOrder.set('ASC'); + } + this.page.set({ offset: 0, pageSize: this.page().pageSize }); + } + + private initVisaRequests(): Signal { + return toSignal( + toObservable( + computed(() => ({ + ...this.page(), + searchQuery: this.searchQuery() || undefined, + status: this.status() ?? undefined, + sortField: this.sortField(), + sortOrder: this.sortOrder(), + })) + ).pipe( + tap(() => this.loading.set(true)), + switchMap(({ offset, pageSize, searchQuery, status, sortField, sortOrder }) => + this.eventsService.getVisaRequests({ offset, pageSize, searchQuery, status, sortField, sortOrder }).pipe( + catchError(() => of(EMPTY_VISA_REQUESTS_RESPONSE)), + finalize(() => this.loading.set(false)) + ) + ) + ), + { initialValue: EMPTY_VISA_REQUESTS_RESPONSE } + ); } } 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 ae011a293..84fd8d24d 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 @@ -19,8 +19,10 @@

My Events
(null); protected readonly selectedSearchQuery = signal(''); + /** True when the active tab uses request-style filters (no role, no foundation, different statuses). */ + protected readonly isRequestTab = computed(() => this.activeTab() === 'visa-letters' || this.activeTab() === 'travel-funding'); + + protected readonly currentStatusOptions = computed(() => (this.isRequestTab() ? VISA_REQUEST_STATUS_OPTIONS : MY_EVENT_STATUS_OPTIONS)); + protected readonly foundationLabel: Signal = this.initFoundationLabel(); protected onFoundationChange(value: string | null): void { @@ -52,8 +57,10 @@ export class MyEventsDashboardComponent { protected onActiveTabChange(tab: EventTabId): void { this.activeTab.set(tab); - // Reset foundation filter when switching tabs — each tab has a different foundation list + // Reset all filters when switching tabs — each tab has different filter sets this.selectedFoundation.set(null); + this.selectedRole.set(null); + this.selectedStatus.set(null); } private initFoundationLabel(): Signal { diff --git a/apps/lfx-one/src/app/shared/services/events.service.ts b/apps/lfx-one/src/app/shared/services/events.service.ts index 71a13dde3..cc4e148f8 100644 --- a/apps/lfx-one/src/app/shared/services/events.service.ts +++ b/apps/lfx-one/src/app/shared/services/events.service.ts @@ -5,15 +5,18 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { EMPTY_ORGANIZATIONS_RESPONSE } from '@lfx-one/shared/constants'; +import { EMPTY_ORGANIZATIONS_RESPONSE, EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE, EMPTY_VISA_REQUESTS_RESPONSE } from '@lfx-one/shared/constants'; import { EventsResponse, GetCertificateParams, GetEventOrganizationsParams, + GetEventRequestsParams, GetEventsParams, GetMyEventsParams, MyEventOrganizationsResponse, MyEventsResponse, + TravelFundRequestsResponse, + VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; import { catchError, Observable, of } from 'rxjs'; @@ -71,6 +74,34 @@ export class EventsService { .pipe(catchError(() => of(EMPTY_ORGANIZATIONS_RESPONSE))); } + public getVisaRequests(params: GetEventRequestsParams = {}): Observable { + let httpParams = new HttpParams(); + + if (params.searchQuery) httpParams = httpParams.set('searchQuery', params.searchQuery); + if (params.status) httpParams = httpParams.set('status', params.status); + if (params.sortField) httpParams = httpParams.set('sortField', params.sortField); + if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize)); + if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset)); + if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder); + + return this.http.get('/api/events/visa-requests', { params: httpParams }).pipe(catchError(() => of(EMPTY_VISA_REQUESTS_RESPONSE))); + } + + public getTravelFundRequests(params: GetEventRequestsParams = {}): Observable { + let httpParams = new HttpParams(); + + if (params.searchQuery) httpParams = httpParams.set('searchQuery', params.searchQuery); + if (params.status) httpParams = httpParams.set('status', params.status); + if (params.sortField) httpParams = httpParams.set('sortField', params.sortField); + if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize)); + if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset)); + if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder); + + return this.http + .get('/api/events/travel-fund-requests', { params: httpParams }) + .pipe(catchError(() => of(EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE))); + } + public getCertificate(params: GetCertificateParams): Observable { let httpParams = new HttpParams(); diff --git a/apps/lfx-one/src/server/controllers/events.controller.ts b/apps/lfx-one/src/server/controllers/events.controller.ts index 54be398f2..064f46780 100644 --- a/apps/lfx-one/src/server/controllers/events.controller.ts +++ b/apps/lfx-one/src/server/controllers/events.controller.ts @@ -15,7 +15,14 @@ import { VALID_EVENT_STATUS_VALUES, VALID_MY_EVENT_STATUS_VALUES, } from '@lfx-one/shared/constants'; -import { EventSortOrder, EventStatusFilter, GetEventOrganizationsOptions, GetEventsOptions } from '@lfx-one/shared/interfaces'; +import { + EventSortOrder, + EventStatusFilter, + GetEventOrganizationsOptions, + GetEventRequestsOptions, + GetEventsOptions, + VisaRequestsResponse, +} from '@lfx-one/shared/interfaces'; import { EventsService } from '../services/events.service'; export class EventsController { @@ -195,6 +202,78 @@ export class EventsController { } } + /** + * GET /api/events/visa-requests + * Get paginated visa letter requests for the authenticated user + * Query params: eventId (string), projectName (string), searchQuery (string), status (string), + * sortField (string), pageSize (number), offset (number), sortOrder (ASC|DESC) + */ + public async getVisaRequests(req: Request, res: Response, next: NextFunction): Promise { + return this.handleEventRequestsEndpoint(req, res, next, 'get_visa_requests', (r, email, opts) => this.eventsService.getVisaRequests(r, email, opts)); + } + + /** + * GET /api/events/travel-fund-requests + * Get paginated travel fund requests for the authenticated user + * Query params: eventId (string), projectName (string), searchQuery (string), status (string), + * sortField (string), pageSize (number), offset (number), sortOrder (ASC|DESC) + */ + public async getTravelFundRequests(req: Request, res: Response, next: NextFunction): Promise { + return this.handleEventRequestsEndpoint(req, res, next, 'get_travel_fund_requests', (r, email, opts) => + this.eventsService.getTravelFundRequests(r, email, opts) + ); + } + + private async handleEventRequestsEndpoint( + req: Request, + res: Response, + next: NextFunction, + operationName: string, + fetchFn: (req: Request, userEmail: string, options: GetEventRequestsOptions) => Promise + ): Promise { + const startTime = logger.startOperation(req, operationName, { + has_query: Object.keys(req.query).length > 0, + }); + + try { + const userEmail = (req.oidc?.user?.['email'] as string)?.toLowerCase(); + + if (!userEmail) { + throw new AuthenticationError('User authentication required', { operation: operationName }); + } + + const rawPageSize = parseInt(String(req.query['pageSize'] ?? DEFAULT_EVENTS_PAGE_SIZE), 10); + const rawOffset = parseInt(String(req.query['offset'] ?? 0), 10); + const rawSortOrder = String(req.query['sortOrder'] ?? 'DESC').toUpperCase() as EventSortOrder; + + const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 && rawPageSize <= MAX_EVENTS_PAGE_SIZE ? rawPageSize : DEFAULT_EVENTS_PAGE_SIZE; + const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0; + const sortOrder: EventSortOrder = VALID_EVENT_SORT_ORDERS.includes(rawSortOrder) ? rawSortOrder : 'DESC'; + + const options: GetEventRequestsOptions = { + eventId: req.query['eventId'] ? String(req.query['eventId']) : undefined, + projectName: req.query['projectName'] ? String(req.query['projectName']) : undefined, + searchQuery: req.query['searchQuery'] ? String(req.query['searchQuery']).trim() : undefined, + status: req.query['status'] ? String(req.query['status']) : undefined, + sortField: req.query['sortField'] ? String(req.query['sortField']) : undefined, + pageSize, + offset, + sortOrder, + }; + + const response = await fetchFn(req, userEmail, options); + + logger.success(req, operationName, startTime, { + result_count: response.data.length, + total: response.total, + }); + + res.json(response); + } catch (error) { + next(error); + } + } + /** * GET /api/events/certificate * Download attendance certificate as a PDF for the authenticated user diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html index c76c44bd2..26c72973c 100644 --- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html +++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html @@ -103,6 +103,11 @@ + + + + + }}{{ event.state }}, {{/if}} {{#if event.country }}{{ event.country }}{{/if}} diff --git a/apps/lfx-one/src/server/routes/events.route.ts b/apps/lfx-one/src/server/routes/events.route.ts index 37d0a8b0b..893ec9c91 100644 --- a/apps/lfx-one/src/server/routes/events.route.ts +++ b/apps/lfx-one/src/server/routes/events.route.ts @@ -11,5 +11,7 @@ const eventsController = new EventsController(); router.get('/', (req, res, next) => eventsController.getMyEvents(req, res, next)); router.get('/all', (req, res, next) => eventsController.getEvents(req, res, next)); router.get('/organizations', (req, res, next) => eventsController.getEventOrganizations(req, res, next)); +router.get('/visa-requests', (req, res, next) => eventsController.getVisaRequests(req, res, next)); +router.get('/travel-fund-requests', (req, res, next) => eventsController.getTravelFundRequests(req, res, next)); router.get('/certificate', (req, res, next) => eventsController.getCertificate(req, res, next)); export default router; diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts index f330b9495..d3f53da03 100644 --- a/apps/lfx-one/src/server/services/events.service.ts +++ b/apps/lfx-one/src/server/services/events.service.ts @@ -3,19 +3,30 @@ // Generated with [Claude Code](https://claude.ai/code) -import { COMING_SOON_SENTINEL, DEFAULT_EVENT_SORT_FIELD, VALID_EVENT_SORT_FIELDS } from '@lfx-one/shared/constants'; +import { + COMING_SOON_SENTINEL, + DEFAULT_EVENT_SORT_FIELD, + DEFAULT_VISA_REQUEST_SORT_FIELD, + VALID_EVENT_SORT_FIELDS, + VALID_VISA_REQUEST_SORT_FIELDS, +} from '@lfx-one/shared/constants'; import { EventRow, EventSortOrder, EventsResponse, FoundationEvent, GetEventOrganizationsOptions, + GetEventRequestsOptions, GetEventsOptions, GetMyEventsOptions, MyEvent, MyEventOrganizationsResponse, MyEventRow, MyEventsResponse, + TravelFundRequestsResponse, + VisaRequest, + VisaRequestRow, + VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; import { Request } from 'express'; @@ -373,6 +384,93 @@ export class EventsService { return { data }; } + public async getVisaRequests(req: Request, userEmail: string, options: GetEventRequestsOptions): Promise { + return this.executeEventRequestsQuery(req, userEmail, options, 'VL_REQUEST_STATUS', 'VL_APPLICATION_DATE', 'get_visa_requests'); + } + + public async getTravelFundRequests(req: Request, userEmail: string, options: GetEventRequestsOptions): Promise { + return this.executeEventRequestsQuery(req, userEmail, options, 'TF_REQUEST_STATUS', 'TF_APPLICATION_DATE', 'get_travel_fund_requests'); + } + + private async executeEventRequestsQuery( + req: Request, + userEmail: string, + options: GetEventRequestsOptions, + statusColumn: string, + applicationDateColumn: string, + operationName: string + ): Promise { + const { eventId, projectName, searchQuery, status, sortField: rawSortField, pageSize, offset, sortOrder } = options; + const sortField = rawSortField && VALID_VISA_REQUEST_SORT_FIELDS.has(rawSortField) ? rawSortField : DEFAULT_VISA_REQUEST_SORT_FIELD; + const normalizedSortOrder: EventSortOrder = sortOrder === 'DESC' ? 'DESC' : 'ASC'; + const normalizedPageSize = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : 10; + const normalizedOffset = Number.isInteger(offset) && offset >= 0 ? offset : 0; + + logger.debug(req, operationName, 'Building event requests query', { + has_event_id: !!eventId, + has_project_name: !!projectName, + has_search_query: !!searchQuery, + status, + page_size: normalizedPageSize, + offset: normalizedOffset, + sort_order: normalizedSortOrder, + }); + + const eventIdFilter = eventId ? 'AND EVENT_ID = ?' : ''; + const projectNameFilter = projectName ? 'AND PROJECT_NAME = ?' : ''; + const searchQueryFilter = searchQuery ? 'AND EVENT_NAME ILIKE ?' : ''; + const statusFilter = status ? `AND ${statusColumn} = ?` : ''; + + const sql = ` + SELECT + EVENT_ID, + EVENT_NAME, + EVENT_URL, + EVENT_LOCATION, + EVENT_CITY, + EVENT_COUNTRY, + ${applicationDateColumn} AS APPLICATION_DATE, + ${statusColumn} AS REQUEST_STATUS, + COUNT(*) OVER() AS TOTAL_RECORDS + FROM ANALYTICS.PLATINUM_LFX_ONE.EVENT_REGISTRATIONS + WHERE ${statusColumn} IS NOT NULL + AND USER_EMAIL = ? + ${eventIdFilter} + ${projectNameFilter} + ${searchQueryFilter} + ${statusFilter} + ORDER BY ${sortField} ${normalizedSortOrder} + LIMIT ${normalizedPageSize} OFFSET ${normalizedOffset} + `; + + const binds: string[] = [userEmail]; + if (eventId) binds.push(eventId); + if (projectName) binds.push(projectName); + if (searchQuery) binds.push(`%${searchQuery}%`); + if (status) binds.push(status); + + logger.debug(req, operationName, 'Executing event requests query', { bind_count: binds.length }); + + let result; + try { + result = await this.snowflakeService.execute(sql, binds); + } catch (error) { + logger.warning(req, operationName, 'Snowflake query failed, returning empty results', { + error: error instanceof Error ? error.message : String(error), + page_size: normalizedPageSize, + offset: normalizedOffset, + }); + return { data: [], total: 0, pageSize: normalizedPageSize, offset: normalizedOffset }; + } + + const total = result.rows.length > 0 ? result.rows[0].TOTAL_RECORDS : 0; + const data = result.rows.map((row) => this.mapRowToVisaRequest(row)); + + logger.debug(req, operationName, 'Fetched event requests', { count: data.length, total }); + + return { data, total, pageSize: normalizedPageSize, offset: normalizedOffset }; + } + /** Status filter for the past events query (unqualified column names, no IS NULL support). */ private buildStatusFilter(status: string): { filter: string; binds: string[] } { switch (status) { @@ -437,6 +535,19 @@ export class EventsService { } } + private mapRowToVisaRequest(row: VisaRequestRow): VisaRequest { + return { + id: row.EVENT_ID, + name: row.EVENT_NAME, + url: row.EVENT_URL ?? '', + location: this.formatLocation(row.EVENT_LOCATION, row.EVENT_CITY, row.EVENT_COUNTRY), + applicationDate: row.APPLICATION_DATE + ? new Date(row.APPLICATION_DATE).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : '—', + status: row.REQUEST_STATUS, + }; + } + private mapRowToFoundationEvent(row: EventRow): FoundationEvent { return { id: row.EVENT_ID, diff --git a/packages/shared/src/constants/events.constants.ts b/packages/shared/src/constants/events.constants.ts index da92610e1..73a5356e4 100644 --- a/packages/shared/src/constants/events.constants.ts +++ b/packages/shared/src/constants/events.constants.ts @@ -1,12 +1,16 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -<<<<<<< Updated upstream import { FoundationEventStatus } from '../enums'; -import { FilterOption, MyEventsResponse, EventsResponse, MyEventOrganizationsResponse, TagSeverity } from '../interfaces'; -======= -import { FilterOption, MyEventsResponse, EventsResponse, MyEventOrganizationsResponse, VisaRequestsResponse } from '../interfaces'; ->>>>>>> Stashed changes +import { + FilterOption, + MyEventsResponse, + EventsResponse, + MyEventOrganizationsResponse, + TagSeverity, + TravelFundRequestsResponse, + VisaRequestsResponse, +} from '../interfaces'; export const EVENT_ROLE_OPTIONS: FilterOption[] = [ { label: 'All Roles', value: null }, @@ -23,6 +27,14 @@ export const MY_EVENT_STATUS_OPTIONS: FilterOption[] = [ { label: 'Not Registered', value: 'not-registered' }, ]; +export const VISA_REQUEST_STATUS_OPTIONS: FilterOption[] = [ + { label: 'All Statuses', value: null }, + { label: 'Submitted', value: 'Submitted' }, + { label: 'Approved', value: 'Approved' }, + { label: 'Denied', value: 'Denied' }, + { label: 'Expired', value: 'Expired' }, +]; + /** * Status filter options for Foundation Lens events. * Values are raw EVENT_STATUS DB values except 'coming-soon', which is a sentinel @@ -48,6 +60,8 @@ export const FOUNDATION_EVENT_STATUS_SEVERITY_MAP: Partial = new Set(['EVENT_NAME', 'PROJECT_NAME', 'EVENT_START_DATE', 'EVENT_CITY']); +export const VALID_VISA_REQUEST_SORT_FIELDS: ReadonlySet = new Set(['EVENT_NAME', 'EVENT_CITY', 'APPLICATION_DATE']); +export const DEFAULT_VISA_REQUEST_SORT_FIELD = 'APPLICATION_DATE'; export const DEFAULT_EVENT_SORT_FIELD = 'EVENT_START_DATE'; export const VALID_EVENT_SORT_ORDERS: readonly string[] = ['ASC', 'DESC']; export const DEFAULT_EVENTS_PAGE_SIZE = 10; @@ -57,3 +71,4 @@ export const EMPTY_MY_EVENTS_RESPONSE: MyEventsResponse = { data: [], total: 0, export const EMPTY_EVENTS_RESPONSE: EventsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; export const EMPTY_ORGANIZATIONS_RESPONSE: MyEventOrganizationsResponse = { data: [] }; export const EMPTY_VISA_REQUESTS_RESPONSE: VisaRequestsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; +export const EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE: TravelFundRequestsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; diff --git a/packages/shared/src/interfaces/my-event.interface.ts b/packages/shared/src/interfaces/my-event.interface.ts index e68855966..3c834521b 100644 --- a/packages/shared/src/interfaces/my-event.interface.ts +++ b/packages/shared/src/interfaces/my-event.interface.ts @@ -177,6 +177,18 @@ export interface EventTab { countKey?: 'upcoming' | 'past'; } +/** + * Parameters for fetching event requests (visa letters or travel fund) from the API + */ +export interface GetEventRequestsParams { + searchQuery?: string; + status?: string; + sortField?: string; + pageSize?: number; + offset?: number; + sortOrder?: 'ASC' | 'DESC'; +} + /** * Parameters for fetching my events from the API */ @@ -252,6 +264,23 @@ export interface CertificateData { userName: string; } +/** + * Raw row returned from ANALYTICS.PLATINUM_LFX_ONE.EVENT_REGISTRATIONS for visa letter requests + */ +export interface VisaRequestRow { + EVENT_ID: string; + EVENT_NAME: string; + EVENT_URL: string | null; + EVENT_LOCATION: string | null; + EVENT_CITY: string | null; + EVENT_COUNTRY: string | null; + /** Date the visa letter was applied for */ + APPLICATION_DATE: Date | string | null; + /** Visa letter request status (e.g. "Pending", "Approved", "Denied") */ + REQUEST_STATUS: string; + TOTAL_RECORDS: number; +} + /** * Visa letter request item for the My Events visa-letters tab */ @@ -275,11 +304,35 @@ export interface VisaRequest { */ export type VisaRequestsResponse = OffsetPaginatedResponse; +/** + * Travel fund request item — identical shape to VisaRequest (event name, location, application date, status) + */ +export type TravelFundRequest = VisaRequest; + +/** + * Paginated API response for travel fund requests + */ +export type TravelFundRequestsResponse = OffsetPaginatedResponse; + /** * Valid sort order values for event queries */ export type EventSortOrder = 'ASC' | 'DESC'; +/** + * Server-side options for fetching event requests (visa letters or travel fund) — required pagination/sort fields + */ +export interface GetEventRequestsOptions { + eventId?: string; + projectName?: string; + searchQuery?: string; + status?: string; + sortField?: string; + pageSize: number; + offset: number; + sortOrder: EventSortOrder; +} + /** * Server-side options for fetching user events (required pagination/sort fields) */ From b9bb9da3621b782543ab9eeb57e518880a2cfe73 Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Fri, 10 Apr 2026 16:47:18 +0800 Subject: [PATCH 03/12] chore: partial work on the travel funding application dialog Signed-off-by: Efren Lim --- .../event-selection.component.html | 105 ++++++++++++ .../event-selection.component.scss | 35 ++++ .../event-selection.component.ts | 150 ++++++++++++++++++ ...vel-fund-application-dialog.component.html | 51 ++++++ ...vel-fund-application-dialog.component.scss | 4 + ...ravel-fund-application-dialog.component.ts | 51 ++++++ .../travel-funding.component.html | 8 +- .../travel-funding.component.ts | 16 +- .../src/app/shared/services/events.service.ts | 9 ++ .../server/controllers/events.controller.ts | 107 ++++++++----- .../visa-letter-manual/template.html | 8 + .../lfx-one/src/server/routes/events.route.ts | 1 + .../src/server/services/events.service.ts | 58 ++++++- .../src/interfaces/my-event.interface.ts | 25 +++ 14 files changed, 584 insertions(+), 44 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html new file mode 100644 index 000000000..e57101122 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html @@ -0,0 +1,105 @@ + + + + + +
+ +
+ + + + + + + + + + + +
+ + +
+ @if (loading()) { +
+ +
+ } @else if (allEvents().length === 0) { +
+ +

No events found

+

Try adjusting your filters

+
+ } @else { +
+ @for (event of allEvents(); track event.id) { +
+ +

+ {{ event.name }} +

+ +
+ + {{ event.date }} +
+ +
+ + {{ event.location }} +
+ + @if (event.isRegistered && selectedEvent()?.id === event.id) { + + } @else if (event.isRegistered) { + + } @else { + + } +
+ } +
+ + + @if (hasMore()) { +
+ +
+ } + } +
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss new file mode 100644 index 000000000..3f98080b3 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss @@ -0,0 +1,35 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +.events-scroll-area { + max-height: 340px; + scrollbar-width: thin; + scrollbar-color: #e2e8f0 transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: #e2e8f0; + border-radius: 3px; + } +} + +.event-card { + cursor: default; + + &.registered { + cursor: pointer; + } + + &.selected { + @apply border-blue-500; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts new file mode 100644 index 000000000..e77b91118 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts @@ -0,0 +1,150 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, model, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { EventsService } from '@app/shared/services/events.service'; +import { ButtonComponent } from '@components/button/button.component'; +import { SelectComponent } from '@components/select/select.component'; +import { EMPTY_MY_EVENTS_RESPONSE } from '@lfx-one/shared/constants'; +import { MyEvent } from '@lfx-one/shared/interfaces'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; +import { catchError, debounceTime, finalize, of, skip, switchMap, tap } from 'rxjs'; + +type TimeFilterValue = 'any' | 'this-month' | 'next-3-months'; + +const PAGE_SIZE = 12; + +@Component({ + selector: 'lfx-event-selection', + imports: [ButtonComponent, ReactiveFormsModule, SelectComponent, IconFieldModule, InputIconModule, InputTextModule], + templateUrl: './event-selection.component.html', + styleUrl: './event-selection.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventSelectionComponent { + private readonly eventsService = inject(EventsService); + private readonly destroyRef = inject(DestroyRef); + private readonly fb = inject(NonNullableFormBuilder); + + public selectedEvent = model(null); + + // Search filter (plain signal — debounced before sending to API) + public searchQuery = signal(''); + + // Time and location filters via reactive form (required by lfx-select) + public readonly filtersForm = this.fb.group({ + timeFilter: 'any' as TimeFilterValue, + locationFilter: 'any' as string, + }); + + private readonly filtersValue = toSignal(this.filtersForm.valueChanges, { + initialValue: this.filtersForm.getRawValue(), + }); + + // Events & pagination + public loading = signal(true); + public loadingMore = signal(false); + public allEvents = signal([]); + public totalFromServer = signal(0); + public availableLocations = signal<{ label: string; value: string }[]>([{ label: 'Any Where', value: 'any' }]); + private currentOffset = 0; + + public hasMore = computed(() => this.allEvents().length < this.totalFromServer()); + + public readonly timeFilterOptions: { label: string; value: TimeFilterValue }[] = [ + { label: 'Any Time', value: 'any' }, + { label: 'This Month', value: 'this-month' }, + { label: 'Next 3 Months', value: 'next-3-months' }, + ]; + + // Debounced search to avoid API calls on every keystroke + private readonly debouncedSearch = toSignal(toObservable(this.searchQuery).pipe(debounceTime(500)), { initialValue: '' }); + + // Combined server-side filter params — changing this resets pagination and triggers a reload + private readonly activeFilters = computed(() => ({ + searchQuery: this.debouncedSearch() || undefined, + ...this.computeTimeFilterParams(this.filtersValue().timeFilter as TimeFilterValue), + country: this.filtersValue().locationFilter !== 'any' ? (this.filtersValue().locationFilter ?? undefined) : undefined, + })); + + public constructor() { + // Reset and reload when filters change (skip initial emission) + toObservable(this.activeFilters) + .pipe(skip(1), debounceTime(0), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.loadInitialEvents()); + + this.loadInitialEvents(); + this.loadCountries(); + } + + public onSelectEvent(event: MyEvent): void { + this.selectedEvent.set(event); + } + + public onLoadMore(): void { + this.currentOffset += PAGE_SIZE; + this.loadingMore.set(true); + + this.eventsService + .getMyEvents({ isPast: false, pageSize: PAGE_SIZE, offset: this.currentOffset, registeredFirst: true, ...this.activeFilters() }) + .pipe( + catchError(() => of(EMPTY_MY_EVENTS_RESPONSE)), + finalize(() => this.loadingMore.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((response) => { + this.allEvents.update((current) => [...current, ...response.data]); + this.totalFromServer.set(response.total); + }); + } + + private loadInitialEvents(): void { + this.loading.set(true); + this.currentOffset = 0; + + this.eventsService + .getMyEvents({ isPast: false, pageSize: PAGE_SIZE, offset: 0, registeredFirst: true, ...this.activeFilters() }) + .pipe( + catchError(() => of(EMPTY_MY_EVENTS_RESPONSE)), + finalize(() => this.loading.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((response) => { + this.allEvents.set(response.data); + this.totalFromServer.set(response.total); + }); + } + + private loadCountries(): void { + this.eventsService + .getUpcomingCountries() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((response) => { + this.availableLocations.set([{ label: 'Any Where', value: 'any' }, ...response.data.map((c) => ({ label: c, value: c }))]); + }); + } + + private computeTimeFilterParams(timeFilter: TimeFilterValue): { startDateFrom?: string; startDateTo?: string } { + if (timeFilter === 'this-month') { + const now = new Date(); + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); + const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); + return { startDateFrom: firstDay.toISOString(), startDateTo: lastDay.toISOString() }; + } + + if (timeFilter === 'next-3-months') { + const now = new Date(); + now.setHours(0, 0, 0, 0); + const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate(), 23, 59, 59, 999); + return { startDateFrom: now.toISOString(), startDateTo: threeMonthsLater.toISOString() }; + } + + return {}; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html new file mode 100644 index 000000000..5dbfb153a --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html @@ -0,0 +1,51 @@ + + + + + +
+ +
+ @for (s of steps; track s.id; let last = $last) { +
+
+ +
+ {{ s.number }} +
+ + + {{ s.label }} + +
+ + @if (!last) { +
+ } +
+ } +
+ + + @if (step() === 'select-event') { + + } + + +
+ + +
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss new file mode 100644 index 000000000..435bd6f52 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss @@ -0,0 +1,4 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts new file mode 100644 index 000000000..d3a665577 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts @@ -0,0 +1,51 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { ButtonComponent } from '@components/button/button.component'; +import { MyEvent } from '@lfx-one/shared/interfaces'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { EventSelectionComponent } from '../event-selection/event-selection.component'; + +export type TravelFundStep = 'select-event' | 'terms' | 'about-me' | 'expenses'; + +const STEP_ORDER: TravelFundStep[] = ['select-event', 'terms', 'about-me', 'expenses']; + +@Component({ + selector: 'lfx-travel-fund-application-dialog', + imports: [ButtonComponent, EventSelectionComponent], + templateUrl: './travel-fund-application-dialog.component.html', + styleUrl: './travel-fund-application-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TravelFundApplicationDialogComponent { + private readonly ref = inject(DynamicDialogRef); + + public step = signal('select-event'); + public selectedEvent = signal(null); + + public readonly steps: { id: TravelFundStep; label: string; number: number }[] = [ + { id: 'select-event', label: 'Choose an Event', number: 1 }, + { id: 'terms', label: 'Terms and Conditions', number: 2 }, + { id: 'about-me', label: 'About Me', number: 3 }, + { id: 'expenses', label: 'Expenses', number: 4 }, + ]; + + public onNextStep(): void { + // Future steps will be wired up here + } + + public onCancel(): void { + this.ref.close(null); + } + + public isStepActive(stepId: TravelFundStep): boolean { + return this.step() === stepId; + } + + public isStepCompleted(stepId: TravelFundStep): boolean { + return STEP_ORDER.indexOf(stepId) < STEP_ORDER.indexOf(this.step()); + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.html index aaa1290dc..27933fbff 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.html @@ -2,7 +2,13 @@
- +
(''); public readonly status = input(null); @@ -60,6 +64,16 @@ export class TravelFundingComponent { }); } + public openApplicationDialog(): void { + this.dialogService.open(TravelFundApplicationDialogComponent, { + header: 'Travel Funding Application', + width: '800px', + modal: true, + closable: true, + closeOnEscape: true, + }); + } + protected onPageChange(event: { first: number; rows: number }): void { this.loading.set(true); this.page.set({ offset: event.first, pageSize: event.rows }); diff --git a/apps/lfx-one/src/app/shared/services/events.service.ts b/apps/lfx-one/src/app/shared/services/events.service.ts index cc4e148f8..70e492998 100644 --- a/apps/lfx-one/src/app/shared/services/events.service.ts +++ b/apps/lfx-one/src/app/shared/services/events.service.ts @@ -13,6 +13,7 @@ import { GetEventRequestsParams, GetEventsParams, GetMyEventsParams, + GetUpcomingCountriesResponse, MyEventOrganizationsResponse, MyEventsResponse, TravelFundRequestsResponse, @@ -39,6 +40,10 @@ export class EventsService { if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize)); if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset)); if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder); + if (params.registeredFirst) httpParams = httpParams.set('registeredFirst', 'true'); + if (params.startDateFrom) httpParams = httpParams.set('startDateFrom', params.startDateFrom); + if (params.startDateTo) httpParams = httpParams.set('startDateTo', params.startDateTo); + if (params.country) httpParams = httpParams.set('country', params.country); return this.http.get('/api/events', { params: httpParams }); } @@ -102,6 +107,10 @@ export class EventsService { .pipe(catchError(() => of(EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE))); } + public getUpcomingCountries(): Observable { + return this.http.get('/api/events/countries').pipe(catchError(() => of({ data: [] as string[] }))); + } + public getCertificate(params: GetCertificateParams): Observable { let httpParams = new HttpParams(); diff --git a/apps/lfx-one/src/server/controllers/events.controller.ts b/apps/lfx-one/src/server/controllers/events.controller.ts index 064f46780..f9953c240 100644 --- a/apps/lfx-one/src/server/controllers/events.controller.ts +++ b/apps/lfx-one/src/server/controllers/events.controller.ts @@ -21,6 +21,7 @@ import { GetEventOrganizationsOptions, GetEventRequestsOptions, GetEventsOptions, + GetUpcomingCountriesResponse, VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; import { EventsService } from '../services/events.service'; @@ -59,6 +60,10 @@ export class EventsController { const rawMyEventStatus = req.query['status'] ? String(req.query['status']) : undefined; const status = rawMyEventStatus && VALID_MY_EVENT_STATUS_VALUES.has(rawMyEventStatus) ? rawMyEventStatus : undefined; const sortField = req.query['sortField'] ? String(req.query['sortField']) : undefined; + const registeredFirst = req.query['registeredFirst'] === 'true'; + const startDateFrom = req.query['startDateFrom'] ? String(req.query['startDateFrom']) : undefined; + const startDateTo = req.query['startDateTo'] ? String(req.query['startDateTo']) : undefined; + const country = req.query['country'] ? String(req.query['country']) : undefined; const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 && rawPageSize <= MAX_EVENTS_PAGE_SIZE ? rawPageSize : DEFAULT_EVENTS_PAGE_SIZE; const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0; @@ -81,6 +86,10 @@ export class EventsController { pageSize, offset, sortOrder, + registeredFirst, + startDateFrom, + startDateTo, + country, }); logger.success(req, 'get_my_events', startTime, { @@ -224,49 +233,17 @@ export class EventsController { ); } - private async handleEventRequestsEndpoint( - req: Request, - res: Response, - next: NextFunction, - operationName: string, - fetchFn: (req: Request, userEmail: string, options: GetEventRequestsOptions) => Promise - ): Promise { - const startTime = logger.startOperation(req, operationName, { - has_query: Object.keys(req.query).length > 0, - }); + /** + * GET /api/events/countries + * Get distinct country names for upcoming events (for the location filter dropdown) + */ + public async getUpcomingCountries(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'get_upcoming_countries', {}); try { - const userEmail = (req.oidc?.user?.['email'] as string)?.toLowerCase(); - - if (!userEmail) { - throw new AuthenticationError('User authentication required', { operation: operationName }); - } - - const rawPageSize = parseInt(String(req.query['pageSize'] ?? DEFAULT_EVENTS_PAGE_SIZE), 10); - const rawOffset = parseInt(String(req.query['offset'] ?? 0), 10); - const rawSortOrder = String(req.query['sortOrder'] ?? 'DESC').toUpperCase() as EventSortOrder; - - const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 && rawPageSize <= MAX_EVENTS_PAGE_SIZE ? rawPageSize : DEFAULT_EVENTS_PAGE_SIZE; - const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0; - const sortOrder: EventSortOrder = VALID_EVENT_SORT_ORDERS.includes(rawSortOrder) ? rawSortOrder : 'DESC'; + const response: GetUpcomingCountriesResponse = await this.eventsService.getUpcomingCountries(req); - const options: GetEventRequestsOptions = { - eventId: req.query['eventId'] ? String(req.query['eventId']) : undefined, - projectName: req.query['projectName'] ? String(req.query['projectName']) : undefined, - searchQuery: req.query['searchQuery'] ? String(req.query['searchQuery']).trim() : undefined, - status: req.query['status'] ? String(req.query['status']) : undefined, - sortField: req.query['sortField'] ? String(req.query['sortField']) : undefined, - pageSize, - offset, - sortOrder, - }; - - const response = await fetchFn(req, userEmail, options); - - logger.success(req, operationName, startTime, { - result_count: response.data.length, - total: response.total, - }); + logger.success(req, 'get_upcoming_countries', startTime, { result_count: response.data.length }); res.json(response); } catch (error) { @@ -321,4 +298,54 @@ export class EventsController { next(error); } } + + private async handleEventRequestsEndpoint( + req: Request, + res: Response, + next: NextFunction, + operationName: string, + fetchFn: (req: Request, userEmail: string, options: GetEventRequestsOptions) => Promise + ): Promise { + const startTime = logger.startOperation(req, operationName, { + has_query: Object.keys(req.query).length > 0, + }); + + try { + const userEmail = (req.oidc?.user?.['email'] as string)?.toLowerCase(); + + if (!userEmail) { + throw new AuthenticationError('User authentication required', { operation: operationName }); + } + + const rawPageSize = parseInt(String(req.query['pageSize'] ?? DEFAULT_EVENTS_PAGE_SIZE), 10); + const rawOffset = parseInt(String(req.query['offset'] ?? 0), 10); + const rawSortOrder = String(req.query['sortOrder'] ?? 'DESC').toUpperCase() as EventSortOrder; + + const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 && rawPageSize <= MAX_EVENTS_PAGE_SIZE ? rawPageSize : DEFAULT_EVENTS_PAGE_SIZE; + const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0; + const sortOrder: EventSortOrder = VALID_EVENT_SORT_ORDERS.includes(rawSortOrder) ? rawSortOrder : 'DESC'; + + const options: GetEventRequestsOptions = { + eventId: req.query['eventId'] ? String(req.query['eventId']) : undefined, + projectName: req.query['projectName'] ? String(req.query['projectName']) : undefined, + searchQuery: req.query['searchQuery'] ? String(req.query['searchQuery']).trim() : undefined, + status: req.query['status'] ? String(req.query['status']) : undefined, + sortField: req.query['sortField'] ? String(req.query['sortField']) : undefined, + pageSize, + offset, + sortOrder, + }; + + const response = await fetchFn(req, userEmail, options); + + logger.success(req, operationName, startTime, { + result_count: response.data.length, + total: response.total, + }); + + res.json(response); + } catch (error) { + next(error); + } + } } diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html index 26c72973c..b1cea563b 100644 --- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html +++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html @@ -107,6 +107,14 @@ + + + + + + + + }}{{ event.state }}, {{/if}} {{#if event.country }}{{ event.country }}{{/if}} diff --git a/apps/lfx-one/src/server/routes/events.route.ts b/apps/lfx-one/src/server/routes/events.route.ts index 893ec9c91..1238063d1 100644 --- a/apps/lfx-one/src/server/routes/events.route.ts +++ b/apps/lfx-one/src/server/routes/events.route.ts @@ -11,6 +11,7 @@ const eventsController = new EventsController(); router.get('/', (req, res, next) => eventsController.getMyEvents(req, res, next)); router.get('/all', (req, res, next) => eventsController.getEvents(req, res, next)); router.get('/organizations', (req, res, next) => eventsController.getEventOrganizations(req, res, next)); +router.get('/countries', (req, res, next) => eventsController.getUpcomingCountries(req, res, next)); router.get('/visa-requests', (req, res, next) => eventsController.getVisaRequests(req, res, next)); router.get('/travel-fund-requests', (req, res, next) => eventsController.getTravelFundRequests(req, res, next)); router.get('/certificate', (req, res, next) => eventsController.getCertificate(req, res, next)); diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts index d3f53da03..51a6b9ab7 100644 --- a/apps/lfx-one/src/server/services/events.service.ts +++ b/apps/lfx-one/src/server/services/events.service.ts @@ -19,6 +19,7 @@ import { GetEventRequestsOptions, GetEventsOptions, GetMyEventsOptions, + GetUpcomingCountriesResponse, MyEvent, MyEventOrganizationsResponse, MyEventRow, @@ -41,7 +42,22 @@ export class EventsService { } public async getMyEvents(req: Request, userEmail: string, options: GetMyEventsOptions): Promise { - const { isPast, eventId, projectName, searchQuery, role, status, sortField: rawSortField, pageSize, offset, sortOrder } = options; + const { + isPast, + eventId, + projectName, + searchQuery, + role, + status, + sortField: rawSortField, + pageSize, + offset, + sortOrder, + registeredFirst, + startDateFrom, + startDateTo, + country, + } = options; const sortField = rawSortField && VALID_EVENT_SORT_FIELDS.has(rawSortField) ? rawSortField : DEFAULT_EVENT_SORT_FIELD; const normalizedSortOrder: EventSortOrder = sortOrder === 'DESC' ? 'DESC' : 'ASC'; const normalizedPageSize = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : 10; @@ -69,6 +85,9 @@ export class EventsService { const searchQueryFilter = searchQuery ? 'AND e.EVENT_NAME ILIKE ?' : ''; const roleFilterResult = role ? this.buildUpcomingRoleFilter(role) : { filter: '', binds: [] as string[] }; const statusFilterResult = status ? this.buildUpcomingStatusFilter(status) : { filter: '', binds: [] as string[] }; + const startDateFromFilter = startDateFrom ? 'AND e.EVENT_START_DATE >= ?' : ''; + const startDateToFilter = startDateTo ? 'AND e.EVENT_START_DATE <= ?' : ''; + const countryFilter = country ? 'AND e.EVENT_COUNTRY = ?' : ''; sql = ` WITH all_upcoming AS ( @@ -140,7 +159,10 @@ export class EventsService { ${searchQueryFilter} ${roleFilterResult.filter} ${statusFilterResult.filter} - ORDER BY ${sortField} ${normalizedSortOrder} + ${startDateFromFilter} + ${startDateToFilter} + ${countryFilter} + ORDER BY ${registeredFirst ? 'IS_REGISTERED DESC, ' : ''}${sortField} ${normalizedSortOrder} LIMIT ${normalizedPageSize} OFFSET ${normalizedOffset} `; @@ -151,6 +173,9 @@ export class EventsService { if (searchQuery) binds.push(`%${searchQuery}%`); binds.push(...roleFilterResult.binds); binds.push(...statusFilterResult.binds); + if (startDateFrom) binds.push(startDateFrom); + if (startDateTo) binds.push(startDateTo); + if (country) binds.push(country); } else { // Past tab (or no isPast filter): show only the user's own registered events. const isPastFilter = isPast !== undefined ? `AND IS_PAST_EVENT = ${isPast ? 'TRUE' : 'FALSE'}` : ''; @@ -384,6 +409,34 @@ export class EventsService { return { data }; } + public async getUpcomingCountries(req: Request): Promise { + logger.debug(req, 'get_upcoming_countries', 'Fetching distinct countries for upcoming events'); + + const sql = ` + SELECT DISTINCT EVENT_COUNTRY + FROM ANALYTICS.PLATINUM_LFX_ONE.EVENT_REGISTRATIONS + WHERE IS_PAST_EVENT = FALSE + AND EVENT_COUNTRY IS NOT NULL + ORDER BY EVENT_COUNTRY + `; + + let result; + try { + result = await this.snowflakeService.execute<{ EVENT_COUNTRY: string }>(sql, []); + } catch (error) { + logger.warning(req, 'get_upcoming_countries', 'Snowflake query failed, returning empty countries', { + error: error instanceof Error ? error.message : String(error), + }); + return { data: [] }; + } + + const data = result.rows.map((row) => row.EVENT_COUNTRY); + + logger.debug(req, 'get_upcoming_countries', 'Fetched countries', { count: data.length }); + + return { data }; + } + public async getVisaRequests(req: Request, userEmail: string, options: GetEventRequestsOptions): Promise { return this.executeEventRequestsQuery(req, userEmail, options, 'VL_REQUEST_STATUS', 'VL_APPLICATION_DATE', 'get_visa_requests'); } @@ -583,6 +636,7 @@ export class EventsService { url: row.EVENT_URL ?? '', registrationUrl: row.EVENT_REGISTRATION_URL ?? null, foundation: row.PROJECT_NAME, + startDate: new Date(row.EVENT_START_DATE).toISOString(), date: this.formatDateRange(row.EVENT_START_DATE, row.EVENT_END_DATE), location: this.formatLocation(row.EVENT_LOCATION, row.EVENT_CITY, row.EVENT_COUNTRY), role: row.USER_ROLE ?? '', diff --git a/packages/shared/src/interfaces/my-event.interface.ts b/packages/shared/src/interfaces/my-event.interface.ts index 3c834521b..82a4e405f 100644 --- a/packages/shared/src/interfaces/my-event.interface.ts +++ b/packages/shared/src/interfaces/my-event.interface.ts @@ -25,6 +25,8 @@ export interface MyEvent { registrationUrl: string | null; /** Foundation short name (e.g. "CNCF", "OpenSSF") */ foundation: string; + /** ISO 8601 event start date string (e.g. "2026-11-10T00:00:00.000Z") — used for date-range filtering */ + startDate: string; /** Human-readable date string (e.g. "Nov 10–13, 2026") */ date: string; /** Human-readable location string (e.g. "Salt Lake City, UT") */ @@ -203,6 +205,14 @@ export interface GetMyEventsParams { pageSize?: number; offset?: number; sortOrder?: 'ASC' | 'DESC'; + /** When true, registered events are sorted before unregistered events */ + registeredFirst?: boolean; + /** ISO 8601 date string — include only events starting on or after this date */ + startDateFrom?: string; + /** ISO 8601 date string — include only events starting on or before this date */ + startDateTo?: string; + /** Filter events by country (e.g. "United States") */ + country?: string; } /** @@ -347,6 +357,21 @@ export interface GetMyEventsOptions { pageSize: number; offset: number; sortOrder: EventSortOrder; + /** When true, registered events are sorted before unregistered events */ + registeredFirst?: boolean; + /** ISO 8601 date string — include only events starting on or after this date */ + startDateFrom?: string; + /** ISO 8601 date string — include only events starting on or before this date */ + startDateTo?: string; + /** Filter events by country (e.g. "United States") */ + country?: string; +} + +/** + * Response for distinct event countries + */ +export interface GetUpcomingCountriesResponse { + data: string[]; } /** From c361957cccffae4df24d9606007bb8bc50ce5f53 Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Fri, 10 Apr 2026 17:49:31 +0800 Subject: [PATCH 04/12] chore: added all the steps ui for the travel fund application Signed-off-by: Efren Lim --- .../about-me-form.component.html | 155 ++++++++++++++++++ .../about-me-form.component.scss | 2 + .../about-me-form/about-me-form.component.ts | 70 ++++++++ .../event-selection.component.html | 2 - .../event-selection.component.scss | 11 +- .../event-selection.component.ts | 2 - .../travel-expenses-form.component.html | 95 +++++++++++ .../travel-expenses-form.component.scss | 2 + .../travel-expenses-form.component.ts | 47 ++++++ ...vel-fund-application-dialog.component.html | 44 ++++- ...vel-fund-application-dialog.component.scss | 18 +- ...ravel-fund-application-dialog.component.ts | 32 +++- .../travel-fund-terms.component.html | 45 +++++ .../travel-fund-terms.component.scss | 2 + .../travel-fund-terms.component.ts | 12 ++ .../travel-funding.component.ts | 2 - .../visa-letter-manual/template.html | 4 + 17 files changed, 519 insertions(+), 26 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.ts diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html new file mode 100644 index 000000000..8e2e987ed --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html @@ -0,0 +1,155 @@ + + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ +
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.scss new file mode 100644 index 000000000..c32094b45 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.scss @@ -0,0 +1,2 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts new file mode 100644 index 000000000..1738ad2eb --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts @@ -0,0 +1,70 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { UserService } from '@app/shared/services/user.service'; +import { CheckboxComponent } from '@components/checkbox/checkbox.component'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { SelectComponent } from '@components/select/select.component'; +import { TextareaComponent } from '@components/textarea/textarea.component'; +import { COUNTRIES } from '@lfx-one/shared/constants'; + +const YES_NO_OPTIONS = [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, +]; + +@Component({ + selector: 'lfx-about-me-form', + imports: [ReactiveFormsModule, InputTextComponent, SelectComponent, TextareaComponent, CheckboxComponent], + templateUrl: './about-me-form.component.html', + styleUrl: './about-me-form.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AboutMeFormComponent { + private readonly userService = inject(UserService); + private readonly fb = inject(NonNullableFormBuilder); + + public readonly formValidityChange = output(); + + public readonly form = this.fb.group({ + firstName: ['', Validators.required], + lastName: ['', Validators.required], + email: [''], + citizenshipCountry: ['', Validators.required], + profileLink: ['', Validators.required], + company: ['', Validators.required], + canReceiveFunds: ['', Validators.required], + travelFromCountry: ['', Validators.required], + openSourceInvolvement: ['', Validators.required], + isLgbtqia: [false], + isWoman: [false], + isPersonWithDisability: [false], + isDiversityOther: [false], + preferNotToAnswer: [false], + attendingForCompany: ['', Validators.required], + willingToBlog: ['', Validators.required], + }); + + public readonly countryOptions = [...COUNTRIES]; + public readonly yesNoOptions = YES_NO_OPTIONS; + + public constructor() { + this.form.get('email')?.disable(); + + const user = this.userService.user(); + if (user) { + this.form.patchValue({ + firstName: user.given_name ?? '', + lastName: user.family_name ?? '', + email: user.email ?? '', + }); + } + + this.form.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.formValidityChange.emit(this.form.valid); + }); + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html index e57101122..9f0506788 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html @@ -1,8 +1,6 @@ - -
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss index 3f98080b3..d452c055e 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss @@ -1,24 +1,21 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -// Generated with [Claude Code](https://claude.ai/code) - .events-scroll-area { - max-height: 340px; + @apply max-h-[340px]; scrollbar-width: thin; scrollbar-color: #e2e8f0 transparent; &::-webkit-scrollbar { - width: 6px; + @apply w-1.5; } &::-webkit-scrollbar-track { - background: transparent; + @apply bg-transparent; } &::-webkit-scrollbar-thumb { - background-color: #e2e8f0; - border-radius: 3px; + @apply rounded-sm bg-slate-200; } } diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts index e77b91118..6512d21c0 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts @@ -1,8 +1,6 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -// Generated with [Claude Code](https://claude.ai/code) - import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, model, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.html new file mode 100644 index 000000000..e2d83845c --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.html @@ -0,0 +1,95 @@ + + + +
+ +
+

Estimated Travel Expenses

+

Provide your best estimates for each category. All amounts in USD.

+
+ + +
+ +
+ Expense Category + Estimated Cost ($US) + Notes +
+ + +
+
+ + Airfare (coach) +
+
+ $ + +
+ +
+ + +
+
+ + Hotel accommodation +
+
+ $ + +
+ +
+ + +
+
+ + Ground transportation +
+
+ $ + +
+ +
+ + +
+ Estimated Total: + + {{ estimatedTotal() | currency: 'USD' : 'symbol' : '1.2-2' }} + +
+
+ + + + +

+ Reminder: + Lower cost requests are more likely to be approved. Funds may not be used for food, visa costs, non-airport transportation, or baggage fees. +

+
+
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.scss new file mode 100644 index 000000000..c32094b45 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.scss @@ -0,0 +1,2 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts new file mode 100644 index 000000000..1691116d3 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts @@ -0,0 +1,47 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CurrencyPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { MessageComponent } from '@components/message/message.component'; + +@Component({ + selector: 'lfx-travel-expenses-form', + imports: [ReactiveFormsModule, InputTextComponent, MessageComponent, CurrencyPipe], + templateUrl: './travel-expenses-form.component.html', + styleUrl: './travel-expenses-form.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TravelExpensesFormComponent { + private readonly fb = inject(NonNullableFormBuilder); + + public readonly formValidityChange = output(); + + public readonly form = this.fb.group({ + airfareCost: ['0'], + airfareNotes: [''], + hotelCost: ['0'], + hotelNotes: [''], + groundTransportCost: ['0'], + groundTransportNotes: [''], + }); + + private readonly formValues = toSignal(this.form.valueChanges, { initialValue: this.form.value }); + + public readonly estimatedTotal = computed(() => { + const v = this.formValues(); + return (parseFloat(v.airfareCost ?? '0') || 0) + (parseFloat(v.hotelCost ?? '0') || 0) + (parseFloat(v.groundTransportCost ?? '0') || 0); + }); + + public constructor() { + // No required fields — form is always valid + this.formValidityChange.emit(true); + + this.form.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.formValidityChange.emit(this.form.valid); + }); + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html index 5dbfb153a..778e3b1f9 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html @@ -1,8 +1,6 @@ - -
@@ -18,7 +16,11 @@ [class.border-gray-300]="!isStepActive(s.id) && !isStepCompleted(s.id)" [class.text-gray-400]="!isStepActive(s.id) && !isStepCompleted(s.id)" [attr.data-testid]="'travel-fund-step-circle-' + s.number"> - {{ s.number }} + @if (isStepCompleted(s.id)) { + + } @else { + {{ s.number }} + }
- @if (step() === 'select-event') { - - } +
+ @if (step() === 'select-event') { + + } + @if (step() === 'terms') { + + } + @if (step() === 'about-me') { + + } + @if (step() === 'expenses') { + + } +
- +
+ @if (step() !== 'select-event') { + + } + @if (step() === 'terms') { + + } @else if (step() === 'expenses') { + + } @else { + + } +
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss index 435bd6f52..6dabfd4ea 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss @@ -1,4 +1,20 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -// Generated with [Claude Code](https://claude.ai/code) +.scroll-area { + @apply max-h-[340px]; + scrollbar-width: thin; + scrollbar-color: #e2e8f0 transparent; + + &::-webkit-scrollbar { + @apply w-1.5; + } + + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply rounded-sm bg-slate-200; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts index d3a665577..521a3e23a 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts @@ -1,13 +1,14 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -// Generated with [Claude Code](https://claude.ai/code) - -import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { ButtonComponent } from '@components/button/button.component'; import { MyEvent } from '@lfx-one/shared/interfaces'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { EventSelectionComponent } from '../event-selection/event-selection.component'; +import { TravelFundTermsComponent } from '../travel-fund-terms/travel-fund-terms.component'; +import { AboutMeFormComponent } from '../about-me-form/about-me-form.component'; +import { TravelExpensesFormComponent } from '../travel-expenses-form/travel-expenses-form.component'; export type TravelFundStep = 'select-event' | 'terms' | 'about-me' | 'expenses'; @@ -15,7 +16,7 @@ const STEP_ORDER: TravelFundStep[] = ['select-event', 'terms', 'about-me', 'expe @Component({ selector: 'lfx-travel-fund-application-dialog', - imports: [ButtonComponent, EventSelectionComponent], + imports: [ButtonComponent, EventSelectionComponent, TravelFundTermsComponent, AboutMeFormComponent, TravelExpensesFormComponent], templateUrl: './travel-fund-application-dialog.component.html', styleUrl: './travel-fund-application-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -25,6 +26,13 @@ export class TravelFundApplicationDialogComponent { public step = signal('select-event'); public selectedEvent = signal(null); + public aboutMeFormValid = signal(false); + + public readonly isNextDisabled = computed(() => { + if (this.step() === 'select-event') return !this.selectedEvent(); + if (this.step() === 'about-me') return !this.aboutMeFormValid(); + return false; + }); public readonly steps: { id: TravelFundStep; label: string; number: number }[] = [ { id: 'select-event', label: 'Choose an Event', number: 1 }, @@ -34,7 +42,21 @@ export class TravelFundApplicationDialogComponent { ]; public onNextStep(): void { - // Future steps will be wired up here + const currentIndex = STEP_ORDER.indexOf(this.step()); + if (currentIndex < STEP_ORDER.length - 1) { + this.step.set(STEP_ORDER[currentIndex + 1]); + } + } + + public onPreviousStep(): void { + const currentIndex = STEP_ORDER.indexOf(this.step()); + if (currentIndex > 0) { + this.step.set(STEP_ORDER[currentIndex - 1]); + } + } + + public onSubmitApplication(): void { + this.ref.close({ submitted: true }); } public onCancel(): void { diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.html new file mode 100644 index 000000000..01457793f --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.html @@ -0,0 +1,45 @@ + + + +
+

The Linux Foundation Travel Fund

+ +

+ The Linux Foundation's Travel Fund is intended to enable open source developers and community members to attend events that they would otherwise be unable + to attend due to a lack of funding. We place an emphasis on funding applicants who are from historically underrepresented or untapped groups and/or those of + lower socioeconomic status. +

+ +
+

Travel fund assistance may only be used for:

+
    +
  • Coach airfare tickets
  • +
  • Accommodation for the dates of the event plus one night prior to the event start date
  • +
  • Ground transportation to/from the airport
  • +
+
+ +

+ Funds may not be used for miscellaneous travel expenses including food, visa costs, non-airport transportation, baggage fees, etc. +

+ +
+

Please note:

+
    +
  • We are often unable to fund one person across multiple events in a year
  • +
  • Lower cost travel funding requests are more likely to get approved
  • +
  • We typically do not fund the same person two years in a row
  • +
  • Decisions are final, and reapplying after a denial does not increase chances
  • +
+
+ +

+ Note: If approved, you will be required to show proof of attendance. Receipt of Travel Funding does not guarantee entry to the event. Recipients must adhere + to The Linux Foundation Health and Safety rules. +

+ +

+ I consent to the sharing and processing of my personal information, including sensitive data, by The Linux Foundation for the purposes of funds allocation, + compliance with sanction screening regulations, and communications related to the scholarship program. +

+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.scss new file mode 100644 index 000000000..c32094b45 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.scss @@ -0,0 +1,2 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.ts new file mode 100644 index 000000000..0395c9650 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.ts @@ -0,0 +1,12 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'lfx-travel-fund-terms', + templateUrl: './travel-fund-terms.component.html', + styleUrl: './travel-fund-terms.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TravelFundTermsComponent {} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts index be529d50b..57b16d69b 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts @@ -1,8 +1,6 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -// Generated with [Claude Code](https://claude.ai/code) - import { Component, computed, inject, input, Signal, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { EventsService } from '@app/shared/services/events.service'; diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html index b1cea563b..8ade83019 100644 --- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html +++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html @@ -111,6 +111,10 @@ + + + + From d5309270a4f6f13bbb12bda0444e301e392c6d7f Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Fri, 10 Apr 2026 18:06:52 +0800 Subject: [PATCH 05/12] feat: added stub travel fund application submission endpoint Signed-off-by: Efren Lim --- .../about-me-form/about-me-form.component.ts | 28 ++++++++++ .../travel-expenses-form.component.ts | 22 ++++++++ ...vel-fund-application-dialog.component.html | 13 +++-- ...ravel-fund-application-dialog.component.ts | 51 +++++++++++++++++-- .../src/app/shared/services/events.service.ts | 8 ++- .../server/controllers/events.controller.ts | 29 +++++++++++ .../visa-letter-manual/template.html | 2 + .../lfx-one/src/server/routes/events.route.ts | 1 + .../src/server/services/events.service.ts | 16 ++++++ packages/shared/src/interfaces/index.ts | 3 ++ .../travel-fund-application.interface.ts | 44 ++++++++++++++++ 11 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 packages/shared/src/interfaces/travel-fund-application.interface.ts diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts index 1738ad2eb..8bb819930 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts @@ -10,6 +10,7 @@ import { InputTextComponent } from '@components/input-text/input-text.component' import { SelectComponent } from '@components/select/select.component'; import { TextareaComponent } from '@components/textarea/textarea.component'; import { COUNTRIES } from '@lfx-one/shared/constants'; +import { TravelFundAboutMe } from '@lfx-one/shared/interfaces'; const YES_NO_OPTIONS = [ { label: 'Yes', value: 'yes' }, @@ -28,6 +29,7 @@ export class AboutMeFormComponent { private readonly fb = inject(NonNullableFormBuilder); public readonly formValidityChange = output(); + public readonly formChange = output(); public readonly form = this.fb.group({ firstName: ['', Validators.required], @@ -66,5 +68,31 @@ export class AboutMeFormComponent { this.form.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => { this.formValidityChange.emit(this.form.valid); }); + + this.form.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.formChange.emit(this.buildFormValue()); + }); + } + + private buildFormValue(): TravelFundAboutMe { + const raw = this.form.getRawValue(); + return { + firstName: raw.firstName, + lastName: raw.lastName, + email: raw.email, + citizenshipCountry: raw.citizenshipCountry, + profileLink: raw.profileLink, + company: raw.company, + canReceiveFunds: raw.canReceiveFunds, + travelFromCountry: raw.travelFromCountry, + openSourceInvolvement: raw.openSourceInvolvement, + isLgbtqia: raw.isLgbtqia, + isWoman: raw.isWoman, + isPersonWithDisability: raw.isPersonWithDisability, + isDiversityOther: raw.isDiversityOther, + preferNotToAnswer: raw.preferNotToAnswer, + attendingForCompany: raw.attendingForCompany, + willingToBlog: raw.willingToBlog, + }; } } diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts index 1691116d3..2c1079152 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts @@ -7,6 +7,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; import { InputTextComponent } from '@components/input-text/input-text.component'; import { MessageComponent } from '@components/message/message.component'; +import { TravelFundExpenses } from '@lfx-one/shared/interfaces'; @Component({ selector: 'lfx-travel-expenses-form', @@ -19,6 +20,7 @@ export class TravelExpensesFormComponent { private readonly fb = inject(NonNullableFormBuilder); public readonly formValidityChange = output(); + public readonly formChange = output(); public readonly form = this.fb.group({ airfareCost: ['0'], @@ -43,5 +45,25 @@ export class TravelExpensesFormComponent { this.form.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => { this.formValidityChange.emit(this.form.valid); }); + + this.form.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.formChange.emit(this.buildExpensesValue()); + }); + } + + private buildExpensesValue(): TravelFundExpenses { + const raw = this.form.getRawValue(); + const airfareCost = parseFloat(raw.airfareCost) || 0; + const hotelCost = parseFloat(raw.hotelCost) || 0; + const groundTransportCost = parseFloat(raw.groundTransportCost) || 0; + return { + airfareCost, + airfareNotes: raw.airfareNotes, + hotelCost, + hotelNotes: raw.hotelNotes, + groundTransportCost, + groundTransportNotes: raw.groundTransportNotes, + estimatedTotal: airfareCost + hotelCost + groundTransportCost, + }; } } diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html index 778e3b1f9..974845e35 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html @@ -49,10 +49,10 @@ } @if (step() === 'about-me') { - + } @if (step() === 'expenses') { - + }
@@ -66,7 +66,14 @@ @if (step() === 'terms') { } @else if (step() === 'expenses') { - + } @else { ('select-event'); public selectedEvent = signal(null); public aboutMeFormValid = signal(false); + public aboutMeData = signal(null); + public expensesData = signal(null); + public submitting = signal(false); public readonly isNextDisabled = computed(() => { if (this.step() === 'select-event') return !this.selectedEvent(); @@ -56,7 +65,43 @@ export class TravelFundApplicationDialogComponent { } public onSubmitApplication(): void { - this.ref.close({ submitted: true }); + const event = this.selectedEvent(); + const aboutMe = this.aboutMeData(); + + if (!event || !aboutMe) return; + + const payload: TravelFundApplication = { + eventId: event.id, + eventName: event.name, + termsAccepted: true, + aboutMe, + expenses: this.expensesData() ?? { + airfareCost: 0, + airfareNotes: '', + hotelCost: 0, + hotelNotes: '', + groundTransportCost: 0, + groundTransportNotes: '', + estimatedTotal: 0, + }, + }; + + this.submitting.set(true); + + this.eventsService + .submitTravelFundApplication(payload) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Your travel fund application has been submitted successfully.', + }); + this.ref.close({ submitted: true }); + }, + error: () => this.submitting.set(false), + }); } public onCancel(): void { diff --git a/apps/lfx-one/src/app/shared/services/events.service.ts b/apps/lfx-one/src/app/shared/services/events.service.ts index 70e492998..08151411f 100644 --- a/apps/lfx-one/src/app/shared/services/events.service.ts +++ b/apps/lfx-one/src/app/shared/services/events.service.ts @@ -16,10 +16,12 @@ import { GetUpcomingCountriesResponse, MyEventOrganizationsResponse, MyEventsResponse, + TravelFundApplication, + TravelFundApplicationResponse, TravelFundRequestsResponse, VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; -import { catchError, Observable, of } from 'rxjs'; +import { catchError, Observable, of, take } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -111,6 +113,10 @@ export class EventsService { return this.http.get('/api/events/countries').pipe(catchError(() => of({ data: [] as string[] }))); } + public submitTravelFundApplication(payload: TravelFundApplication): Observable { + return this.http.post('/api/events/travel-fund-applications', payload).pipe(take(1)); + } + public getCertificate(params: GetCertificateParams): Observable { let httpParams = new HttpParams(); diff --git a/apps/lfx-one/src/server/controllers/events.controller.ts b/apps/lfx-one/src/server/controllers/events.controller.ts index f9953c240..fe7ea2dfb 100644 --- a/apps/lfx-one/src/server/controllers/events.controller.ts +++ b/apps/lfx-one/src/server/controllers/events.controller.ts @@ -22,6 +22,7 @@ import { GetEventRequestsOptions, GetEventsOptions, GetUpcomingCountriesResponse, + TravelFundApplication, VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; import { EventsService } from '../services/events.service'; @@ -348,4 +349,32 @@ export class EventsController { next(error); } } + + /** + * POST /api/events/travel-fund-applications + * Submit a travel fund application + * TODO: Wire to upstream microservice once available. + */ + public async submitTravelFundApplication(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'submit_travel_fund_application', {}); + + try { + const payload = req.body as TravelFundApplication; + + if (!payload?.eventId) { + throw ServiceValidationError.forField('eventId', 'eventId is required', { operation: 'submit_travel_fund_application' }); + } + + if (!payload?.aboutMe) { + throw ServiceValidationError.forField('aboutMe', 'aboutMe is required', { operation: 'submit_travel_fund_application' }); + } + + const result = await this.eventsService.submitTravelFundApplication(req, payload); + logger.success(req, 'submit_travel_fund_application', startTime, { event_id: payload.eventId }); + res.json(result); + } catch (error) { + logger.error(req, 'submit_travel_fund_application', startTime, error, {}); + next(error); + } + } } diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html index 8ade83019..ac02be0ce 100644 --- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html +++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html @@ -117,6 +117,8 @@ + + diff --git a/apps/lfx-one/src/server/routes/events.route.ts b/apps/lfx-one/src/server/routes/events.route.ts index 1238063d1..e0d5c6a0b 100644 --- a/apps/lfx-one/src/server/routes/events.route.ts +++ b/apps/lfx-one/src/server/routes/events.route.ts @@ -14,5 +14,6 @@ router.get('/organizations', (req, res, next) => eventsController.getEventOrgani router.get('/countries', (req, res, next) => eventsController.getUpcomingCountries(req, res, next)); router.get('/visa-requests', (req, res, next) => eventsController.getVisaRequests(req, res, next)); router.get('/travel-fund-requests', (req, res, next) => eventsController.getTravelFundRequests(req, res, next)); +router.post('/travel-fund-applications', (req, res, next) => eventsController.submitTravelFundApplication(req, res, next)); router.get('/certificate', (req, res, next) => eventsController.getCertificate(req, res, next)); export default router; diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts index 51a6b9ab7..13c4d064d 100644 --- a/apps/lfx-one/src/server/services/events.service.ts +++ b/apps/lfx-one/src/server/services/events.service.ts @@ -24,6 +24,8 @@ import { MyEventOrganizationsResponse, MyEventRow, MyEventsResponse, + TravelFundApplication, + TravelFundApplicationResponse, TravelFundRequestsResponse, VisaRequest, VisaRequestRow, @@ -672,6 +674,20 @@ export class EventsService { return `${startStr} – ${endStr}`; } + /** + * Stub: Submit a travel fund application. + * TODO: Replace with upstream microservice call once the API is available. + */ + public async submitTravelFundApplication(req: Request, payload: TravelFundApplication): Promise { + logger.debug(req, 'submit_travel_fund_application', 'Received travel fund application', { + event_id: payload.eventId, + event_name: payload.eventName, + estimated_total: payload.expenses.estimatedTotal, + }); + + return { success: true, message: 'Your travel fund application has been submitted successfully.' }; + } + private formatLocation(location: string | null, city: string | null, country: string | null): string { if (city && country) return `${city}, ${country}`; if (location) return location; diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index bba80e268..a930ce6e2 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -106,3 +106,6 @@ export * from './persona-detection.interface'; // My Event interfaces export * from './my-event.interface'; + +// Travel fund application interfaces +export * from './travel-fund-application.interface'; diff --git a/packages/shared/src/interfaces/travel-fund-application.interface.ts b/packages/shared/src/interfaces/travel-fund-application.interface.ts new file mode 100644 index 000000000..1085326a8 --- /dev/null +++ b/packages/shared/src/interfaces/travel-fund-application.interface.ts @@ -0,0 +1,44 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +export interface TravelFundAboutMe { + firstName: string; + lastName: string; + email: string; + citizenshipCountry: string; + profileLink: string; + company: string; + canReceiveFunds: string; + travelFromCountry: string; + openSourceInvolvement: string; + isLgbtqia: boolean; + isWoman: boolean; + isPersonWithDisability: boolean; + isDiversityOther: boolean; + preferNotToAnswer: boolean; + attendingForCompany: string; + willingToBlog: string; +} + +export interface TravelFundExpenses { + airfareCost: number; + airfareNotes: string; + hotelCost: number; + hotelNotes: string; + groundTransportCost: number; + groundTransportNotes: string; + estimatedTotal: number; +} + +export interface TravelFundApplication { + eventId: string; + eventName: string; + termsAccepted: boolean; + aboutMe: TravelFundAboutMe; + expenses: TravelFundExpenses; +} + +export interface TravelFundApplicationResponse { + success: boolean; + message: string; +} From 0fc4478b4bff917df09512e60cf809ed916bf6f6 Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Fri, 10 Apr 2026 18:36:24 +0800 Subject: [PATCH 06/12] feat: added the ui for the visa request application dialog Signed-off-by: Efren Lim --- .../event-selection.component.html | 27 ++--- .../event-selection.component.ts | 4 +- ...vel-fund-application-dialog.component.scss | 2 +- ...-request-application-dialog.component.html | 85 ++++++++++++++ ...-request-application-dialog.component.scss | 20 ++++ ...sa-request-application-dialog.component.ts | 74 ++++++++++++ .../visa-request-apply-form.component.html | 108 ++++++++++++++++++ .../visa-request-apply-form.component.scss | 2 + .../visa-request-apply-form.component.ts | 78 +++++++++++++ .../visa-request-terms.component.html | 8 ++ .../visa-request-terms.component.scss | 2 + .../visa-request-terms.component.ts | 12 ++ .../visa-request/visa-request.component.html | 8 +- .../visa-request/visa-request.component.ts | 16 ++- .../src/app/shared/services/events.service.ts | 2 +- .../server/controllers/events.controller.ts | 4 +- .../visa-letter-manual/template.html | 6 + .../src/server/services/events.service.ts | 6 +- packages/shared/src/interfaces/index.ts | 3 + .../src/interfaces/my-event.interface.ts | 8 +- .../visa-request-application.interface.ts | 26 +++++ 21 files changed, 468 insertions(+), 33 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.ts create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.html create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.scss create mode 100644 apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.ts create mode 100644 packages/shared/src/interfaces/visa-request-application.interface.ts diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html index 9f0506788..0a8066b7b 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html @@ -33,17 +33,16 @@ } @else if (allEvents().length === 0) {
-

No events found

-

Try adjusting your filters

+

No registered events found

+

Register for an event to apply for a visa letter

} @else {
@for (event of allEvents(); track event.id) {

@@ -59,10 +58,10 @@ {{ event.location }}

- - @if (event.isRegistered && selectedEvent()?.id === event.id) { + + @if (selectedEvent()?.id === event.id) { - } @else if (event.isRegistered) { + } @else { - } @else { - }
} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts index 6512d21c0..760b82410 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts @@ -90,7 +90,7 @@ export class EventSelectionComponent { this.loadingMore.set(true); this.eventsService - .getMyEvents({ isPast: false, pageSize: PAGE_SIZE, offset: this.currentOffset, registeredFirst: true, ...this.activeFilters() }) + .getMyEvents({ isPast: false, pageSize: PAGE_SIZE, offset: this.currentOffset, registeredOnly: true, ...this.activeFilters() }) .pipe( catchError(() => of(EMPTY_MY_EVENTS_RESPONSE)), finalize(() => this.loadingMore.set(false)), @@ -107,7 +107,7 @@ export class EventSelectionComponent { this.currentOffset = 0; this.eventsService - .getMyEvents({ isPast: false, pageSize: PAGE_SIZE, offset: 0, registeredFirst: true, ...this.activeFilters() }) + .getMyEvents({ isPast: false, pageSize: PAGE_SIZE, offset: 0, registeredOnly: true, ...this.activeFilters() }) .pipe( catchError(() => of(EMPTY_MY_EVENTS_RESPONSE)), finalize(() => this.loading.set(false)), diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss index 6dabfd4ea..8b97b4963 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT .scroll-area { - @apply max-h-[340px]; + @apply max-h-[440px]; scrollbar-width: thin; scrollbar-color: #e2e8f0 transparent; diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html new file mode 100644 index 000000000..fd8eba202 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html @@ -0,0 +1,85 @@ + + + +
+ +
+ @for (s of steps; track s.id; let last = $last) { +
+
+ +
+ @if (isStepCompleted(s.id)) { + + } @else { + {{ s.number }} + } +
+ + + {{ s.label }} + +
+ + @if (!last) { +
+ } +
+ } +
+ + + @if (step() === 'select-event') { +
+ +
+ } + @if (step() === 'terms') { + + } + @if (step() === 'apply') { + + } + + +
+ +
+ @if (step() !== 'select-event') { + + } + @if (step() === 'terms') { + + } @else if (step() === 'apply') { + + } @else { + + } +
+
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss new file mode 100644 index 000000000..8b97b4963 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss @@ -0,0 +1,20 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +.scroll-area { + @apply max-h-[440px]; + scrollbar-width: thin; + scrollbar-color: #e2e8f0 transparent; + + &::-webkit-scrollbar { + @apply w-1.5; + } + + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply rounded-sm bg-slate-200; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts new file mode 100644 index 000000000..704135bb6 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts @@ -0,0 +1,74 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { ButtonComponent } from '@components/button/button.component'; +import { MyEvent, VisaRequestApplicantInfo } from '@lfx-one/shared/interfaces'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { EventSelectionComponent } from '../event-selection/event-selection.component'; +import { VisaRequestApplyFormComponent } from '../visa-request-apply-form/visa-request-apply-form.component'; +import { VisaRequestTermsComponent } from '../visa-request-terms/visa-request-terms.component'; + +export type VisaRequestStep = 'select-event' | 'terms' | 'apply'; + +const STEP_ORDER: VisaRequestStep[] = ['select-event', 'terms', 'apply']; + +@Component({ + selector: 'lfx-visa-request-application-dialog', + imports: [ButtonComponent, EventSelectionComponent, VisaRequestTermsComponent, VisaRequestApplyFormComponent], + templateUrl: './visa-request-application-dialog.component.html', + styleUrl: './visa-request-application-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VisaRequestApplicationDialogComponent { + private readonly ref = inject(DynamicDialogRef); + + public step = signal('select-event'); + public selectedEvent = signal(null); + public applyFormValid = signal(false); + public applicantData = signal(null); + public submitting = signal(false); + + public readonly isNextDisabled = computed(() => { + if (this.step() === 'select-event') return !this.selectedEvent(); + if (this.step() === 'apply') return !this.applyFormValid(); + return false; + }); + + public readonly steps: { id: VisaRequestStep; label: string; number: number }[] = [ + { id: 'select-event', label: 'Choose an Event', number: 1 }, + { id: 'terms', label: 'Terms and Conditions', number: 2 }, + { id: 'apply', label: 'Apply', number: 3 }, + ]; + + public onNextStep(): void { + const currentIndex = STEP_ORDER.indexOf(this.step()); + if (currentIndex < STEP_ORDER.length - 1) { + this.step.set(STEP_ORDER[currentIndex + 1]); + } + } + + public onPreviousStep(): void { + const currentIndex = STEP_ORDER.indexOf(this.step()); + if (currentIndex > 0) { + this.step.set(STEP_ORDER[currentIndex - 1]); + } + } + + public onSubmitApplication(): void { + // TODO: wire up to submit endpoint once available + this.ref.close({ submitted: true }); + } + + public onCancel(): void { + this.ref.close(null); + } + + public isStepActive(stepId: VisaRequestStep): boolean { + return this.step() === stepId; + } + + public isStepCompleted(stepId: VisaRequestStep): boolean { + return STEP_ORDER.indexOf(stepId) < STEP_ORDER.indexOf(this.step()); + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html new file mode 100644 index 000000000..d628e7505 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html @@ -0,0 +1,108 @@ + + + +
+

Applicant Information

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.scss new file mode 100644 index 000000000..c32094b45 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.scss @@ -0,0 +1,2 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.ts new file mode 100644 index 000000000..1fa9b4246 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.ts @@ -0,0 +1,78 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { UserService } from '@app/shared/services/user.service'; +import { CalendarComponent } from '@components/calendar/calendar.component'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { SelectComponent } from '@components/select/select.component'; +import { TextareaComponent } from '@components/textarea/textarea.component'; +import { COUNTRIES } from '@lfx-one/shared/constants'; +import { VisaRequestApplicantInfo } from '@lfx-one/shared/interfaces'; + +@Component({ + selector: 'lfx-visa-request-apply-form', + imports: [ReactiveFormsModule, InputTextComponent, SelectComponent, TextareaComponent, CalendarComponent], + templateUrl: './visa-request-apply-form.component.html', + styleUrl: './visa-request-apply-form.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VisaRequestApplyFormComponent { + private readonly userService = inject(UserService); + private readonly fb = inject(NonNullableFormBuilder); + + public readonly formValidityChange = output(); + public readonly formChange = output(); + + public readonly form = this.fb.group({ + firstName: ['', Validators.required], + lastName: ['', Validators.required], + email: [''], + passportNumber: ['', Validators.required], + citizenshipCountry: ['', Validators.required], + passportExpiryDate: [null as Date | null, Validators.required], + embassyCity: ['', Validators.required], + company: [''], + mailingAddress: ['', Validators.required], + }); + + public readonly countryOptions = [...COUNTRIES]; + + public constructor() { + this.form.get('email')?.disable(); + + const user = this.userService.user(); + if (user) { + this.form.patchValue({ + firstName: user.given_name ?? '', + lastName: user.family_name ?? '', + email: user.email ?? '', + }); + } + + this.form.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.formValidityChange.emit(this.form.valid); + }); + + this.form.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.formChange.emit(this.buildFormValue()); + }); + } + + private buildFormValue(): VisaRequestApplicantInfo { + const raw = this.form.getRawValue(); + return { + firstName: raw.firstName, + lastName: raw.lastName, + email: raw.email, + passportNumber: raw.passportNumber, + citizenshipCountry: raw.citizenshipCountry, + passportExpiryDate: raw.passportExpiryDate, + embassyCity: raw.embassyCity, + company: raw.company, + mailingAddress: raw.mailingAddress, + }; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.html new file mode 100644 index 000000000..52ee834c8 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.html @@ -0,0 +1,8 @@ + + + +
+

Visa Letter Request Terms and Conditions

+ +

Terms and conditions for visa letter requests will be provided here.

+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.scss new file mode 100644 index 000000000..c32094b45 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.scss @@ -0,0 +1,2 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.ts new file mode 100644 index 000000000..70d9e0c99 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.ts @@ -0,0 +1,12 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'lfx-visa-request-terms', + templateUrl: './visa-request-terms.component.html', + styleUrl: './visa-request-terms.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VisaRequestTermsComponent {} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.html index e7909b966..473107f6f 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.html @@ -2,7 +2,13 @@
- +
(''); public readonly status = input(null); @@ -58,6 +62,16 @@ export class VisaRequestComponent { }); } + public openApplicationDialog(): void { + this.dialogService.open(VisaRequestApplicationDialogComponent, { + header: 'Visa Letter Application', + width: '800px', + modal: true, + closable: true, + closeOnEscape: true, + }); + } + protected onPageChange(event: { first: number; rows: number }): void { this.loading.set(true); this.page.set({ offset: event.first, pageSize: event.rows }); diff --git a/apps/lfx-one/src/app/shared/services/events.service.ts b/apps/lfx-one/src/app/shared/services/events.service.ts index 08151411f..6e2191c70 100644 --- a/apps/lfx-one/src/app/shared/services/events.service.ts +++ b/apps/lfx-one/src/app/shared/services/events.service.ts @@ -42,7 +42,7 @@ export class EventsService { if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize)); if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset)); if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder); - if (params.registeredFirst) httpParams = httpParams.set('registeredFirst', 'true'); + if (params.registeredOnly) httpParams = httpParams.set('registeredOnly', 'true'); if (params.startDateFrom) httpParams = httpParams.set('startDateFrom', params.startDateFrom); if (params.startDateTo) httpParams = httpParams.set('startDateTo', params.startDateTo); if (params.country) httpParams = httpParams.set('country', params.country); diff --git a/apps/lfx-one/src/server/controllers/events.controller.ts b/apps/lfx-one/src/server/controllers/events.controller.ts index fe7ea2dfb..f2ab3289c 100644 --- a/apps/lfx-one/src/server/controllers/events.controller.ts +++ b/apps/lfx-one/src/server/controllers/events.controller.ts @@ -61,7 +61,7 @@ export class EventsController { const rawMyEventStatus = req.query['status'] ? String(req.query['status']) : undefined; const status = rawMyEventStatus && VALID_MY_EVENT_STATUS_VALUES.has(rawMyEventStatus) ? rawMyEventStatus : undefined; const sortField = req.query['sortField'] ? String(req.query['sortField']) : undefined; - const registeredFirst = req.query['registeredFirst'] === 'true'; + const registeredOnly = req.query['registeredOnly'] === 'true'; const startDateFrom = req.query['startDateFrom'] ? String(req.query['startDateFrom']) : undefined; const startDateTo = req.query['startDateTo'] ? String(req.query['startDateTo']) : undefined; const country = req.query['country'] ? String(req.query['country']) : undefined; @@ -87,7 +87,7 @@ export class EventsController { pageSize, offset, sortOrder, - registeredFirst, + registeredOnly, startDateFrom, startDateTo, country, diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html index ac02be0ce..ccf45b3a9 100644 --- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html +++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html @@ -115,6 +115,12 @@ + + + + + + diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts index 13c4d064d..5d57fe7de 100644 --- a/apps/lfx-one/src/server/services/events.service.ts +++ b/apps/lfx-one/src/server/services/events.service.ts @@ -55,7 +55,7 @@ export class EventsService { pageSize, offset, sortOrder, - registeredFirst, + registeredOnly, startDateFrom, startDateTo, country, @@ -90,6 +90,7 @@ export class EventsService { const startDateFromFilter = startDateFrom ? 'AND e.EVENT_START_DATE >= ?' : ''; const startDateToFilter = startDateTo ? 'AND e.EVENT_START_DATE <= ?' : ''; const countryFilter = country ? 'AND e.EVENT_COUNTRY = ?' : ''; + const registeredOnlyFilter = registeredOnly ? 'AND r.EVENT_ID IS NOT NULL' : ''; sql = ` WITH all_upcoming AS ( @@ -164,7 +165,8 @@ export class EventsService { ${startDateFromFilter} ${startDateToFilter} ${countryFilter} - ORDER BY ${registeredFirst ? 'IS_REGISTERED DESC, ' : ''}${sortField} ${normalizedSortOrder} + ${registeredOnlyFilter} + ORDER BY ${sortField} ${normalizedSortOrder} LIMIT ${normalizedPageSize} OFFSET ${normalizedOffset} `; diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index a930ce6e2..4bb804d14 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -109,3 +109,6 @@ export * from './my-event.interface'; // Travel fund application interfaces export * from './travel-fund-application.interface'; + +// Visa request application interfaces +export * from './visa-request-application.interface'; diff --git a/packages/shared/src/interfaces/my-event.interface.ts b/packages/shared/src/interfaces/my-event.interface.ts index 82a4e405f..df0adef25 100644 --- a/packages/shared/src/interfaces/my-event.interface.ts +++ b/packages/shared/src/interfaces/my-event.interface.ts @@ -205,8 +205,8 @@ export interface GetMyEventsParams { pageSize?: number; offset?: number; sortOrder?: 'ASC' | 'DESC'; - /** When true, registered events are sorted before unregistered events */ - registeredFirst?: boolean; + /** When true, only events the user has registered for are returned */ + registeredOnly?: boolean; /** ISO 8601 date string — include only events starting on or after this date */ startDateFrom?: string; /** ISO 8601 date string — include only events starting on or before this date */ @@ -357,8 +357,8 @@ export interface GetMyEventsOptions { pageSize: number; offset: number; sortOrder: EventSortOrder; - /** When true, registered events are sorted before unregistered events */ - registeredFirst?: boolean; + /** When true, only events the user has registered for are returned */ + registeredOnly?: boolean; /** ISO 8601 date string — include only events starting on or after this date */ startDateFrom?: string; /** ISO 8601 date string — include only events starting on or before this date */ diff --git a/packages/shared/src/interfaces/visa-request-application.interface.ts b/packages/shared/src/interfaces/visa-request-application.interface.ts new file mode 100644 index 000000000..18456a8e5 --- /dev/null +++ b/packages/shared/src/interfaces/visa-request-application.interface.ts @@ -0,0 +1,26 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +export interface VisaRequestApplicantInfo { + firstName: string; + lastName: string; + email: string; + passportNumber: string; + citizenshipCountry: string; + passportExpiryDate: Date | null; + embassyCity: string; + company: string; + mailingAddress: string; +} + +export interface VisaRequestApplication { + eventId: string; + eventName: string; + termsAccepted: boolean; + applicantInfo: VisaRequestApplicantInfo; +} + +export interface VisaRequestApplicationResponse { + success: boolean; + message: string; +} From 3d5913f32ff5086fe1434baadd467ed5711c6bcf Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Fri, 10 Apr 2026 18:44:31 +0800 Subject: [PATCH 07/12] feat: added stub endpoint for submitting visa request application Signed-off-by: Efren Lim --- .../event-selection.component.ts | 2 +- ...sa-request-application-dialog.component.ts | 40 ++++++++- .../src/app/shared/services/events.service.ts | 6 ++ .../server/controllers/events.controller.ts | 84 +++++++++++++------ .../visa-letter-manual/template.html | 2 + .../lfx-one/src/server/routes/events.route.ts | 1 + .../src/server/services/events.service.ts | 43 ++++++---- 7 files changed, 132 insertions(+), 46 deletions(-) diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts index 760b82410..9786eadfe 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts @@ -12,7 +12,7 @@ import { MyEvent } from '@lfx-one/shared/interfaces'; import { IconFieldModule } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; import { InputTextModule } from 'primeng/inputtext'; -import { catchError, debounceTime, finalize, of, skip, switchMap, tap } from 'rxjs'; +import { catchError, debounceTime, finalize, of, skip } from 'rxjs'; type TimeFilterValue = 'any' | 'this-month' | 'next-3-months'; diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts index 704135bb6..e43e7ad3c 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts @@ -1,9 +1,12 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EventsService } from '@app/shared/services/events.service'; import { ButtonComponent } from '@components/button/button.component'; -import { MyEvent, VisaRequestApplicantInfo } from '@lfx-one/shared/interfaces'; +import { MyEvent, VisaRequestApplicantInfo, VisaRequestApplication } from '@lfx-one/shared/interfaces'; +import { MessageService } from 'primeng/api'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { EventSelectionComponent } from '../event-selection/event-selection.component'; import { VisaRequestApplyFormComponent } from '../visa-request-apply-form/visa-request-apply-form.component'; @@ -22,6 +25,9 @@ const STEP_ORDER: VisaRequestStep[] = ['select-event', 'terms', 'apply']; }) export class VisaRequestApplicationDialogComponent { private readonly ref = inject(DynamicDialogRef); + private readonly eventsService = inject(EventsService); + private readonly messageService = inject(MessageService); + private readonly destroyRef = inject(DestroyRef); public step = signal('select-event'); public selectedEvent = signal(null); @@ -56,8 +62,34 @@ export class VisaRequestApplicationDialogComponent { } public onSubmitApplication(): void { - // TODO: wire up to submit endpoint once available - this.ref.close({ submitted: true }); + const event = this.selectedEvent(); + const applicantInfo = this.applicantData(); + + if (!event || !applicantInfo) return; + + const payload: VisaRequestApplication = { + eventId: event.id, + eventName: event.name, + termsAccepted: true, + applicantInfo, + }; + + this.submitting.set(true); + + this.eventsService + .submitVisaRequestApplication(payload) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Your visa letter application has been submitted successfully.', + }); + this.ref.close({ submitted: true }); + }, + error: () => this.submitting.set(false), + }); } public onCancel(): void { diff --git a/apps/lfx-one/src/app/shared/services/events.service.ts b/apps/lfx-one/src/app/shared/services/events.service.ts index 6e2191c70..cba988de7 100644 --- a/apps/lfx-one/src/app/shared/services/events.service.ts +++ b/apps/lfx-one/src/app/shared/services/events.service.ts @@ -19,6 +19,8 @@ import { TravelFundApplication, TravelFundApplicationResponse, TravelFundRequestsResponse, + VisaRequestApplication, + VisaRequestApplicationResponse, VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; import { catchError, Observable, of, take } from 'rxjs'; @@ -113,6 +115,10 @@ export class EventsService { return this.http.get('/api/events/countries').pipe(catchError(() => of({ data: [] as string[] }))); } + public submitVisaRequestApplication(payload: VisaRequestApplication): Observable { + return this.http.post('/api/events/visa-applications', payload).pipe(take(1)); + } + public submitTravelFundApplication(payload: TravelFundApplication): Observable { return this.http.post('/api/events/travel-fund-applications', payload).pipe(take(1)); } diff --git a/apps/lfx-one/src/server/controllers/events.controller.ts b/apps/lfx-one/src/server/controllers/events.controller.ts index f2ab3289c..1b847982c 100644 --- a/apps/lfx-one/src/server/controllers/events.controller.ts +++ b/apps/lfx-one/src/server/controllers/events.controller.ts @@ -23,6 +23,7 @@ import { GetEventsOptions, GetUpcomingCountriesResponse, TravelFundApplication, + VisaRequestApplication, VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; import { EventsService } from '../services/events.service'; @@ -300,6 +301,62 @@ export class EventsController { } } + /** + * POST /api/events/visa-applications + * Submit a visa letter application + * TODO: Wire to upstream microservice once available. + */ + public async submitVisaRequestApplication(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'submit_visa_request_application', {}); + + try { + const payload = req.body as VisaRequestApplication; + + if (!payload?.eventId) { + throw ServiceValidationError.forField('eventId', 'eventId is required', { operation: 'submit_visa_request_application' }); + } + + if (!payload?.applicantInfo) { + throw ServiceValidationError.forField('applicantInfo', 'applicantInfo is required', { operation: 'submit_visa_request_application' }); + } + + const result = await this.eventsService.submitVisaRequestApplication(req, payload); + logger.success(req, 'submit_visa_request_application', startTime, { event_id: payload.eventId }); + res.json(result); + } catch (error) { + logger.error(req, 'submit_visa_request_application', startTime, error, {}); + next(error); + } + } + + /** + * POST /api/events/travel-fund-applications + * Submit a travel fund application + * TODO: Wire to upstream microservice once available. + */ + public async submitTravelFundApplication(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'submit_travel_fund_application', {}); + + try { + const payload = req.body as TravelFundApplication; + + if (!payload?.eventId) { + throw ServiceValidationError.forField('eventId', 'eventId is required', { operation: 'submit_travel_fund_application' }); + } + + if (!payload?.aboutMe) { + throw ServiceValidationError.forField('aboutMe', 'aboutMe is required', { operation: 'submit_travel_fund_application' }); + } + + const result = await this.eventsService.submitTravelFundApplication(req, payload); + logger.success(req, 'submit_travel_fund_application', startTime, { event_id: payload.eventId }); + res.json(result); + } catch (error) { + logger.error(req, 'submit_travel_fund_application', startTime, error, {}); + next(error); + } + } + private async handleEventRequestsEndpoint( req: Request, res: Response, @@ -350,31 +407,4 @@ export class EventsController { } } - /** - * POST /api/events/travel-fund-applications - * Submit a travel fund application - * TODO: Wire to upstream microservice once available. - */ - public async submitTravelFundApplication(req: Request, res: Response, next: NextFunction): Promise { - const startTime = logger.startOperation(req, 'submit_travel_fund_application', {}); - - try { - const payload = req.body as TravelFundApplication; - - if (!payload?.eventId) { - throw ServiceValidationError.forField('eventId', 'eventId is required', { operation: 'submit_travel_fund_application' }); - } - - if (!payload?.aboutMe) { - throw ServiceValidationError.forField('aboutMe', 'aboutMe is required', { operation: 'submit_travel_fund_application' }); - } - - const result = await this.eventsService.submitTravelFundApplication(req, payload); - logger.success(req, 'submit_travel_fund_application', startTime, { event_id: payload.eventId }); - res.json(result); - } catch (error) { - logger.error(req, 'submit_travel_fund_application', startTime, error, {}); - next(error); - } - } } diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html index ccf45b3a9..c958fc95b 100644 --- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html +++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html @@ -125,6 +125,8 @@ + + diff --git a/apps/lfx-one/src/server/routes/events.route.ts b/apps/lfx-one/src/server/routes/events.route.ts index e0d5c6a0b..23931a45a 100644 --- a/apps/lfx-one/src/server/routes/events.route.ts +++ b/apps/lfx-one/src/server/routes/events.route.ts @@ -14,6 +14,7 @@ router.get('/organizations', (req, res, next) => eventsController.getEventOrgani router.get('/countries', (req, res, next) => eventsController.getUpcomingCountries(req, res, next)); router.get('/visa-requests', (req, res, next) => eventsController.getVisaRequests(req, res, next)); router.get('/travel-fund-requests', (req, res, next) => eventsController.getTravelFundRequests(req, res, next)); +router.post('/visa-applications', (req, res, next) => eventsController.submitVisaRequestApplication(req, res, next)); router.post('/travel-fund-applications', (req, res, next) => eventsController.submitTravelFundApplication(req, res, next)); router.get('/certificate', (req, res, next) => eventsController.getCertificate(req, res, next)); export default router; diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts index 5d57fe7de..561416aef 100644 --- a/apps/lfx-one/src/server/services/events.service.ts +++ b/apps/lfx-one/src/server/services/events.service.ts @@ -28,6 +28,8 @@ import { TravelFundApplicationResponse, TravelFundRequestsResponse, VisaRequest, + VisaRequestApplication, + VisaRequestApplicationResponse, VisaRequestRow, VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; @@ -449,6 +451,33 @@ export class EventsService { return this.executeEventRequestsQuery(req, userEmail, options, 'TF_REQUEST_STATUS', 'TF_APPLICATION_DATE', 'get_travel_fund_requests'); } + /** + * Stub: Submit a visa letter application. + * TODO: Replace with upstream microservice call once the API is available. + */ + public async submitVisaRequestApplication(req: Request, payload: VisaRequestApplication): Promise { + logger.debug(req, 'submit_visa_request_application', 'Received visa letter application', { + event_id: payload.eventId, + event_name: payload.eventName, + }); + + return { success: true, message: 'Your visa letter application has been submitted successfully.' }; + } + + /** + * Stub: Submit a travel fund application. + * TODO: Replace with upstream microservice call once the API is available. + */ + public async submitTravelFundApplication(req: Request, payload: TravelFundApplication): Promise { + logger.debug(req, 'submit_travel_fund_application', 'Received travel fund application', { + event_id: payload.eventId, + event_name: payload.eventName, + estimated_total: payload.expenses.estimatedTotal, + }); + + return { success: true, message: 'Your travel fund application has been submitted successfully.' }; + } + private async executeEventRequestsQuery( req: Request, userEmail: string, @@ -676,20 +705,6 @@ export class EventsService { return `${startStr} – ${endStr}`; } - /** - * Stub: Submit a travel fund application. - * TODO: Replace with upstream microservice call once the API is available. - */ - public async submitTravelFundApplication(req: Request, payload: TravelFundApplication): Promise { - logger.debug(req, 'submit_travel_fund_application', 'Received travel fund application', { - event_id: payload.eventId, - event_name: payload.eventName, - estimated_total: payload.expenses.estimatedTotal, - }); - - return { success: true, message: 'Your travel fund application has been submitted successfully.' }; - } - private formatLocation(location: string | null, city: string | null, country: string | null): string { if (city && country) return `${city}, ${country}`; if (location) return location; From a3d62c3f2636da1775f3c6595db83138556edfcb Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Mon, 13 Apr 2026 12:19:44 +0800 Subject: [PATCH 08/12] fix(review): address PR #415 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from @MRashad26, copilot-pull-request-reviewer[bot], coderabbitai[bot]: - event-selection.component.ts: fix "Any Where" → "Anywhere" typo in filter options (per @MRashad26) - event-selection.component.ts: fix pagination — only advance currentOffset after successful response (per @MRashad26) - event-selection.component.html: make empty-state text generic "Register for an event to continue" (per coderabbitai[bot]) - event-selection.component.html: replace \$any() cast with template reference variable #inputEl (per coderabbitai[bot]) - event-selection.component.scss: add empty line before scrollbar-width, replace hardcoded hex with theme() (per coderabbitai[bot]) - visa-request-application-dialog.component.ts: track termsAccepted with signal, set on "I Agree" click (per @MRashad26) - visa-request-application-dialog.component.ts: check response.success before closing; show error toast if false (per @MRashad26) - visa-request-application-dialog.component.ts: replace isStepActive/isStepCompleted methods with stepStates computed signal (per coderabbitai[bot]) - visa-request-application-dialog.component.ts: move VisaRequestStep type to @lfx-one/shared/interfaces (per coderabbitai[bot]) - visa-request-application-dialog.component.scss: add empty line before scrollbar-width, replace hardcoded hex (per coderabbitai[bot]) - travel-fund-application-dialog.component.ts: same termsAccepted tracking and response.success check (per @MRashad26) - travel-fund-application-dialog.component.ts: same computed stepStates refactor (per coderabbitai[bot]) - travel-fund-application-dialog.component.scss: add empty line before scrollbar-width, replace hardcoded hex (per coderabbitai[bot]) - visa-request-apply-form.component.html: add autocomplete="off" to passport number input (per @MRashad26) - visa-request-apply-form.component.html: add validation error messages to all required fields (per @MRashad26) - about-me-form.component.html: add validation error messages to all required fields (per @MRashad26) - visa-request.component.ts: add ChangeDetectionStrategy.OnPush (per coderabbitai[bot]) - my-events-dashboard.component.ts: reset selectedSearchQuery on tab switch (per @MRashad26) - events.service.ts (frontend): remove redundant take(1) from submit methods (per copilot-pull-request-reviewer[bot]) - events.controller.ts: add auth identity check (extract + overwrite email from session) to both submit endpoints (per @MRashad26) - events.controller.ts: add server-side termsAccepted and expenses validation (per coderabbitai[bot]) - events.controller.ts: add logger.error() to getUpcomingCountries catch block (per coderabbitai[bot]) - events.controller.ts: add logger.error() to handleEventRequestsEndpoint catch block (per coderabbitai[bot]) - events.service.ts (server): fix registeredOnly filter to require REGISTRATION_STATUS = 'Accepted' (per @MRashad26) - template.html: collapse blank lines in PDF visa letter template (per @MRashad26) - my-event.interface.ts: move TravelFundStep and VisaRequestStep to shared package (per coderabbitai[bot]) - my-event.interface.ts: add eventId and projectName to GetEventRequestsParams (per @MRashad26) - my-event.interface.ts: update JSDoc to reflect actual Snowflake status values (per @MRashad26) Resolves 37 review threads. LFXV2-2196 Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Efren Lim --- .../about-me-form.component.html | 24 +++++++++ .../event-selection.component.html | 5 +- .../event-selection.component.scss | 3 +- .../event-selection.component.ts | 9 ++-- ...vel-fund-application-dialog.component.html | 20 ++++---- ...vel-fund-application-dialog.component.scss | 3 +- ...ravel-fund-application-dialog.component.ts | 49 ++++++++++++------- ...-request-application-dialog.component.html | 20 ++++---- ...-request-application-dialog.component.scss | 3 +- ...sa-request-application-dialog.component.ts | 49 ++++++++++++------- .../visa-request-apply-form.component.html | 22 +++++++++ .../visa-request/visa-request.component.ts | 3 +- .../my-events-dashboard.component.ts | 1 + .../training-card.component.html | 5 +- .../trainings-dashboard.component.html | 4 +- .../src/app/shared/services/events.service.ts | 6 +-- .../server/controllers/events.controller.ts | 33 ++++++++++++- .../visa-letter-manual/template.html | 41 +--------------- .../src/server/services/events.service.ts | 2 +- .../src/interfaces/my-event.interface.ts | 16 +++++- 20 files changed, 201 insertions(+), 117 deletions(-) diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html index 8e2e987ed..14e30fb3c 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html @@ -14,6 +14,9 @@ placeholder="Jane" styleClass="w-full" dataTest="about-me-first-name" /> + @if (form.get('firstName')?.invalid && form.get('firstName')?.touched) { + First name is required + }
@@ -25,6 +28,9 @@ placeholder="Smith" styleClass="w-full" dataTest="about-me-last-name" /> + @if (form.get('lastName')?.invalid && form.get('lastName')?.touched) { + Last name is required + }
@@ -46,6 +52,9 @@ [filter]="true" styleClass="w-full" dataTest="about-me-citizenship-country" /> + @if (form.get('citizenshipCountry')?.invalid && form.get('citizenshipCountry')?.touched) { + Country of citizenship is required + } @@ -63,6 +72,9 @@ placeholder="https://" styleClass="w-full" dataTest="about-me-profile-link" /> + @if (form.get('profileLink')?.invalid && form.get('profileLink')?.touched) { + Profile link is required + }
@@ -74,6 +86,9 @@ size="small" styleClass="w-full" dataTest="about-me-company" /> + @if (form.get('company')?.invalid && form.get('company')?.touched) { + Company is required + }
@@ -88,6 +103,9 @@ placeholder="- Select -" styleClass="w-full" dataTest="about-me-can-receive-funds" /> + @if (form.get('canReceiveFunds')?.invalid && form.get('canReceiveFunds')?.touched) { + This field is required + }
@@ -99,6 +117,9 @@ [filter]="true" styleClass="w-full" dataTest="about-me-travel-from-country" /> + @if (form.get('travelFromCountry')?.invalid && form.get('travelFromCountry')?.touched) { + Travel from country is required + }
@@ -115,6 +136,9 @@ placeholder="Tell us about your contributions and motivation..." styleClass="w-full" dataTest="about-me-open-source-involvement" /> + @if (form.get('openSourceInvolvement')?.invalid && form.get('openSourceInvolvement')?.touched) { + This field is required + } diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html index 0a8066b7b..332612e4d 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html @@ -13,7 +13,8 @@ class="w-full text-sm" placeholder="Filter by Name" [value]="searchQuery()" - (input)="searchQuery.set($any($event.target).value)" + (input)="searchQuery.set(inputEl.value)" + #inputEl data-testid="event-selection-search" /> @@ -34,7 +35,7 @@

No registered events found

-

Register for an event to apply for a visa letter

+

Register for an event to continue

} @else {
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss index d452c055e..c1a622019 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss @@ -3,8 +3,9 @@ .events-scroll-area { @apply max-h-[340px]; + scrollbar-width: thin; - scrollbar-color: #e2e8f0 transparent; + scrollbar-color: theme('colors.slate.200') transparent; &::-webkit-scrollbar { @apply w-1.5; diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts index 9786eadfe..e6a7df7e0 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts @@ -50,7 +50,7 @@ export class EventSelectionComponent { public loadingMore = signal(false); public allEvents = signal([]); public totalFromServer = signal(0); - public availableLocations = signal<{ label: string; value: string }[]>([{ label: 'Any Where', value: 'any' }]); + public availableLocations = signal<{ label: string; value: string }[]>([{ label: 'Anywhere', value: 'any' }]); private currentOffset = 0; public hasMore = computed(() => this.allEvents().length < this.totalFromServer()); @@ -86,17 +86,18 @@ export class EventSelectionComponent { } public onLoadMore(): void { - this.currentOffset += PAGE_SIZE; + const nextOffset = this.currentOffset + PAGE_SIZE; this.loadingMore.set(true); this.eventsService - .getMyEvents({ isPast: false, pageSize: PAGE_SIZE, offset: this.currentOffset, registeredOnly: true, ...this.activeFilters() }) + .getMyEvents({ isPast: false, pageSize: PAGE_SIZE, offset: nextOffset, registeredOnly: true, ...this.activeFilters() }) .pipe( catchError(() => of(EMPTY_MY_EVENTS_RESPONSE)), finalize(() => this.loadingMore.set(false)), takeUntilDestroyed(this.destroyRef) ) .subscribe((response) => { + this.currentOffset = nextOffset; this.allEvents.update((current) => [...current, ...response.data]); this.totalFromServer.set(response.total); }); @@ -124,7 +125,7 @@ export class EventSelectionComponent { .getUpcomingCountries() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((response) => { - this.availableLocations.set([{ label: 'Any Where', value: 'any' }, ...response.data.map((c) => ({ label: c, value: c }))]); + this.availableLocations.set([{ label: 'Anywhere', value: 'any' }, ...response.data.map((c) => ({ label: c, value: c }))]); }); } diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html index 974845e35..013e8d1b5 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html @@ -4,19 +4,19 @@
- @for (s of steps; track s.id; let last = $last) { + @for (s of stepStates(); track s.id; let last = $last) {
- @if (isStepCompleted(s.id)) { + @if (s.isCompleted) { } @else { {{ s.number }} @@ -25,9 +25,9 @@ {{ s.label }} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss index 8b97b4963..130246a28 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss @@ -3,8 +3,9 @@ .scroll-area { @apply max-h-[440px]; + scrollbar-width: thin; - scrollbar-color: #e2e8f0 transparent; + scrollbar-color: theme('colors.slate.200') transparent; &::-webkit-scrollbar { @apply w-1.5; diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts index 31a9aef73..8acc235a0 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts @@ -5,7 +5,7 @@ import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signa import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EventsService } from '@app/shared/services/events.service'; import { ButtonComponent } from '@components/button/button.component'; -import { MyEvent, TravelFundAboutMe, TravelFundApplication, TravelFundExpenses } from '@lfx-one/shared/interfaces'; +import { MyEvent, TravelFundAboutMe, TravelFundApplication, TravelFundExpenses, TravelFundStep } from '@lfx-one/shared/interfaces'; import { MessageService } from 'primeng/api'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { EventSelectionComponent } from '../event-selection/event-selection.component'; @@ -13,8 +13,6 @@ import { TravelFundTermsComponent } from '../travel-fund-terms/travel-fund-terms import { AboutMeFormComponent } from '../about-me-form/about-me-form.component'; import { TravelExpensesFormComponent } from '../travel-expenses-form/travel-expenses-form.component'; -export type TravelFundStep = 'select-event' | 'terms' | 'about-me' | 'expenses'; - const STEP_ORDER: TravelFundStep[] = ['select-event', 'terms', 'about-me', 'expenses']; @Component({ @@ -32,6 +30,7 @@ export class TravelFundApplicationDialogComponent { public step = signal('select-event'); public selectedEvent = signal(null); + public termsAccepted = signal(false); public aboutMeFormValid = signal(false); public aboutMeData = signal(null); public expensesData = signal(null); @@ -50,7 +49,18 @@ export class TravelFundApplicationDialogComponent { { id: 'expenses', label: 'Expenses', number: 4 }, ]; + public readonly stepStates = computed(() => + this.steps.map((s) => ({ + ...s, + isActive: this.step() === s.id, + isCompleted: STEP_ORDER.indexOf(s.id) < STEP_ORDER.indexOf(this.step()), + })) + ); + public onNextStep(): void { + if (this.step() === 'terms') { + this.termsAccepted.set(true); + } const currentIndex = STEP_ORDER.indexOf(this.step()); if (currentIndex < STEP_ORDER.length - 1) { this.step.set(STEP_ORDER[currentIndex + 1]); @@ -73,7 +83,7 @@ export class TravelFundApplicationDialogComponent { const payload: TravelFundApplication = { eventId: event.id, eventName: event.name, - termsAccepted: true, + termsAccepted: this.termsAccepted(), aboutMe, expenses: this.expensesData() ?? { airfareCost: 0, @@ -92,13 +102,22 @@ export class TravelFundApplicationDialogComponent { .submitTravelFundApplication(payload) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: () => { - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: 'Your travel fund application has been submitted successfully.', - }); - this.ref.close({ submitted: true }); + next: (response) => { + if (response.success) { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Your travel fund application has been submitted successfully.', + }); + this.ref.close({ submitted: true }); + } else { + this.messageService.add({ + severity: 'error', + summary: 'Submission Failed', + detail: response.message ?? 'Unable to submit your application. Please try again.', + }); + this.submitting.set(false); + } }, error: () => this.submitting.set(false), }); @@ -107,12 +126,4 @@ export class TravelFundApplicationDialogComponent { public onCancel(): void { this.ref.close(null); } - - public isStepActive(stepId: TravelFundStep): boolean { - return this.step() === stepId; - } - - public isStepCompleted(stepId: TravelFundStep): boolean { - return STEP_ORDER.indexOf(stepId) < STEP_ORDER.indexOf(this.step()); - } } diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html index fd8eba202..ab223dd20 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html @@ -4,19 +4,19 @@
- @for (s of steps; track s.id; let last = $last) { + @for (s of stepStates(); track s.id; let last = $last) {
- @if (isStepCompleted(s.id)) { + @if (s.isCompleted) { } @else { {{ s.number }} @@ -25,9 +25,9 @@ {{ s.label }} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss index 8b97b4963..130246a28 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss @@ -3,8 +3,9 @@ .scroll-area { @apply max-h-[440px]; + scrollbar-width: thin; - scrollbar-color: #e2e8f0 transparent; + scrollbar-color: theme('colors.slate.200') transparent; &::-webkit-scrollbar { @apply w-1.5; diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts index e43e7ad3c..bfd2ddb45 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts @@ -5,15 +5,13 @@ import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signa import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EventsService } from '@app/shared/services/events.service'; import { ButtonComponent } from '@components/button/button.component'; -import { MyEvent, VisaRequestApplicantInfo, VisaRequestApplication } from '@lfx-one/shared/interfaces'; +import { MyEvent, VisaRequestApplicantInfo, VisaRequestApplication, VisaRequestStep } from '@lfx-one/shared/interfaces'; import { MessageService } from 'primeng/api'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { EventSelectionComponent } from '../event-selection/event-selection.component'; import { VisaRequestApplyFormComponent } from '../visa-request-apply-form/visa-request-apply-form.component'; import { VisaRequestTermsComponent } from '../visa-request-terms/visa-request-terms.component'; -export type VisaRequestStep = 'select-event' | 'terms' | 'apply'; - const STEP_ORDER: VisaRequestStep[] = ['select-event', 'terms', 'apply']; @Component({ @@ -31,6 +29,7 @@ export class VisaRequestApplicationDialogComponent { public step = signal('select-event'); public selectedEvent = signal(null); + public termsAccepted = signal(false); public applyFormValid = signal(false); public applicantData = signal(null); public submitting = signal(false); @@ -47,7 +46,18 @@ export class VisaRequestApplicationDialogComponent { { id: 'apply', label: 'Apply', number: 3 }, ]; + public readonly stepStates = computed(() => + this.steps.map((s) => ({ + ...s, + isActive: this.step() === s.id, + isCompleted: STEP_ORDER.indexOf(s.id) < STEP_ORDER.indexOf(this.step()), + })) + ); + public onNextStep(): void { + if (this.step() === 'terms') { + this.termsAccepted.set(true); + } const currentIndex = STEP_ORDER.indexOf(this.step()); if (currentIndex < STEP_ORDER.length - 1) { this.step.set(STEP_ORDER[currentIndex + 1]); @@ -70,7 +80,7 @@ export class VisaRequestApplicationDialogComponent { const payload: VisaRequestApplication = { eventId: event.id, eventName: event.name, - termsAccepted: true, + termsAccepted: this.termsAccepted(), applicantInfo, }; @@ -80,13 +90,22 @@ export class VisaRequestApplicationDialogComponent { .submitVisaRequestApplication(payload) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: () => { - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: 'Your visa letter application has been submitted successfully.', - }); - this.ref.close({ submitted: true }); + next: (response) => { + if (response.success) { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Your visa letter application has been submitted successfully.', + }); + this.ref.close({ submitted: true }); + } else { + this.messageService.add({ + severity: 'error', + summary: 'Submission Failed', + detail: response.message ?? 'Unable to submit your application. Please try again.', + }); + this.submitting.set(false); + } }, error: () => this.submitting.set(false), }); @@ -95,12 +114,4 @@ export class VisaRequestApplicationDialogComponent { public onCancel(): void { this.ref.close(null); } - - public isStepActive(stepId: VisaRequestStep): boolean { - return this.step() === stepId; - } - - public isStepCompleted(stepId: VisaRequestStep): boolean { - return STEP_ORDER.indexOf(stepId) < STEP_ORDER.indexOf(this.step()); - } } diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html index d628e7505..c14c053d0 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html @@ -16,6 +16,9 @@

+ @if (form.get('firstName')?.invalid && form.get('firstName')?.touched) { + First name is required + }

@@ -27,6 +30,9 @@

+ @if (form.get('lastName')?.invalid && form.get('lastName')?.touched) { + Last name is required + }

@@ -45,7 +51,11 @@

+ @if (form.get('passportNumber')?.invalid && form.get('passportNumber')?.touched) { + Passport number is required + }

@@ -61,10 +71,16 @@

+ @if (form.get('citizenshipCountry')?.invalid && form.get('citizenshipCountry')?.touched) { + Country of citizenship is required + }

+ @if (form.get('passportExpiryDate')?.invalid && form.get('passportExpiryDate')?.touched) { + Passport expiry date is required + }
@@ -80,6 +96,9 @@

+ @if (form.get('embassyCity')?.invalid && form.get('embassyCity')?.touched) { + Embassy city is required + }

@@ -104,5 +123,8 @@

+ @if (form.get('mailingAddress')?.invalid && form.get('mailingAddress')?.touched) { + Mailing address is required + }

diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts index 089cb7303..cdc8d0bf0 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request/visa-request.component.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject, input, Signal, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, Signal, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { EventsService } from '@app/shared/services/events.service'; import { ButtonComponent } from '@components/button/button.component'; @@ -18,6 +18,7 @@ import { VisaRequestApplicationDialogComponent } from '../visa-request-applicati imports: [TableComponent, TagComponent, ButtonComponent, DynamicDialogModule], providers: [DialogService], templateUrl: './visa-request.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class VisaRequestComponent { private readonly eventsService = inject(EventsService); 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 32b29d1be..02966ab53 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 @@ -61,6 +61,7 @@ export class MyEventsDashboardComponent { this.selectedFoundation.set(null); this.selectedRole.set(null); this.selectedStatus.set(null); + this.selectedSearchQuery.set(''); } private initFoundationLabel(): Signal { diff --git a/apps/lfx-one/src/app/modules/trainings/components/training-card/training-card.component.html b/apps/lfx-one/src/app/modules/trainings/components/training-card/training-card.component.html index 86775ad2a..1c81441b4 100644 --- a/apps/lfx-one/src/app/modules/trainings/components/training-card/training-card.component.html +++ b/apps/lfx-one/src/app/modules/trainings/components/training-card/training-card.component.html @@ -22,7 +22,10 @@

{{ training().name }}

@if (training().level) { - + {{ training().level }} } diff --git a/apps/lfx-one/src/app/modules/trainings/trainings-dashboard/trainings-dashboard.component.html b/apps/lfx-one/src/app/modules/trainings/trainings-dashboard/trainings-dashboard.component.html index 6f21b3787..c974b891f 100644 --- a/apps/lfx-one/src/app/modules/trainings/trainings-dashboard/trainings-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/trainings/trainings-dashboard/trainings-dashboard.component.html @@ -107,7 +107,7 @@

No enrolled trainings yet

Ongoing trainings

- @for (enrollment of (enrollments() ?? []); track enrollment.id) { + @for (enrollment of enrollments() ?? []; track enrollment.id) { }
@@ -118,7 +118,7 @@

Completed trainings

- @for (cert of (completedTrainings() ?? []); track cert.id) { + @for (cert of completedTrainings() ?? []; track cert.id) { }
diff --git a/apps/lfx-one/src/app/shared/services/events.service.ts b/apps/lfx-one/src/app/shared/services/events.service.ts index cba988de7..ce0b3da1a 100644 --- a/apps/lfx-one/src/app/shared/services/events.service.ts +++ b/apps/lfx-one/src/app/shared/services/events.service.ts @@ -23,7 +23,7 @@ import { VisaRequestApplicationResponse, VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; -import { catchError, Observable, of, take } from 'rxjs'; +import { catchError, Observable, of } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -116,11 +116,11 @@ export class EventsService { } public submitVisaRequestApplication(payload: VisaRequestApplication): Observable { - return this.http.post('/api/events/visa-applications', payload).pipe(take(1)); + return this.http.post('/api/events/visa-applications', payload); } public submitTravelFundApplication(payload: TravelFundApplication): Observable { - return this.http.post('/api/events/travel-fund-applications', payload).pipe(take(1)); + return this.http.post('/api/events/travel-fund-applications', payload); } public getCertificate(params: GetCertificateParams): Observable { diff --git a/apps/lfx-one/src/server/controllers/events.controller.ts b/apps/lfx-one/src/server/controllers/events.controller.ts index 1b847982c..3d3781713 100644 --- a/apps/lfx-one/src/server/controllers/events.controller.ts +++ b/apps/lfx-one/src/server/controllers/events.controller.ts @@ -249,6 +249,7 @@ export class EventsController { res.json(response); } catch (error) { + logger.error(req, 'get_upcoming_countries', startTime, error, {}); next(error); } } @@ -310,6 +311,12 @@ export class EventsController { const startTime = logger.startOperation(req, 'submit_visa_request_application', {}); try { + const userEmail = (req.oidc?.user?.['email'] as string)?.toLowerCase(); + + if (!userEmail) { + throw new AuthenticationError('User authentication required', { operation: 'submit_visa_request_application' }); + } + const payload = req.body as VisaRequestApplication; if (!payload?.eventId) { @@ -320,6 +327,13 @@ export class EventsController { throw ServiceValidationError.forField('applicantInfo', 'applicantInfo is required', { operation: 'submit_visa_request_application' }); } + if (!payload?.termsAccepted) { + throw ServiceValidationError.forField('termsAccepted', 'termsAccepted must be true', { operation: 'submit_visa_request_application' }); + } + + // Overwrite client-provided email with session email for data integrity + payload.applicantInfo.email = userEmail; + const result = await this.eventsService.submitVisaRequestApplication(req, payload); logger.success(req, 'submit_visa_request_application', startTime, { event_id: payload.eventId }); res.json(result); @@ -338,6 +352,12 @@ export class EventsController { const startTime = logger.startOperation(req, 'submit_travel_fund_application', {}); try { + const userEmail = (req.oidc?.user?.['email'] as string)?.toLowerCase(); + + if (!userEmail) { + throw new AuthenticationError('User authentication required', { operation: 'submit_travel_fund_application' }); + } + const payload = req.body as TravelFundApplication; if (!payload?.eventId) { @@ -348,6 +368,17 @@ export class EventsController { throw ServiceValidationError.forField('aboutMe', 'aboutMe is required', { operation: 'submit_travel_fund_application' }); } + if (!payload?.termsAccepted) { + throw ServiceValidationError.forField('termsAccepted', 'termsAccepted must be true', { operation: 'submit_travel_fund_application' }); + } + + if (!payload?.expenses) { + throw ServiceValidationError.forField('expenses', 'expenses is required', { operation: 'submit_travel_fund_application' }); + } + + // Overwrite client-provided email with session email for data integrity + payload.aboutMe.email = userEmail; + const result = await this.eventsService.submitTravelFundApplication(req, payload); logger.success(req, 'submit_travel_fund_application', startTime, { event_id: payload.eventId }); res.json(result); @@ -403,8 +434,8 @@ export class EventsController { res.json(response); } catch (error) { + logger.error(req, operationName, startTime, error, {}); next(error); } } - } diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html index c958fc95b..688aff4e2 100644 --- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html +++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html @@ -92,45 +92,8 @@ Event Location: - {{#if event.location }}{{ event.location }}, {{/if}} {{#if event.city }}{{ event.city }}, {{/if}} - {{#if - event.state - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - }}{{ event.state }}, {{/if}} {{#if event.country }}{{ event.country }}{{/if}} + {{#if event.location }}{{ event.location }}, {{/if}} {{#if event.city }}{{ event.city }}, {{/if}} {{#if event.state }}{{ event.state }}, {{/if}} + {{#if event.country }}{{ event.country }}{{/if}} diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts index 561416aef..0dbdf4bb2 100644 --- a/apps/lfx-one/src/server/services/events.service.ts +++ b/apps/lfx-one/src/server/services/events.service.ts @@ -92,7 +92,7 @@ export class EventsService { const startDateFromFilter = startDateFrom ? 'AND e.EVENT_START_DATE >= ?' : ''; const startDateToFilter = startDateTo ? 'AND e.EVENT_START_DATE <= ?' : ''; const countryFilter = country ? 'AND e.EVENT_COUNTRY = ?' : ''; - const registeredOnlyFilter = registeredOnly ? 'AND r.EVENT_ID IS NOT NULL' : ''; + const registeredOnlyFilter = registeredOnly ? "AND r.EVENT_ID IS NOT NULL AND r.REGISTRATION_STATUS = 'Accepted'" : ''; sql = ` WITH all_upcoming AS ( diff --git a/packages/shared/src/interfaces/my-event.interface.ts b/packages/shared/src/interfaces/my-event.interface.ts index df0adef25..566214adc 100644 --- a/packages/shared/src/interfaces/my-event.interface.ts +++ b/packages/shared/src/interfaces/my-event.interface.ts @@ -179,10 +179,22 @@ export interface EventTab { countKey?: 'upcoming' | 'past'; } +/** + * Step identifiers for the visa letter application dialog + */ +export type VisaRequestStep = 'select-event' | 'terms' | 'apply'; + +/** + * Step identifiers for the travel fund application dialog + */ +export type TravelFundStep = 'select-event' | 'terms' | 'about-me' | 'expenses'; + /** * Parameters for fetching event requests (visa letters or travel fund) from the API */ export interface GetEventRequestsParams { + eventId?: string; + projectName?: string; searchQuery?: string; status?: string; sortField?: string; @@ -286,7 +298,7 @@ export interface VisaRequestRow { EVENT_COUNTRY: string | null; /** Date the visa letter was applied for */ APPLICATION_DATE: Date | string | null; - /** Visa letter request status (e.g. "Pending", "Approved", "Denied") */ + /** Visa letter request status. Actual Snowflake values: "Submitted", "Approved", "Denied", "Expired" */ REQUEST_STATUS: string; TOTAL_RECORDS: number; } @@ -305,7 +317,7 @@ export interface VisaRequest { location: string; /** Human-readable application date string (e.g. "Jan 15, 2026") */ applicationDate: string; - /** Visa letter request status (e.g. "Pending", "Approved", "Denied") */ + /** Visa letter request status (e.g. "Submitted", "Approved", "Denied", "Expired") */ status: string; } From 5d5c6a96fa44f80a353713d2bd683c7d204399fb Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Mon, 13 Apr 2026 18:23:02 +0800 Subject: [PATCH 09/12] fix: address pr issue Signed-off-by: Efren Lim --- .../components/travel-funding/travel-funding.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts index 57b16d69b..6d908e6b9 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-funding/travel-funding.component.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject, input, Signal, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, Signal, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { EventsService } from '@app/shared/services/events.service'; import { ButtonComponent } from '@components/button/button.component'; @@ -18,6 +18,7 @@ import { TravelFundApplicationDialogComponent } from '../travel-fund-application imports: [TableComponent, TagComponent, ButtonComponent, DynamicDialogModule], providers: [DialogService], templateUrl: './travel-funding.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class TravelFundingComponent { private readonly eventsService = inject(EventsService); From 7df661fa0bb515cdd349ec68e79d6aeb59f3056c Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Tue, 14 Apr 2026 10:21:34 +0800 Subject: [PATCH 10/12] chore: remove the me prefix on the events routes Signed-off-by: Efren Lim --- apps/lfx-one/src/app/app.routes.ts | 7 +----- .../main-layout/main-layout.component.ts | 2 +- .../events-dashboard.component.ts | 25 +++++++++++++++++++ .../src/app/modules/events/events.routes.ts | 11 +------- 4 files changed, 28 insertions(+), 17 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts index be4740817..6a91e552d 100644 --- a/apps/lfx-one/src/app/app.routes.ts +++ b/apps/lfx-one/src/app/app.routes.ts @@ -68,18 +68,13 @@ export const routes: Routes = [ path: 'profile', loadChildren: () => import('./modules/profile/profile.routes').then((m) => m.PROFILE_ROUTES), }, - { - path: 'me/events', - loadChildren: () => import('./modules/events/events.routes').then((m) => m.EVENTS_ROUTES), - }, { path: 'me/training', loadChildren: () => import('./modules/trainings/trainings.routes').then((m) => m.TRAINING_ROUTES), }, { path: 'events', - data: { lens: 'foundation' }, - loadChildren: () => import('./modules/events/events.routes').then((m) => m.FOUNDATION_EVENTS_ROUTES), + loadChildren: () => import('./modules/events/events.routes').then((m) => m.EVENTS_ROUTES), }, ], }, diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts index 47f8484c3..1075eb260 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts @@ -76,7 +76,7 @@ export class MainLayoutComponent { { label: 'My Events', icon: 'fa-light fa-ticket', - routerLink: '/me/events', + routerLink: '/events', }, { label: 'My ' + COMMITTEE_LABEL.plural, diff --git a/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts new file mode 100644 index 000000000..c447791ab --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts @@ -0,0 +1,25 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { LensService } from '@app/shared/services/lens.service'; +import { FoundationEventDashboardComponent } from '../foundation-event-dashboard/foundation-event-dashboard.component'; +import { MyEventsDashboardComponent } from '../my-events-dashboard/my-events-dashboard.component'; + +@Component({ + selector: 'lfx-events-dashboard', + imports: [MyEventsDashboardComponent, FoundationEventDashboardComponent], + template: ` + @if (isMeLens()) { + + } @else { + + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventsDashboardComponent { + private readonly lensService = inject(LensService); + + protected readonly isMeLens = computed(() => this.lensService.activeLens() === 'me'); +} diff --git a/apps/lfx-one/src/app/modules/events/events.routes.ts b/apps/lfx-one/src/app/modules/events/events.routes.ts index b5d3277ae..393a60ed2 100644 --- a/apps/lfx-one/src/app/modules/events/events.routes.ts +++ b/apps/lfx-one/src/app/modules/events/events.routes.ts @@ -7,16 +7,7 @@ import { authGuard } from '@shared/guards/auth.guard'; export const EVENTS_ROUTES: Routes = [ { path: '', - loadComponent: () => import('./my-events-dashboard/my-events-dashboard.component').then((m) => m.MyEventsDashboardComponent), - canActivate: [authGuard], - data: { preload: true, preloadDelay: 1500 }, - }, -]; - -export const FOUNDATION_EVENTS_ROUTES: Routes = [ - { - path: '', - loadComponent: () => import('./foundation-event-dashboard/foundation-event-dashboard.component').then((m) => m.FoundationEventDashboardComponent), + loadComponent: () => import('./events-dashboard/events-dashboard.component').then((m) => m.EventsDashboardComponent), canActivate: [authGuard], data: { preload: true, preloadDelay: 1500 }, }, From ee67e08b1bd00e523517efc8dd77d8b7ca1c03b8 Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Tue, 14 Apr 2026 18:01:17 +0800 Subject: [PATCH 11/12] fix: coderabbit comments Signed-off-by: Efren Lim --- .../events-dashboard/events-dashboard.component.html | 8 ++++++++ .../events-dashboard/events-dashboard.component.ts | 10 ++-------- .../about-me-form/about-me-form.component.html | 6 ++++++ .../travel-fund-application-dialog.component.html | 4 ++-- .../travel-fund-application-dialog.component.ts | 1 + 5 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.html diff --git a/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.html b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.html new file mode 100644 index 000000000..d861293f7 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.html @@ -0,0 +1,8 @@ + + + +@if (isFoundationLens()) { + +} @else { + +} diff --git a/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts index c447791ab..b0ac162fe 100644 --- a/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts @@ -9,17 +9,11 @@ import { MyEventsDashboardComponent } from '../my-events-dashboard/my-events-das @Component({ selector: 'lfx-events-dashboard', imports: [MyEventsDashboardComponent, FoundationEventDashboardComponent], - template: ` - @if (isMeLens()) { - - } @else { - - } - `, + templateUrl: './events-dashboard.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class EventsDashboardComponent { private readonly lensService = inject(LensService); - protected readonly isMeLens = computed(() => this.lensService.activeLens() === 'me'); + protected readonly isFoundationLens = computed(() => this.lensService.activeLens() === 'foundation'); } diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html index 14e30fb3c..e775e5ca6 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html @@ -164,6 +164,9 @@ placeholder="- Select -" styleClass="w-full" dataTest="about-me-attending-for-company" /> + @if (form.get('attendingForCompany')?.invalid && form.get('attendingForCompany')?.touched) { + This field is required + }
@@ -174,6 +177,9 @@ placeholder="- Select -" styleClass="w-full" dataTest="about-me-willing-to-blog" /> + @if (form.get('willingToBlog')?.invalid && form.get('willingToBlog')?.touched) { + This field is required + }
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html index 013e8d1b5..09e01813d 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html @@ -52,7 +52,7 @@ } @if (step() === 'expenses') { - + }
@@ -71,7 +71,7 @@ severity="primary" size="small" [loading]="submitting()" - [disabled]="submitting()" + [disabled]="submitting() || !expensesFormValid()" (onClick)="onSubmitApplication()" data-testid="travel-fund-submit" /> } @else { diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts index 8acc235a0..509423be6 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts @@ -32,6 +32,7 @@ export class TravelFundApplicationDialogComponent { public selectedEvent = signal(null); public termsAccepted = signal(false); public aboutMeFormValid = signal(false); + public expensesFormValid = signal(true); public aboutMeData = signal(null); public expensesData = signal(null); public submitting = signal(false); From e29db493ec9904877a8e7c7425ca337399efe0ec Mon Sep 17 00:00:00 2001 From: Efren Lim Date: Wed, 15 Apr 2026 09:06:29 +0800 Subject: [PATCH 12/12] fix: error caused by incorrect merge Signed-off-by: Efren Lim --- apps/lfx-one/src/server/services/events.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts index 19c33111f..6f64f4893 100644 --- a/apps/lfx-one/src/server/services/events.service.ts +++ b/apps/lfx-one/src/server/services/events.service.ts @@ -233,8 +233,7 @@ export class EventsService { const eventIdFilter = eventId ? 'AND EVENT_ID = ?' : ''; const projectNameFilter = projectName ? 'AND PROJECT_NAME = ?' : ''; const searchQueryFilter = searchQuery ? 'AND EVENT_NAME ILIKE ?' : ''; - const - = role ? this.buildRoleFilter(role) : { filter: '', binds: [] as string[] }; + const roleFilterResult = role ? this.buildRoleFilter(role) : { filter: '', binds: [] as string[] }; const statusFilterResult = status ? this.buildStatusFilter(status) : { filter: '', binds: [] as string[] }; sql = `