diff --git a/.husky/pre-commit b/.husky/pre-commit index ea5a55b6f..2312dc587 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -bunx lint-staged +npx lint-staged diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 0944ccce4..d927a789a 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -2,6 +2,7 @@ import { AuthState } from '@core/store/auth'; import { UserState } from '@core/store/user'; import { CollectionsState } from '@osf/features/collections/store'; import { InstitutionsState } from '@osf/features/institutions/store'; +import { MeetingsState } from '@osf/features/meetings/store'; import { MyProjectsState } from '@osf/features/my-projects/store'; import { AnalyticsState } from '@osf/features/project/analytics/store'; import { ProjectOverviewState } from '@osf/features/project/overview/store'; @@ -28,4 +29,5 @@ export const STATES = [ ProjectOverviewState, CollectionsState, WikiState, + MeetingsState, ]; diff --git a/src/app/core/helpers/http.helper.ts b/src/app/core/helpers/http.helper.ts index e43ce7116..318772d32 100644 --- a/src/app/core/helpers/http.helper.ts +++ b/src/app/core/helpers/http.helper.ts @@ -1,20 +1,13 @@ import { Params } from '@angular/router'; -import { SortOrder } from '@osf/shared/enums/sort-order.enum'; +import { SortOrder } from '@osf/shared/enums'; +import { QueryParams } from '@osf/shared/models'; -export const parseQueryFilterParams = ( - params: Params -): { - page: number; - size: number; - search: string; - sortColumn: string; - sortOrder: SortOrder; -} => { +export const parseQueryFilterParams = (params: Params): QueryParams => { const page = parseInt(params['page'], 10) || 1; - const size = parseInt(params['size'], 10); - const search = params['search']; - const sortColumn = params['sortColumn']; + const size = parseInt(params['size'], 10) || 10; + const search = params['search'] || ''; + const sortColumn = params['sortColumn'] || ''; const sortOrder = params['sortOrder'] === 'desc' ? SortOrder.Desc : SortOrder.Asc; return { diff --git a/src/app/core/models/json-api.model.ts b/src/app/core/models/json-api.model.ts index f483ff96a..ef8c3b00a 100644 --- a/src/app/core/models/json-api.model.ts +++ b/src/app/core/models/json-api.model.ts @@ -3,6 +3,15 @@ export interface JsonApiResponse { included?: Included; } +export interface JsonApiResponseWithPaging extends JsonApiResponse { + links: { + meta: { + total: number; + per_page: number; + }; + }; +} + export interface ApiData { id: string; attributes: Attributes; diff --git a/src/app/features/meetings/mappers/index.ts b/src/app/features/meetings/mappers/index.ts new file mode 100644 index 000000000..31db3d9db --- /dev/null +++ b/src/app/features/meetings/mappers/index.ts @@ -0,0 +1 @@ +export * from './meetings.mapper'; diff --git a/src/app/features/meetings/mappers/meetings.mapper.ts b/src/app/features/meetings/mappers/meetings.mapper.ts new file mode 100644 index 000000000..aa9cc0cea --- /dev/null +++ b/src/app/features/meetings/mappers/meetings.mapper.ts @@ -0,0 +1,51 @@ +import { JsonApiResponseWithPaging } from '@core/models'; +import { + MeetingGetResponse, + MeetingSubmissionGetResponse, + MeetingSubmissionsWithPaging, + MeetingsWithPaging, +} from '@osf/features/meetings/models'; + +export class MeetingsMapper { + static fromMeetingsGetResponse(response: JsonApiResponseWithPaging): MeetingsWithPaging { + return { + data: response.data.map((item) => ({ + id: item.id, + name: item.attributes.name, + location: item.attributes.location, + startDate: item.attributes.start_date, + endDate: item.attributes.end_date, + submissionsCount: item.attributes.submissions_count, + })), + totalCount: response.links.meta.total, + }; + } + + static fromMeetingSubmissionGetResponse( + response: JsonApiResponseWithPaging + ): MeetingSubmissionsWithPaging { + return { + data: response.data.map((item) => ({ + id: item.id, + title: item.attributes.title, + dateCreated: item.attributes.date_created, + authorName: item.attributes.author_name, + downloadCount: item.attributes.download_count, + meetingCategory: item.attributes.meeting_category, + downloadLink: item.links.download, + })), + totalCount: response.links.meta.total, + }; + } + + static fromMeetingGetResponse(response: MeetingGetResponse) { + return { + id: response.id, + name: response.attributes.name, + location: response.attributes.location, + startDate: response.attributes.start_date, + endDate: response.attributes.end_date, + submissionsCount: response.attributes.submissions_count, + }; + } +} diff --git a/src/app/features/meetings/models/meetings.models.ts b/src/app/features/meetings/models/meetings.models.ts index 20962bc75..585d824f2 100644 --- a/src/app/features/meetings/models/meetings.models.ts +++ b/src/app/features/meetings/models/meetings.models.ts @@ -1,18 +1,59 @@ +// domain models +import { NumberOrNull, StringOrNull } from '@core/helpers/types.helper'; + export interface Meeting { id: string; - title: string; + name: string; submissionsCount: number; location: string; startDate: Date; endDate: Date; } +export interface MeetingsWithPaging { + data: Meeting[]; + totalCount: number; +} + export interface MeetingSubmission { id: string; title: string; dateCreated: Date; authorName: string; - downloadCount: number; + downloadCount: NumberOrNull; meetingCategory: string; - downloadLink: string; + downloadLink: StringOrNull; +} + +export interface MeetingSubmissionsWithPaging { + data: MeetingSubmission[]; + totalCount: number; +} + +//api models +export interface MeetingGetResponse { + id: string; + type: 'meetings'; + attributes: { + name: string; + location: string; + start_date: Date; + end_date: Date; + submissions_count: number; + }; +} + +export interface MeetingSubmissionGetResponse { + id: string; + type: 'meeting-submissions'; + attributes: { + title: string; + date_created: Date; + author_name: string; + download_count: NumberOrNull; + meeting_category: string; + }; + links: { + download: StringOrNull; + }; } diff --git a/src/app/features/meetings/pages/meeting-details/meeting-details.component.html b/src/app/features/meetings/pages/meeting-details/meeting-details.component.html index 783b22a80..bc312479d 100644 --- a/src/app/features/meetings/pages/meeting-details/meeting-details.component.html +++ b/src/app/features/meetings/pages/meeting-details/meeting-details.component.html @@ -1,16 +1,18 @@ - +@if (meeting()) { + +}
@@ -53,23 +57,35 @@ - - {{ item.title }} - {{ item.authorName }} - {{ item.meetingCategory }} - {{ item.dateCreated | date: 'MMM d, y, h:mm a' }} - - - {{ item.downloadCount }} - - + @if (item !== 1) { + + {{ item.title }} + {{ item.authorName }} + {{ item.meetingCategory }} + {{ item.dateCreated | date: 'MMM d, y, h:mm a' }} + + @if (item.downloadCount) { + + {{ item.downloadCount }} + } @else { + - + } + + + } @else { + + + + + + }
diff --git a/src/app/features/meetings/pages/meeting-details/meeting-details.component.ts b/src/app/features/meetings/pages/meeting-details/meeting-details.component.ts index 81d8d183a..1e80cbc45 100644 --- a/src/app/features/meetings/pages/meeting-details/meeting-details.component.ts +++ b/src/app/features/meetings/pages/meeting-details/meeting-details.component.ts @@ -1,23 +1,51 @@ +import { createDispatchMap, select, Store } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { SortEvent } from 'primeng/api'; import { Button } from 'primeng/button'; +import { Skeleton } from 'primeng/skeleton'; import { TableModule, TablePageEvent } from 'primeng/table'; -import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, HostBinding, inject, signal } from '@angular/core'; -import { RouterLink } from '@angular/router'; +import { debounceTime, distinctUntilChanged, map, of, Subject, switchMap } from 'rxjs'; -import { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; -import { TableParameters } from '@osf/shared/models'; +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + HostBinding, + inject, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { MEETING_SUBMISSIONS_TABLE_PARAMS } from '../../constants'; -import { Meeting, MeetingSubmission } from '../../models'; -import { testMeeting, testSubmissions } from '../../test-data'; +import { parseQueryFilterParams } from '@core/helpers/http.helper'; +import { MEETING_SUBMISSIONS_TABLE_PARAMS } from '@osf/features/meetings/constants'; +import { MeetingSubmission } from '@osf/features/meetings/models'; +import { GetMeetingById, GetMeetingSubmissions, MeetingsSelectors } from '@osf/features/meetings/store'; +import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; +import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; +import { SortOrder } from '@shared/enums'; +import { QueryParams, TableParameters } from '@shared/models'; +import { SearchFilters } from '@shared/models/filters'; @Component({ selector: 'osf-meeting-details', - imports: [SubHeaderComponent, SearchInputComponent, DatePipe, TableModule, Button, RouterLink, TranslatePipe], + imports: [ + SubHeaderComponent, + SearchInputComponent, + DatePipe, + TableModule, + Button, + RouterLink, + TranslatePipe, + Skeleton, + ], templateUrl: './meeting-details.component.html', styleUrl: './meeting-details.component.scss', providers: [DatePipe], @@ -25,35 +53,186 @@ import { testMeeting, testSubmissions } from '../../test-data'; }) export class MeetingDetailsComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; - #datePipe = inject(DatePipe); + private readonly datePipe = inject(DatePipe); + private readonly store = inject(Store); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly actions = createDispatchMap({ + getMeetingSubmissions: GetMeetingSubmissions, + getMeetingById: GetMeetingById, + }); + private readonly searchSubject = new Subject(); + + queryParams = toSignal(this.route.queryParams); searchValue = signal(''); - sortColumn = signal(undefined); + sortColumn = signal(''); + sortOrder = signal(SortOrder.Asc); + currentPage = signal(1); + currentPageSize = signal(MEETING_SUBMISSIONS_TABLE_PARAMS.rows); tableParams = signal({ ...MEETING_SUBMISSIONS_TABLE_PARAMS, firstRowIndex: 0, }); - meeting = signal(testMeeting); - meetingSubmissions = signal(testSubmissions); + + meetingId = toSignal( + this.route.params.pipe( + map((params) => params['id']), + switchMap((meetingId) => { + const meeting = this.store.selectSnapshot(MeetingsSelectors.getMeetingById)(meetingId); + if (!meeting) { + this.actions.getMeetingById(meetingId); + } + return of(meetingId); + }) + ) + ); + meeting = computed(() => { + const id = this.meetingId(); + if (!id) return null; + const meetingSelector = this.store.selectSignal(MeetingsSelectors.getMeetingById)(); + return meetingSelector(id); + }); + meetingSubmissions = select(MeetingsSelectors.getAllMeetingSubmissions); + totalMeetingSubmissionsCount = select(MeetingsSelectors.getMeetingSubmissionsTotalCount); + isMeetingSubmissionsLoading = select(MeetingsSelectors.isMeetingSubmissionsLoading); + skeletonData: number[] = Array.from({ length: 10 }, () => 1); pageDescription = computed(() => { - if (!this.meeting) { + const meeting = this.meeting(); + if (!meeting) { return ''; } - return `${this.meeting().location} | ${this.#datePipe.transform(this.meeting().startDate, 'MMM d, y')} - - ${this.#datePipe.transform(this.meeting().endDate, 'MMM d, y')}`; + return `${meeting.location} | ${this.datePipe.transform(meeting.startDate, 'MMM d, y')} + - ${this.datePipe.transform(meeting.endDate, 'MMM d, y')}`; }); - onPageChange(tablePageEvent: TablePageEvent) { - // [RNi] TODO: implement paging logic and handle event while integrating API - } - - onSort(sortEvent: SortEvent) { - // [RNi] TODO: implement sorting logic and handle event while integrating API + constructor() { + this.setupTotalRecordsEffect(); + this.setupSearchSubscription(); + this.setupQueryParamsEffect(); } downloadSubmission(event: Event, item: MeetingSubmission) { event.stopPropagation(); + + if (!item.downloadLink) { + return; + } + window.open(item.downloadLink, '_blank'); } + + onSearchChange(value: string): void { + this.searchValue.set(value); + this.searchSubject.next(value); + } + + onPageChange(event: TablePageEvent): void { + const page = Math.floor(event.first / event.rows) + 1; + + this.updateQueryParams({ + page, + size: event.rows, + }); + } + + onSort(event: SortEvent): void { + if (event.field) { + this.updateQueryParams({ + sortColumn: event.field, + sortOrder: event.order === -1 ? SortOrder.Desc : SortOrder.Asc, + }); + } + } + + setupQueryParamsEffect(): void { + effect(() => { + const rawQueryParams = this.queryParams(); + if (!rawQueryParams) return; + + const parsedQueryParams = parseQueryFilterParams(rawQueryParams); + + this.updateComponentState(parsedQueryParams); + const filters = this.createFilters(parsedQueryParams); + if (this.meeting()) { + this.actions.getMeetingSubmissions(this.meeting()!.id, parsedQueryParams.page, parsedQueryParams.size, filters); + } + }); + } + + private setupSearchSubscription(): void { + this.searchSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((searchValue) => { + this.updateQueryParams({ + search: searchValue, + page: 1, + }); + }); + } + + private setupTotalRecordsEffect() { + effect(() => { + const totalRecords = this.totalMeetingSubmissionsCount(); + untracked(() => { + this.updateTableParams({ totalRecords }); + }); + }); + } + + private updateTableParams(updates: Partial): void { + this.tableParams.update((current) => ({ + ...current, + ...updates, + })); + } + + private updateQueryParams(updates: Partial): void { + const queryParams: Record = {}; + + if ('page' in updates) { + queryParams['page'] = updates.page!.toString(); + } + if ('size' in updates) { + queryParams['size'] = updates.size!.toString(); + } + if ('search' in updates) { + queryParams['search'] = updates.search || undefined; + } + if ('sortColumn' in updates) { + queryParams['sortColumn'] = updates.sortColumn!; + queryParams['sortOrder'] = updates.sortOrder === SortOrder.Desc ? 'desc' : 'asc'; + } + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'merge', + }); + } + + private updateComponentState(params: QueryParams): void { + untracked(() => { + this.currentPage.set(params.page); + this.currentPageSize.set(params.size); + this.searchValue.set(params.search); + this.sortColumn.set(params.sortColumn); + this.sortOrder.set(params.sortOrder); + + this.updateTableParams({ + rows: params.size, + firstRowIndex: ((params.page ?? 1) - 1) * params.size, + }); + }); + } + + private createFilters(params: QueryParams): SearchFilters { + return { + searchValue: params.search, + searchFields: ['title', 'author_name', 'meeting_category'], + sortColumn: params.sortColumn, + sortOrder: params.sortOrder, + }; + } } diff --git a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html index 3a74df3b2..96091c20c 100644 --- a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html +++ b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html @@ -12,13 +12,12 @@ - - + {{ 'meetings.landing.table.columns.title' | translate }} - + {{ 'meetings.landing.table.columns.submissions' | translate }} @@ -57,35 +58,43 @@ - - {{ item.title }} - {{ item.submissionsCount }} - {{ item.location }} - {{ item.startDate | date: 'MMM d, y, h:mm a' }} - + @if (item !== 1) { + + {{ item.name }} + {{ item.submissionsCount }} + {{ item.location }} + {{ item.startDate | date: 'MMM d, y, h:mm a' }} + + } @else { + + + + + + } -
- +
+
- Discover + Discover

{{ 'meetings.landing.features.discover.title' | translate }}

{{ 'meetings.landing.features.discover.description' | translate }}

- +
- Share + Share

{{ 'meetings.landing.features.share.title' | translate }}

{{ 'meetings.landing.features.share.description' | translate }}

- +
- Enhance + Enhance

{{ 'meetings.landing.features.enhance.title' | translate }}

{{ 'meetings.landing.features.enhance.description' | translate }}

diff --git a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.ts b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.ts index 782bef933..0034d9c21 100644 --- a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.ts +++ b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.ts @@ -1,25 +1,42 @@ +import { createDispatchMap, select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { SortEvent } from 'primeng/api'; import { Card } from 'primeng/card'; +import { Skeleton } from 'primeng/skeleton'; import { TableModule, TablePageEvent } from 'primeng/table'; -import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, HostBinding, inject, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { Router } from '@angular/router'; +import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; -import { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; -import { TableParameters } from '@osf/shared/models'; -import { IS_XSMALL } from '@osf/shared/utils'; +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + HostBinding, + inject, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; -import { MEETINGS_TABLE_PARAMS } from '../../constants'; -import { Meeting } from '../../models'; -import { testMeetings } from '../../test-data'; +import { parseQueryFilterParams } from '@core/helpers'; +import { MEETINGS_TABLE_PARAMS } from '@osf/features/meetings/constants'; +import { Meeting } from '@osf/features/meetings/models'; +import { GetAllMeetings, MeetingsSelectors } from '@osf/features/meetings/store'; +import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; +import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; +import { SortOrder } from '@shared/enums'; +import { QueryParams, TableParameters } from '@shared/models'; +import { SearchFilters } from '@shared/models/filters'; +import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; @Component({ selector: 'osf-meetings-landing', - imports: [SubHeaderComponent, Card, SearchInputComponent, DatePipe, TableModule, TranslatePipe], + imports: [SubHeaderComponent, Card, SearchInputComponent, DatePipe, TableModule, TranslatePipe, Skeleton], templateUrl: './meetings-landing.component.html', styleUrl: './meetings-landing.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -27,25 +44,145 @@ import { testMeetings } from '../../test-data'; export class MeetingsLandingComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; readonly isXSmall = toSignal(inject(IS_XSMALL)); - #router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly actions = createDispatchMap({ getMeetings: GetAllMeetings }); + private readonly searchSubject = new Subject(); + queryParams = toSignal(this.route.queryParams); searchValue = signal(''); - sortColumn = signal(undefined); + sortColumn = signal(''); + sortOrder = signal(SortOrder.Asc); + currentPage = signal(1); + currentPageSize = signal(MEETINGS_TABLE_PARAMS.rows); tableParams = signal({ ...MEETINGS_TABLE_PARAMS, firstRowIndex: 0, }); - meetings = signal(testMeetings); - onPageChange(tablePageEvent: TablePageEvent) { - // [RNi] TODO: implement paging logic and handle event while integrating API - } + meetings = select(MeetingsSelectors.getAllMeetings); + totalMeetingsCount = select(MeetingsSelectors.getMeetingsTotalCount); + isMeetingsLoading = select(MeetingsSelectors.isMeetingsLoading); + skeletonData: number[] = Array.from({ length: 10 }, () => 1); - onSort(sortEvent: SortEvent) { - // [RNi] TODO: implement sorting logic and handle event while integrating API + constructor() { + this.setupTotalRecordsEffect(); + this.setupSearchSubscription(); + this.setupQueryParamsEffect(); } navigateToMeeting(meeting: Meeting): void { - this.#router.navigate(['/meetings', meeting.id]); + this.router.navigate(['/meetings', meeting.id]); + } + + onSearchChange(value: string): void { + this.searchValue.set(value); + this.searchSubject.next(value); + } + + onPageChange(event: TablePageEvent): void { + const page = Math.floor(event.first / event.rows) + 1; + + this.updateQueryParams({ + page, + size: event.rows, + }); + } + + onSort(event: SortEvent): void { + if (event.field) { + this.updateQueryParams({ + sortColumn: event.field, + sortOrder: event.order === -1 ? SortOrder.Desc : SortOrder.Asc, + }); + } + } + + setupQueryParamsEffect(): void { + effect(() => { + const rawQueryParams = this.queryParams(); + if (!rawQueryParams) return; + + const parsedQueryParams = parseQueryFilterParams(rawQueryParams); + + this.updateComponentState(parsedQueryParams); + const filters = this.createFilters(parsedQueryParams); + this.actions.getMeetings(parsedQueryParams.page, parsedQueryParams.size, filters); + }); + } + + private setupSearchSubscription(): void { + this.searchSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((searchValue) => { + this.updateQueryParams({ + search: searchValue, + page: 1, + }); + }); + } + + private setupTotalRecordsEffect() { + effect(() => { + const totalRecords = this.totalMeetingsCount(); + untracked(() => { + this.updateTableParams({ totalRecords }); + }); + }); + } + + private updateTableParams(updates: Partial): void { + this.tableParams.update((current) => ({ + ...current, + ...updates, + })); + } + + private updateQueryParams(updates: Partial): void { + const queryParams: Record = {}; + + if ('page' in updates) { + queryParams['page'] = updates.page!.toString(); + } + if ('size' in updates) { + queryParams['size'] = updates.size!.toString(); + } + if ('search' in updates) { + queryParams['search'] = updates.search || undefined; + } + if ('sortColumn' in updates) { + queryParams['sortColumn'] = updates.sortColumn!; + queryParams['sortOrder'] = updates.sortOrder === SortOrder.Desc ? 'desc' : 'asc'; + } + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'merge', + }); + } + + private updateComponentState(params: QueryParams): void { + untracked(() => { + this.currentPage.set(params.page); + this.currentPageSize.set(params.size); + this.searchValue.set(params.search); + this.sortColumn.set(params.sortColumn); + this.sortOrder.set(params.sortOrder); + + this.updateTableParams({ + rows: params.size, + firstRowIndex: (params.page - 1) * params.size, + }); + }); + } + + private createFilters(params: QueryParams): SearchFilters { + return { + searchValue: params.search, + searchFields: ['name'], + sortColumn: params.sortColumn, + sortOrder: params.sortOrder, + }; } } diff --git a/src/app/features/meetings/services/index.ts b/src/app/features/meetings/services/index.ts new file mode 100644 index 000000000..895946b38 --- /dev/null +++ b/src/app/features/meetings/services/index.ts @@ -0,0 +1 @@ +export * from './meetings.service'; diff --git a/src/app/features/meetings/services/meetings.service.ts b/src/app/features/meetings/services/meetings.service.ts new file mode 100644 index 000000000..0a1c33873 --- /dev/null +++ b/src/app/features/meetings/services/meetings.service.ts @@ -0,0 +1,75 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@core/services'; +import { JsonApiResponse, JsonApiResponseWithPaging } from '@osf/core/models'; +import { MeetingsMapper } from '@osf/features/meetings/mappers'; +import { + MeetingGetResponse, + MeetingSubmissionGetResponse, + MeetingSubmissionsWithPaging, + MeetingsWithPaging, +} from '@osf/features/meetings/models'; +import { searchPreferencesToJsonApiQueryParams } from '@shared/helpers/search-pref-to-json-api-query-params.helper'; +import { SearchFilters } from '@shared/models/filters/search-filters.model'; + +@Injectable({ + providedIn: 'root', +}) +export class MeetingsService { + jsonApiService = inject(JsonApiService); + baseUrl = 'https://api.staging4.osf.io/_/meetings/'; + #meetingSubmissionSortFieldMap: Record = { + title: 'title', + authorName: 'author_name', + meetingCategory: 'meeting_category', + dateCreated: 'date_created', + downloadCount: 'download_count', + }; + #meetingSortFieldMap: Record = { + name: 'name', + submissionsCount: 'submissions_count', + location: 'location', + startDate: 'start_date', + }; + + getAllMeetings(pageNumber: number, pageSize: number, filters: SearchFilters): Observable { + const params: Record = {}; + searchPreferencesToJsonApiQueryParams(params, pageNumber, pageSize, filters, this.#meetingSortFieldMap); + + return this.jsonApiService.get>(this.baseUrl, params).pipe( + map((response) => { + return MeetingsMapper.fromMeetingsGetResponse(response); + }) + ); + } + + getMeetingSubmissions( + meetingId: string, + pageNumber: number, + pageSize: number, + filters: SearchFilters + ): Observable { + const params: Record = {}; + searchPreferencesToJsonApiQueryParams(params, pageNumber, pageSize, filters, this.#meetingSubmissionSortFieldMap); + + return this.jsonApiService + .get< + JsonApiResponseWithPaging + >(`${this.baseUrl}${meetingId}/submissions/`, params) + .pipe( + map((response) => { + return MeetingsMapper.fromMeetingSubmissionGetResponse(response); + }) + ); + } + + getMeetingById(meetingId: string) { + return this.jsonApiService.get>(this.baseUrl + meetingId).pipe( + map((response) => { + return MeetingsMapper.fromMeetingGetResponse(response.data); + }) + ); + } +} diff --git a/src/app/features/meetings/store/index.ts b/src/app/features/meetings/store/index.ts new file mode 100644 index 000000000..44054bfb1 --- /dev/null +++ b/src/app/features/meetings/store/index.ts @@ -0,0 +1,4 @@ +export * from './meetings.actions'; +export * from './meetings.model'; +export * from './meetings.selectors'; +export * from './meetings.state'; diff --git a/src/app/features/meetings/store/meetings.actions.ts b/src/app/features/meetings/store/meetings.actions.ts new file mode 100644 index 000000000..0e278fbbb --- /dev/null +++ b/src/app/features/meetings/store/meetings.actions.ts @@ -0,0 +1,28 @@ +import { SearchFilters } from '@shared/models/filters'; + +export class GetAllMeetings { + static readonly type = '[Meetings] Get All'; + + constructor( + public pageNumber: number, + public pageSize: number, + public filters: SearchFilters + ) {} +} + +export class GetMeetingById { + static readonly type = '[Meetings] Get Meeting By Id'; + + constructor(public meetingId: string) {} +} + +export class GetMeetingSubmissions { + static readonly type = '[Meetings] Get Meeting Submissions'; + + constructor( + public meetingId: string, + public pageNumber: number, + public pageSize: number, + public filters: SearchFilters + ) {} +} diff --git a/src/app/features/meetings/store/meetings.model.ts b/src/app/features/meetings/store/meetings.model.ts new file mode 100644 index 000000000..5254f09c0 --- /dev/null +++ b/src/app/features/meetings/store/meetings.model.ts @@ -0,0 +1,7 @@ +import { Meeting, MeetingSubmission } from '@osf/features/meetings/models'; +import { AsyncStateWithTotalCount } from '@shared/models/store/async-state-with-total-count.model'; + +export interface MeetingsStateModel { + meetings: AsyncStateWithTotalCount; + meetingSubmissions: AsyncStateWithTotalCount; +} diff --git a/src/app/features/meetings/store/meetings.selectors.ts b/src/app/features/meetings/store/meetings.selectors.ts new file mode 100644 index 000000000..ae994992a --- /dev/null +++ b/src/app/features/meetings/store/meetings.selectors.ts @@ -0,0 +1,42 @@ +import { Selector } from '@ngxs/store'; + +import { Meeting, MeetingSubmission } from '@osf/features/meetings/models'; +import { MeetingsStateModel } from '@osf/features/meetings/store/meetings.model'; +import { MeetingsState } from '@osf/features/meetings/store/meetings.state'; + +export class MeetingsSelectors { + @Selector([MeetingsState]) + static getAllMeetings(state: MeetingsStateModel): Meeting[] { + return state.meetings.data; + } + + @Selector([MeetingsState]) + static getMeetingById(state: MeetingsStateModel): (meetingId: string) => Meeting | undefined { + return (meetingId: string) => state.meetings.data.find((meeting) => meeting.id === meetingId); + } + + @Selector([MeetingsState]) + static isMeetingsLoading(state: MeetingsStateModel): boolean { + return state.meetings.isLoading; + } + + @Selector([MeetingsState]) + static getAllMeetingSubmissions(state: MeetingsStateModel): MeetingSubmission[] { + return state.meetingSubmissions.data; + } + + @Selector([MeetingsState]) + static isMeetingSubmissionsLoading(state: MeetingsStateModel): boolean { + return state.meetingSubmissions.isLoading; + } + + @Selector([MeetingsState]) + static getMeetingsTotalCount(state: MeetingsStateModel): number { + return state.meetings.totalCount; + } + + @Selector([MeetingsState]) + static getMeetingSubmissionsTotalCount(state: MeetingsStateModel): number { + return state.meetingSubmissions.totalCount; + } +} diff --git a/src/app/features/meetings/store/meetings.state.ts b/src/app/features/meetings/store/meetings.state.ts new file mode 100644 index 000000000..cd63bb5c3 --- /dev/null +++ b/src/app/features/meetings/store/meetings.state.ts @@ -0,0 +1,90 @@ +import { Action, State, StateContext } from '@ngxs/store'; +import { insertItem, patch } from '@ngxs/store/operators'; + +import { tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { MeetingsService } from '@osf/features/meetings/services'; +import { GetAllMeetings, GetMeetingById, GetMeetingSubmissions } from '@osf/features/meetings/store/meetings.actions'; +import { MeetingsStateModel } from '@osf/features/meetings/store/meetings.model'; + +@State({ + name: 'meetings', + defaults: { + meetings: { + data: [], + isLoading: false, + error: null, + totalCount: 0, + }, + meetingSubmissions: { + data: [], + isLoading: false, + error: null, + totalCount: 0, + }, + }, +}) +@Injectable() +export class MeetingsState { + #meetingsService = inject(MeetingsService); + + @Action(GetAllMeetings) + getAllMeetings(ctx: StateContext, action: GetAllMeetings) { + ctx.setState(patch({ meetings: patch({ isLoading: true }) })); + + return this.#meetingsService.getAllMeetings(action.pageNumber, action.pageSize, action.filters).pipe( + tap((meetingsWithPaging) => { + ctx.setState( + patch({ + meetings: patch({ + data: meetingsWithPaging.data, + isLoading: false, + totalCount: meetingsWithPaging.totalCount, + }), + }) + ); + }) + ); + } + + @Action(GetMeetingById) + getMeetingById(ctx: StateContext, action: GetMeetingById) { + ctx.setState(patch({ meetings: patch({ isLoading: true }) })); + ctx.setState(patch({ meetingSubmissions: patch({ isLoading: true }) })); + + return this.#meetingsService.getMeetingById(action.meetingId).pipe( + tap((meeting) => { + ctx.setState( + patch({ + meetings: patch({ + data: insertItem(meeting, 0), + }), + }) + ); + }) + ); + } + + @Action(GetMeetingSubmissions) + getMeetingSubmissions(ctx: StateContext, action: GetMeetingSubmissions) { + ctx.setState(patch({ meetingSubmissions: patch({ isLoading: true }) })); + + return this.#meetingsService + .getMeetingSubmissions(action.meetingId, action.pageNumber, action.pageSize, action.filters) + .pipe( + tap((meetingSubmissionsWithPaging) => { + ctx.setState( + patch({ + meetingSubmissions: patch({ + data: meetingSubmissionsWithPaging.data, + isLoading: false, + totalCount: meetingSubmissionsWithPaging.totalCount, + }), + }) + ); + }) + ); + } +} diff --git a/src/app/features/meetings/test-data.ts b/src/app/features/meetings/test-data.ts deleted file mode 100644 index c2af0eb12..000000000 --- a/src/app/features/meetings/test-data.ts +++ /dev/null @@ -1,56 +0,0 @@ -export const testMeetings = [ - { - id: '123', - title: 'Lorem ipsum dolor sit amet', - submissionsCount: 18, - location: 'Virtual', - startDate: new Date(), - endDate: new Date(), - }, - { - id: '124', - title: 'Lorem ipsum dolor sit amet', - submissionsCount: 10, - location: 'Virtual', - startDate: new Date(), - endDate: new Date(), - }, - { - id: '125', - title: 'Lorem ipsum dolor sit amet', - submissionsCount: 24, - location: 'Irving, Texas', - startDate: new Date(), - endDate: new Date(), - }, -]; - -export const testMeeting = { - id: '123', - title: 'Society for Personality and Social Psychology 2016', - submissionsCount: 5, - location: 'San Diego, CA', - startDate: new Date(), - endDate: new Date(), -}; - -export const testSubmissions = [ - { - id: '123', - title: 'Lorem ipsum dolor sit amet', - dateCreated: new Date('2020-06-03T21:36:05.241346Z'), - authorName: 'John Doe', - downloadCount: 0, - meetingCategory: 'poster', - downloadLink: 'https://osf.io/download/72wkh/', - }, - { - id: '123', - title: 'Lorem ipsum dolor sit amet', - dateCreated: new Date('2018-06-03T19:36:05.241346Z'), - authorName: 'John Doe', - downloadCount: 6, - meetingCategory: 'poster', - downloadLink: 'https://osf.io/download/72wkh/', - }, -]; diff --git a/src/app/features/settings/developer-apps/components/developer-app-add-edit-form/developer-app-add-edit-form.component.ts b/src/app/features/settings/developer-apps/components/developer-app-add-edit-form/developer-app-add-edit-form.component.ts index 5b8f0f4a3..5c21d6e6b 100644 --- a/src/app/features/settings/developer-apps/components/developer-app-add-edit-form/developer-app-add-edit-form.component.ts +++ b/src/app/features/settings/developer-apps/components/developer-app-add-edit-form/developer-app-add-edit-form.component.ts @@ -92,7 +92,7 @@ export class DeveloperAppAddEditFormComponent implements OnInit { ) .subscribe({ complete: () => { - this.#router.navigate(['settings/applications']); + this.#router.navigate(['settings/developer-apps']); }, }); } diff --git a/src/app/shared/components/my-projects-table/my-projects-table.component.html b/src/app/shared/components/my-projects-table/my-projects-table.component.html index c91a2778b..6e7be8ccc 100644 --- a/src/app/shared/components/my-projects-table/my-projects-table.component.html +++ b/src/app/shared/components/my-projects-table/my-projects-table.component.html @@ -47,6 +47,7 @@ (onSort)="onSort($event)" [sortField]="sortColumn()" [customSort]="true" + [resetPageOnSort]="false" > diff --git a/src/app/shared/helpers/search-pref-to-json-api-query-params.helper.ts b/src/app/shared/helpers/search-pref-to-json-api-query-params.helper.ts new file mode 100644 index 000000000..03d3782a1 --- /dev/null +++ b/src/app/shared/helpers/search-pref-to-json-api-query-params.helper.ts @@ -0,0 +1,33 @@ +import { SortOrder } from '@shared/enums'; +import { SearchFilters } from '@shared/models/filters'; + +export function searchPreferencesToJsonApiQueryParams( + params: Record, + pageNumber?: number, + pageSize?: number, + filters?: SearchFilters, + sortFieldMap?: Record, + defaultSort?: string +): Record { + if (filters?.searchValue && filters.searchFields.length) { + params[`filter[${filters.searchFields.join(',')}]`] = filters.searchValue; + } + + if (pageNumber) { + params['page'] = pageNumber; + } + + if (pageSize) { + params['page[size]'] = pageSize; + } + + if (filters && filters.sortColumn && sortFieldMap && sortFieldMap[filters.sortColumn]) { + const apiField = sortFieldMap[filters.sortColumn]; + const sortPrefix = filters.sortOrder === SortOrder.Desc ? '-' : ''; + params['sort'] = `${sortPrefix}${apiField}`; + } else if (defaultSort) { + params['sort'] = defaultSort; + } + + return params; +} diff --git a/src/app/shared/models/filters/index.ts b/src/app/shared/models/filters/index.ts index d214faae7..fe8e5ad79 100644 --- a/src/app/shared/models/filters/index.ts +++ b/src/app/shared/models/filters/index.ts @@ -8,5 +8,6 @@ export * from './license'; export * from './part-of-collection'; export * from './provider'; export * from './resource-type'; +export * from './search-filters.model'; export * from './search-result-count.model'; export * from './subject'; diff --git a/src/app/shared/models/filters/search-filters.model.ts b/src/app/shared/models/filters/search-filters.model.ts new file mode 100644 index 000000000..1ce3562c3 --- /dev/null +++ b/src/app/shared/models/filters/search-filters.model.ts @@ -0,0 +1,8 @@ +import { SortOrder } from '@shared/enums'; + +export interface SearchFilters { + searchValue: string; + searchFields: string[]; + sortColumn: string; + sortOrder: SortOrder; +} diff --git a/src/app/shared/models/query-params.model.ts b/src/app/shared/models/query-params.model.ts index e934c140e..2c70770d2 100644 --- a/src/app/shared/models/query-params.model.ts +++ b/src/app/shared/models/query-params.model.ts @@ -1,9 +1,9 @@ -import { SortOrder } from '../enums/sort-order.enum'; +import { SortOrder } from '@shared/enums'; export interface QueryParams { - page?: number; - size?: number; - search?: string; - sortColumn?: string; - sortOrder?: SortOrder; + page: number; + size: number; + search: string; + sortColumn: string; + sortOrder: SortOrder; } diff --git a/src/app/shared/models/store/async-state-with-total-count.model.ts b/src/app/shared/models/store/async-state-with-total-count.model.ts new file mode 100644 index 000000000..bb70250d5 --- /dev/null +++ b/src/app/shared/models/store/async-state-with-total-count.model.ts @@ -0,0 +1,5 @@ +import { AsyncStateModel } from '@shared/models/store/async-state.model'; + +export type AsyncStateWithTotalCount = AsyncStateModel & { + totalCount: number; +}; diff --git a/src/assets/styles/overrides/table.scss b/src/assets/styles/overrides/table.scss index 028261d27..4e2174bcf 100644 --- a/src/assets/styles/overrides/table.scss +++ b/src/assets/styles/overrides/table.scss @@ -17,6 +17,15 @@ } } + tr.loading-row { + td { + background: transparent; + border: none; + padding: 0; + width: 100%; + } + } + th, td { color: var.$dark-blue-1;