diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 58efb93cc..db007ae47 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -8,7 +8,7 @@ import Aura from '@primeng/themes/aura'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideHttpClient } from '@angular/common/http'; import { ConfirmationService } from 'primeng/api'; -import { STATES } from '@core/helpers/ngxs-states.constant'; +import { STATES } from '@core/constants/ngxs-states.constant'; import { provideServiceWorker } from '@angular/service-worker'; export const appConfig: ApplicationConfig = { diff --git a/src/app/core/components/breadcrumb/breadcrumb.component.ts b/src/app/core/components/breadcrumb/breadcrumb.component.ts index 21ace7a94..371a0442b 100644 --- a/src/app/core/components/breadcrumb/breadcrumb.component.ts +++ b/src/app/core/components/breadcrumb/breadcrumb.component.ts @@ -13,7 +13,8 @@ export class BreadcrumbComponent { #destroyRef = inject(DestroyRef); protected readonly url = signal(this.#router.url); protected readonly parsedUrl = computed(() => { - return this.url().split('/').filter(Boolean); + const cleanUrl = this.url().split('?')[0].split('#')[0]; + return cleanUrl.replace('-', ' ').split('/').filter(Boolean); }); constructor() { diff --git a/src/app/core/components/header/header.component.html b/src/app/core/components/header/header.component.html index 7508423be..f73896f19 100644 --- a/src/app/core/components/header/header.component.html +++ b/src/app/core/components/header/header.component.html @@ -1,2 +1,2 @@ - + {{ authButtonText() }} diff --git a/src/app/core/components/nav-menu/nav-menu.component.ts b/src/app/core/components/nav-menu/nav-menu.component.ts index c11d6efb9..e6424ca8f 100644 --- a/src/app/core/components/nav-menu/nav-menu.component.ts +++ b/src/app/core/components/nav-menu/nav-menu.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { RouterLink, RouterLinkActive } from '@angular/router'; -import { NAV_ITEMS } from '@osf/core/helpers/nav-items.constant'; +import { NAV_ITEMS } from '@core/constants/nav-items.constant'; import { PanelMenuModule } from 'primeng/panelmenu'; import { MenuItem } from 'primeng/api'; diff --git a/src/app/core/constants/my-projects-table.constants.ts b/src/app/core/constants/my-projects-table.constants.ts new file mode 100644 index 000000000..7f204b1ee --- /dev/null +++ b/src/app/core/constants/my-projects-table.constants.ts @@ -0,0 +1,12 @@ +import { TableParameters } from '@shared/entities/table-parameters.interface'; + +export const MY_PROJECTS_TABLE_PARAMS: TableParameters = { + rows: 10, + paginator: true, + scrollable: false, + rowsPerPageOptions: [5, 10, 25], + totalRecords: 0, + firstRowIndex: 0, + defaultSortColumn: null, + defaultSortOrder: null, +}; diff --git a/src/app/core/helpers/nav-items.constant.ts b/src/app/core/constants/nav-items.constant.ts similarity index 95% rename from src/app/core/helpers/nav-items.constant.ts rename to src/app/core/constants/nav-items.constant.ts index 4382b28b4..f60e067f8 100644 --- a/src/app/core/helpers/nav-items.constant.ts +++ b/src/app/core/constants/nav-items.constant.ts @@ -1,4 +1,4 @@ -import { NavItem } from '@osf/shared/entities/nav-item.interface'; +import { NavItem } from '@shared/entities/nav-item.interface'; export const NAV_ITEMS: NavItem[] = [ { path: '/home', label: 'Home', icon: 'home', useExactMatch: true }, diff --git a/src/app/core/helpers/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts similarity index 73% rename from src/app/core/helpers/ngxs-states.constant.ts rename to src/app/core/constants/ngxs-states.constant.ts index c0d346532..fa901cc45 100644 --- a/src/app/core/helpers/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -2,7 +2,8 @@ import { AuthState } from '@core/store/auth'; import { TokensState } from '@core/store/settings'; import { AddonsState } from '@core/store/settings/addons'; import { UserState } from '@core/store/user'; -import { HomeState } from 'src/app/features/home/store'; +import { HomeState } from '@osf/features/home/store'; +import { MyProjectsState } from '@core/store/my-projects'; import { SearchState } from '@osf/features/search/store'; export const STATES = [ @@ -12,4 +13,5 @@ export const STATES = [ UserState, HomeState, SearchState, + MyProjectsState, ]; diff --git a/src/app/core/helpers/http.helper.ts b/src/app/core/helpers/http.helper.ts new file mode 100644 index 000000000..fbd689092 --- /dev/null +++ b/src/app/core/helpers/http.helper.ts @@ -0,0 +1,27 @@ +import { Params } from '@angular/router'; +import { SortOrder } from '@shared/utils/sort-order.enum'; + +export const parseQueryFilterParams = ( + params: Params, +): { + page: number; + size: number; + search: string; + sortColumn: string; + sortOrder: SortOrder; +} => { + const page = parseInt(params['page'], 10) || 1; + const size = parseInt(params['size'], 10); + const search = params['search']; + const sortColumn = params['sortColumn']; + const sortOrder = + params['sortOrder'] === 'desc' ? SortOrder.Desc : SortOrder.Asc; + + return { + page, + size, + search, + sortColumn, + sortOrder, + }; +}; diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts index e4ec8f422..c1ce33697 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -10,6 +10,7 @@ export class JsonApiService { http: HttpClient = inject(HttpClient); readonly #token = 'Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt'; + // OBJoUomBgbUuDgQo5JoaSKNya6XaYcd0ojAX1XOLmWi6J2arQPzByxyEi81fHE60drQUWv readonly #headers = new HttpHeaders({ Authorization: this.#token, Accept: 'application/vnd.api+json', diff --git a/src/app/core/store/my-projects/index.ts b/src/app/core/store/my-projects/index.ts new file mode 100644 index 000000000..48506c4af --- /dev/null +++ b/src/app/core/store/my-projects/index.ts @@ -0,0 +1,4 @@ +export * from './my-projects.state'; +export * from './my-projects.actions'; +export * from './my-projects.selectors'; +export * from './my-projects.model'; diff --git a/src/app/core/store/my-projects/my-projects.actions.ts b/src/app/core/store/my-projects/my-projects.actions.ts new file mode 100644 index 000000000..170bc8bc1 --- /dev/null +++ b/src/app/core/store/my-projects/my-projects.actions.ts @@ -0,0 +1,49 @@ +import { MyProjectsSearchFilters } from '@osf/features/my-projects/entities/my-projects-search-filters.models'; + +export class GetMyProjects { + static readonly type = '[My Projects] Get Projects'; + + constructor( + public pageNumber: number, + public pageSize: number, + public filters: MyProjectsSearchFilters, + ) {} +} + +export class GetMyRegistrations { + static readonly type = '[My Projects] Get Registrations'; + + constructor( + public pageNumber: number, + public pageSize: number, + public filters: MyProjectsSearchFilters, + ) {} +} + +export class GetMyPreprints { + static readonly type = '[My Projects] Get Preprints'; + + constructor( + public pageNumber: number, + public pageSize: number, + public filters: MyProjectsSearchFilters, + ) {} +} + +export class GetMyBookmarks { + static readonly type = '[My Projects] Get Bookmarks'; + constructor( + public bookmarksId: string, + public pageNumber: number, + public pageSize: number, + public filters: MyProjectsSearchFilters, + ) {} +} + +export class GetBookmarksCollectionId { + static readonly type = '[My Projects] Get Bookmarks Collection Id'; +} + +export class ClearMyProjects { + static readonly type = '[My Projects] Clear Projects'; +} diff --git a/src/app/core/store/my-projects/my-projects.model.ts b/src/app/core/store/my-projects/my-projects.model.ts new file mode 100644 index 000000000..2998362cd --- /dev/null +++ b/src/app/core/store/my-projects/my-projects.model.ts @@ -0,0 +1,13 @@ +import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.entities'; + +export interface MyProjectsStateModel { + projects: MyProjectsItem[]; + registrations: MyProjectsItem[]; + preprints: MyProjectsItem[]; + bookmarks: MyProjectsItem[]; + totalProjects: number; + totalRegistrations: number; + totalPreprints: number; + totalBookmarks: number; + bookmarksId: string; +} diff --git a/src/app/core/store/my-projects/my-projects.selectors.ts b/src/app/core/store/my-projects/my-projects.selectors.ts new file mode 100644 index 000000000..2f35d549f --- /dev/null +++ b/src/app/core/store/my-projects/my-projects.selectors.ts @@ -0,0 +1,50 @@ +import { Selector } from '@ngxs/store'; +import { MyProjectsStateModel } from './my-projects.model'; +import { MyProjectsState } from '@core/store/my-projects/my-projects.state'; + +export class MyProjectsSelectors { + @Selector([MyProjectsState]) + static getProjects(state: MyProjectsStateModel) { + return state.projects; + } + + @Selector([MyProjectsState]) + static getRegistrations(state: MyProjectsStateModel) { + return state.registrations; + } + + @Selector([MyProjectsState]) + static getPreprints(state: MyProjectsStateModel) { + return state.preprints; + } + + @Selector([MyProjectsState]) + static getBookmarks(state: MyProjectsStateModel) { + return state.bookmarks; + } + + @Selector([MyProjectsState]) + static getTotalProjectsCount(state: MyProjectsStateModel) { + return state.totalProjects; + } + + @Selector([MyProjectsState]) + static getTotalRegistrationsCount(state: MyProjectsStateModel) { + return state.totalRegistrations; + } + + @Selector([MyProjectsState]) + static getTotalPreprintsCount(state: MyProjectsStateModel) { + return state.totalPreprints; + } + + @Selector([MyProjectsState]) + static getTotalBookmarksCount(state: MyProjectsStateModel) { + return state.totalBookmarks; + } + + @Selector([MyProjectsState]) + static getBookmarksCollectionId(state: MyProjectsStateModel) { + return state.bookmarksId; + } +} diff --git a/src/app/core/store/my-projects/my-projects.state.ts b/src/app/core/store/my-projects/my-projects.state.ts new file mode 100644 index 000000000..819b34c20 --- /dev/null +++ b/src/app/core/store/my-projects/my-projects.state.ts @@ -0,0 +1,127 @@ +import { inject, Injectable } from '@angular/core'; +import { State, Action, StateContext } from '@ngxs/store'; +import { MyProjectsStateModel } from './my-projects.model'; +import { + GetMyProjects, + GetMyRegistrations, + GetMyPreprints, + GetMyBookmarks, + GetBookmarksCollectionId, + ClearMyProjects, +} from './my-projects.actions'; +import { MyProjectsService } from '@osf/features/my-projects/my-projects.service'; +import { tap } from 'rxjs'; + +@State({ + name: 'myProjects', + defaults: { + projects: [], + registrations: [], + preprints: [], + bookmarks: [], + totalProjects: 0, + totalRegistrations: 0, + totalPreprints: 0, + totalBookmarks: 0, + bookmarksId: '', + }, +}) +@Injectable() +export class MyProjectsState { + myProjectsService = inject(MyProjectsService); + + @Action(GetMyProjects) + getProjects(ctx: StateContext, action: GetMyProjects) { + return this.myProjectsService + .getMyProjects(action.filters, action.pageNumber, action.pageSize) + .pipe( + tap((res) => { + ctx.patchState({ + projects: res.data, + totalProjects: res.links.meta.total, + }); + }), + ); + } + + @Action(GetMyRegistrations) + getRegistrations( + ctx: StateContext, + action: GetMyRegistrations, + ) { + return this.myProjectsService + .getMyRegistrations(action.filters, action.pageNumber, action.pageSize) + .pipe( + tap((res) => { + ctx.patchState({ + registrations: res.data, + totalRegistrations: res.links.meta.total, + }); + }), + ); + } + + @Action(GetMyPreprints) + getPreprints( + ctx: StateContext, + action: GetMyPreprints, + ) { + return this.myProjectsService + .getMyPreprints(action.filters, action.pageNumber, action.pageSize) + .pipe( + tap((res) => { + ctx.patchState({ + preprints: res.data, + totalPreprints: res.links.meta.total, + }); + }), + ); + } + + @Action(GetMyBookmarks) + getBookmarks( + ctx: StateContext, + action: GetMyBookmarks, + ) { + return this.myProjectsService + .getMyBookmarks( + action.bookmarksId, + action.filters, + action.pageNumber, + action.pageSize, + ) + .pipe( + tap((res) => { + ctx.patchState({ + bookmarks: res.data, + totalBookmarks: res.links.meta.total, + }); + }), + ); + } + + @Action(GetBookmarksCollectionId) + getBookmarksCollectionId(ctx: StateContext) { + return this.myProjectsService.getBookmarksCollectionId().pipe( + tap((res) => { + ctx.patchState({ + bookmarksId: res, + }); + }), + ); + } + + @Action(ClearMyProjects) + clearMyProjects(ctx: StateContext) { + ctx.patchState({ + projects: [], + registrations: [], + preprints: [], + bookmarks: [], + totalProjects: 0, + totalRegistrations: 0, + totalPreprints: 0, + totalBookmarks: 0, + }); + } +} diff --git a/src/app/features/home/home.component.html b/src/app/features/home/home.component.html index fb4e33c2a..6af895913 100644 --- a/src/app/features/home/home.component.html +++ b/src/app/features/home/home.component.html @@ -27,43 +27,19 @@ search OSF

- - - - - - - Title - - - Contributors - - Modified - - - - - - - {{ project.title }} - {{ getContributorsList(project) }} - {{ project.dateModified | date: "MMM d, y, h:mm a" }} - - -
diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts index efa75655d..dfcabefd3 100644 --- a/src/app/features/home/home.component.ts +++ b/src/app/features/home/home.component.ts @@ -1,63 +1,217 @@ -import { Component, computed, inject, OnInit, signal } from '@angular/core'; -import { TableModule } from 'primeng/table'; -import { Project } from '@osf/features/home/models/project.entity'; -import { DatePipe } from '@angular/common'; -import { RouterLink } from '@angular/router'; +import { + Component, + DestroyRef, + inject, + OnInit, + signal, + computed, + effect, +} from '@angular/core'; +import { RouterLink, ActivatedRoute, Router } from '@angular/router'; import { Button } from 'primeng/button'; import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; -import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; import { IS_MEDIUM, IS_XSMALL } from '@shared/utils/breakpoints.tokens'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { DashboardService } from '@osf/features/home/dashboard.service'; +import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngxs/store'; -import { GetProjects, HomeSelectors } from 'src/app/features/home/store'; +import { MyProjectsTableComponent } from '@shared/components/my-projects-table/my-projects-table.component'; +import { TableParameters } from '@shared/entities/table-parameters.interface'; +import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants/my-projects-table.constants'; +import { SortOrder } from '@shared/utils/sort-order.enum'; +import { TablePageEvent } from 'primeng/table'; +import { SortEvent } from 'primeng/api'; +import { + MyProjectsSelectors, + GetMyProjects, + ClearMyProjects, +} from '@core/store/my-projects'; +import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; +import { MyProjectsSearchFilters } from '@osf/features/my-projects/entities/my-projects-search-filters.models'; +import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.entities'; @Component({ selector: 'osf-home', standalone: true, - imports: [ - TableModule, - DatePipe, - RouterLink, - Button, - SubHeaderComponent, - SearchInputComponent, - ], + imports: [RouterLink, Button, SubHeaderComponent, MyProjectsTableComponent], templateUrl: './home.component.html', styleUrl: './home.component.scss', }) export class HomeComponent implements OnInit { - #store = inject(Store); + readonly #destroyRef = inject(DestroyRef); + readonly #store = inject(Store); + readonly #router = inject(Router); + readonly #route = inject(ActivatedRoute); + readonly #searchSubject = new Subject(); + + protected readonly isLoading = signal(false); + protected readonly isMedium = toSignal(inject(IS_MEDIUM)); protected readonly isMobile = toSignal(inject(IS_XSMALL)); - protected readonly dashboardService: DashboardService = - inject(DashboardService); + + protected readonly activeProject = signal(null); + protected readonly searchValue = signal(''); + protected readonly sortColumn = signal(undefined); + protected readonly sortOrder = signal(SortOrder.Asc); + protected readonly tableParams = signal({ + ...MY_PROJECTS_TABLE_PARAMS, + }); protected readonly projects = this.#store.selectSignal( - HomeSelectors.getProjects, + MyProjectsSelectors.getProjects, + ); + protected readonly totalProjectsCount = this.#store.selectSignal( + MyProjectsSelectors.getTotalProjectsCount, ); - searchValue = signal(''); - - filteredProjects = computed(() => { + protected readonly filteredProjects = computed(() => { const search = this.searchValue().toLowerCase(); - return this.projects().filter( - (project) => - project.title.toLowerCase().includes(search) || - project.bibliographicContributors.some((i) => - i.users.familyName.toLowerCase().includes(search), - ), + return this.projects().filter((project) => + project.title.toLowerCase().includes(search), ); }); - getContributorsList(item: Project) { - return this.projects() - .find((i) => i.id === item.id) - ?.bibliographicContributors.map((i) => i.users.familyName) - .join(', '); + constructor() { + this.#setupSearchSubscription(); + this.#setupTotalRecordsEffect(); + this.#setupCleanup(); } ngOnInit() { - this.#store.dispatch(GetProjects); + this.#setupQueryParamsSubscription(); + } + + #setupQueryParamsSubscription(): void { + this.#route.queryParams + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((params) => { + const page = Number(params['page']) || 1; + const rows = Number(params['rows']) || MY_PROJECTS_TABLE_PARAMS.rows; + const sortField = params['sortField']; + const sortOrder = params['sortOrder'] as SortOrder; + const search = params['search'] || ''; + + this.tableParams.update((current) => ({ + ...current, + firstRowIndex: (page - 1) * rows, + rows, + })); + + if (sortField) { + this.sortColumn.set(sortField); + this.sortOrder.set(sortOrder || SortOrder.Asc); + } + + if (search) { + this.searchValue.set(search); + } + + this.#fetchProjects(); + }); + } + + #setupSearchSubscription(): void { + this.#searchSubject + .pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntilDestroyed(this.#destroyRef), + ) + .subscribe((searchValue) => { + this.#handleSearch(searchValue); + }); + } + + #setupTotalRecordsEffect(): void { + effect(() => { + const total = this.totalProjectsCount(); + this.tableParams.update((current) => ({ + ...current, + totalRecords: total, + })); + }); + } + + #setupCleanup(): void { + this.#destroyRef.onDestroy(() => { + this.#store.dispatch(new ClearMyProjects()); + }); + } + + #fetchProjects(): void { + this.isLoading.set(true); + const filters = this.#createFilters(); + const page = + Math.floor(this.tableParams().firstRowIndex / this.tableParams().rows) + + 1; + this.#store + .dispatch(new GetMyProjects(page, this.tableParams().rows, filters)) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe({ + complete: () => { + this.isLoading.set(false); + }, + error: () => { + this.isLoading.set(false); + }, + }); + } + + #createFilters(): MyProjectsSearchFilters { + return { + searchValue: this.searchValue(), + searchFields: ['title'], + sortColumn: this.sortColumn(), + sortOrder: this.sortOrder(), + }; + } + + #handleSearch(searchValue: string): void { + this.searchValue.set(searchValue); + this.#updateQueryParams(); + } + + #updateQueryParams(): void { + const page = + Math.floor(this.tableParams().firstRowIndex / this.tableParams().rows) + + 1; + const queryParams = { + page, + rows: this.tableParams().rows, + search: this.searchValue() || undefined, + sortField: this.sortColumn() || undefined, + sortOrder: this.sortOrder() || undefined, + }; + + this.#router.navigate([], { + relativeTo: this.#route, + queryParams, + queryParamsHandling: 'merge', + }); + } + + protected onSearchChange(value: string): void { + this.searchValue.set(value); + this.#searchSubject.next(value); + } + + protected onPageChange(event: TablePageEvent): void { + this.tableParams.update((current) => ({ + ...current, + rows: event.rows, + firstRowIndex: event.first, + })); + this.#updateQueryParams(); + } + + protected onSort(event: SortEvent): void { + if (event.field) { + this.sortColumn.set(event.field); + this.sortOrder.set(event.order === -1 ? SortOrder.Desc : SortOrder.Asc); + this.#updateQueryParams(); + } + } + + protected navigateToProject(project: MyProjectsItem): void { + this.activeProject.set(project); + this.#router.navigate(['/my-projects', project.id]); } } diff --git a/src/app/features/my-projects/entities/my-projects-search-filters.models.ts b/src/app/features/my-projects/entities/my-projects-search-filters.models.ts new file mode 100644 index 000000000..e2f167d16 --- /dev/null +++ b/src/app/features/my-projects/entities/my-projects-search-filters.models.ts @@ -0,0 +1,10 @@ +import { SortOrder } from '@shared/utils/sort-order.enum'; + +export type SearchField = 'tags' | 'title' | 'description'; + +export interface MyProjectsSearchFilters { + searchValue?: string; + searchFields?: SearchField[]; + sortColumn?: string; + sortOrder?: SortOrder; +} diff --git a/src/app/features/my-projects/entities/my-projects.entities.ts b/src/app/features/my-projects/entities/my-projects.entities.ts new file mode 100644 index 000000000..4b56578b5 --- /dev/null +++ b/src/app/features/my-projects/entities/my-projects.entities.ts @@ -0,0 +1,85 @@ +import { JsonApiResponse } from '@core/services/json-api/json-api.entity'; + +export interface MyProjectsItemGetResponse { + id: string; + type: string; + attributes: { + title: string; + date_modified: string; + public: boolean; + }; + embeds: { + bibliographic_contributors: { + data: { + embeds: { + users: { + data: { + attributes: { + family_name: string; + full_name: string; + given_name: string; + middle_name: string; + }; + }; + }; + }; + }[]; + links: { + meta: { + total: number; + per_page: number; + }; + }; + }; + }; +} + +export interface MyProjectsContributor { + familyName: string; + fullName: string; + givenName: string; + middleName: string; +} + +export interface MyProjectsItem { + id: string; + type: string; + title: string; + dateModified: string; + isPublic: boolean; + contributors: MyProjectsContributor[]; +} + +export interface MyProjectsItemResponse { + data: MyProjectsItem[]; + links: { + meta: { + total: number; + per_page: number; + }; + }; +} + +export interface MyProjectsJsonApiResponse + extends JsonApiResponse { + links: { + meta: { + total: number; + per_page: number; + }; + }; +} + +export interface CollectionAttributes { + title: string; + bookmarks: boolean; +} + +export interface Collection { + id: string; + attributes: CollectionAttributes; +} + +export interface SparseCollectionsResponse { + data: Collection[]; +} diff --git a/src/app/features/my-projects/entities/my-projects.types.ts b/src/app/features/my-projects/entities/my-projects.types.ts new file mode 100644 index 000000000..58cbe7285 --- /dev/null +++ b/src/app/features/my-projects/entities/my-projects.types.ts @@ -0,0 +1,5 @@ +export type EndpointType = + | 'nodes' + | 'registrations' + | 'preprints' + | `collections/${string}/linked_nodes`; diff --git a/src/app/features/my-projects/mappers/my-preprints.mapper.ts b/src/app/features/my-projects/mappers/my-preprints.mapper.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/my-projects/mappers/my-projects.mapper.ts b/src/app/features/my-projects/mappers/my-projects.mapper.ts new file mode 100644 index 000000000..6b39e06e4 --- /dev/null +++ b/src/app/features/my-projects/mappers/my-projects.mapper.ts @@ -0,0 +1,24 @@ +import { + MyProjectsItem, + MyProjectsItemGetResponse, +} from '@osf/features/my-projects/entities/my-projects.entities'; + +export class MyProjectsMapper { + static fromResponse(response: MyProjectsItemGetResponse): MyProjectsItem { + return { + id: response.id, + type: response.type, + title: response.attributes.title, + dateModified: response.attributes.date_modified, + isPublic: response.attributes.public, + contributors: response.embeds.bibliographic_contributors.data.map( + (contributor) => ({ + familyName: contributor.embeds.users.data.attributes.family_name, + fullName: contributor.embeds.users.data.attributes.full_name, + givenName: contributor.embeds.users.data.attributes.given_name, + middleName: contributor.embeds.users.data.attributes.middle_name, + }), + ), + }; + } +} diff --git a/src/app/features/my-projects/mappers/my-registrations.mapper.ts b/src/app/features/my-projects/mappers/my-registrations.mapper.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/my-projects/my-projects.component.html b/src/app/features/my-projects/my-projects.component.html index 660e669e8..bff477a51 100644 --- a/src/app/features/my-projects/my-projects.component.html +++ b/src/app/features/my-projects/my-projects.component.html @@ -10,7 +10,7 @@
@if (!isMobile()) { @@ -36,164 +36,73 @@ } - - - - - - - Title - - - Contributors - - Modified - - - - - - - -

- - {{ project.title }} -

- - {{ getContributorsList(project) }} - {{ project.dateModified | date: "MMM d, y, h:mm a" }} - -
-
- - - - - - - Title - - - Contributors - - Modified - - - - - - - -

- - {{ project.title }} -

- - {{ getContributorsList(project) }} - {{ project.dateModified | date: "MMM d, y, h:mm a" }} - -
-
- - - - - - - Title - - - Contributors - - Modified - - - - - - - {{ project.title }} - {{ getContributorsList(project) }} - {{ project.dateModified | date: "MMM d, y, h:mm a" }} - - - -

- You don't have any bookmarks. Click the bookmark icon on projects or - registrations to add them here. -

+ @if (!bookmarks().length && !isLoading()) { +

+ You don't have any bookmarks. Click the bookmark icon on projects + or registrations to add them here. +

+ } @else { + + }
diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index b2a9fdf36..8fdcaf079 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -1,47 +1,59 @@ import { ChangeDetectionStrategy, Component, - computed, + DestroyRef, + effect, inject, OnInit, signal, + untracked, } from '@angular/core'; import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { IS_MEDIUM, IS_WEB, IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { DropdownModule } from 'primeng/dropdown'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; import { Select } from 'primeng/select'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { TabOption } from '@shared/entities/tab-option.interface'; -import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; -import { Project } from '@osf/features/home/models/project.entity'; -import { DatePipe, NgClass } from '@angular/common'; -import { TableModule } from 'primeng/table'; +import { TablePageEvent } from 'primeng/table'; +import type { SortEvent } from 'primeng/api'; import { DialogService } from 'primeng/dynamicdialog'; import { AddProjectFormComponent } from './add-project-form/add-project-form.component'; -import { GetProjects, HomeSelectors } from 'src/app/features/home/store'; import { Store } from '@ngxs/store'; -import { Router, RouterOutlet } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + GetMyProjects, + MyProjectsSelectors, + ClearMyProjects, + GetMyRegistrations, + GetMyPreprints, + GetBookmarksCollectionId, + GetMyBookmarks, +} from '@core/store/my-projects'; +import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; +import { MyProjectsSearchFilters } from '@osf/features/my-projects/entities/my-projects-search-filters.models'; +import { TableParameters } from '@shared/entities/table-parameters.interface'; +import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants/my-projects-table.constants'; +import { parseQueryFilterParams } from '@core/helpers/http.helper'; +import { SortOrder } from '@shared/utils/sort-order.enum'; +import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.entities'; +import { QueryParams } from '@osf/shared/entities/query-params.interface'; +import { MyProjectsTableComponent } from '@shared/components/my-projects-table/my-projects-table.component'; @Component({ selector: 'osf-my-projects', imports: [ SubHeaderComponent, DropdownModule, - ReactiveFormsModule, + FormsModule, Select, Tab, TabList, TabPanel, TabPanels, Tabs, - SearchInputComponent, - FormsModule, - DatePipe, - TableModule, - NgClass, - RouterOutlet, + MyProjectsTableComponent, ], templateUrl: './my-projects.component.html', styleUrl: './my-projects.component.scss', @@ -49,10 +61,15 @@ import { Router, RouterOutlet } from '@angular/router'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyProjectsComponent implements OnInit { + readonly #destroyRef = inject(DestroyRef); readonly #dialogService = inject(DialogService); readonly #store = inject(Store); readonly #router = inject(Router); + readonly #route = inject(ActivatedRoute); + readonly #searchSubject = new Subject(); + protected readonly defaultTabValue = 0; + protected readonly isLoading = signal(false); protected readonly isDesktop = toSignal(inject(IS_WEB)); protected readonly isTablet = toSignal(inject(IS_MEDIUM)); protected readonly isMobile = toSignal(inject(IS_XSMALL)); @@ -62,38 +79,317 @@ export class MyProjectsComponent implements OnInit { { label: 'My Preprints', value: 2 }, { label: 'Bookmarks', value: 3 }, ]; + + protected readonly queryParams = toSignal(this.#route.queryParams); + protected readonly currentPage = signal(1); + protected readonly currentPageSize = signal(MY_PROJECTS_TABLE_PARAMS.rows); protected readonly searchValue = signal(''); + protected readonly selectedTab = signal(this.defaultTabValue); + protected readonly activeProject = signal(null); + protected readonly sortColumn = signal(undefined); + protected readonly sortOrder = signal(SortOrder.Asc); + protected readonly tableParams = signal({ + ...MY_PROJECTS_TABLE_PARAMS, + firstRowIndex: 0, + }); + protected readonly projects = this.#store.selectSignal( - HomeSelectors.getProjects, + MyProjectsSelectors.getProjects, ); - protected readonly selectedTab = signal(this.defaultTabValue); - readonly activeProject = signal(null); - - filteredProjects = computed(() => { - const search = this.searchValue().toLowerCase(); - return this.projects().filter( - (project) => - project.title.toLowerCase().includes(search) || - project.bibliographicContributors.some((i) => - i.users.familyName.toLowerCase().includes(search), - ), - ); - }); + protected readonly registrations = this.#store.selectSignal( + MyProjectsSelectors.getRegistrations, + ); + protected readonly preprints = this.#store.selectSignal( + MyProjectsSelectors.getPreprints, + ); + protected readonly bookmarks = this.#store.selectSignal( + MyProjectsSelectors.getBookmarks, + ); + protected readonly totalProjectsCount = this.#store.selectSignal( + MyProjectsSelectors.getTotalProjectsCount, + ); + protected readonly totalRegistrationsCount = this.#store.selectSignal( + MyProjectsSelectors.getTotalRegistrationsCount, + ); + protected readonly totalPreprintsCount = this.#store.selectSignal( + MyProjectsSelectors.getTotalPreprintsCount, + ); + protected readonly totalBookmarksCount = this.#store.selectSignal( + MyProjectsSelectors.getTotalBookmarksCount, + ); + + protected readonly bookmarksCollectionId = this.#store.selectSignal( + MyProjectsSelectors.getBookmarksCollectionId, + ); + + constructor() { + this.#setupQueryParamsEffect(); + this.#setupSearchSubscription(); + this.#setupTotalRecordsEffect(); + this.#setupCleanup(); + } + + ngOnInit(): void { + this.#store.dispatch(new GetBookmarksCollectionId()); + } + + #setupCleanup(): void { + this.#destroyRef.onDestroy(() => { + this.#store.dispatch(new ClearMyProjects()); + }); + } + + #setupSearchSubscription(): void { + this.#searchSubject + .pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntilDestroyed(this.#destroyRef), + ) + .subscribe((searchValue) => { + this.#handleSearch(searchValue); + }); + } + + #setupTotalRecordsEffect(): void { + effect(() => { + const totalRecords = this.#getTotalRecordsForCurrentTab(); + untracked(() => { + this.#updateTableParams({ totalRecords }); + }); + }); + } + + #getTotalRecordsForCurrentTab(): number { + switch (this.selectedTab()) { + case 0: + return this.totalProjectsCount(); + case 1: + return this.totalRegistrationsCount(); + case 2: + return this.totalPreprintsCount(); + case 3: + return this.totalBookmarksCount(); + default: + return 0; + } + } + + #setupQueryParamsEffect(): void { + effect(() => { + const params = this.queryParams(); + if (!params) return; + + const { page, size, search, sortColumn, sortOrder } = + parseQueryFilterParams(params); + + this.#updateComponentState({ page, size, search, sortColumn, sortOrder }); + this.#fetchDataForCurrentTab({ + page, + size, + search, + sortColumn, + sortOrder, + }); + }); + } + + #updateComponentState(params: QueryParams): void { + untracked(() => { + const size = params.size || MY_PROJECTS_TABLE_PARAMS.rows; + + this.currentPage.set(params.page ?? 1); + this.currentPageSize.set(size); + this.searchValue.set(params.search || ''); + this.sortColumn.set(params.sortColumn); + this.sortOrder.set(params.sortOrder ?? SortOrder.Asc); - getContributorsList(item: Project) { - return this.projects() - .find((i) => i.id === item.id) - ?.bibliographicContributors.map((i) => i.users.familyName) - .join(', '); + this.#updateTableParams({ + rows: size, + firstRowIndex: ((params.page ?? 1) - 1) * size, + }); + }); + } + + #updateTableParams(updates: Partial): void { + this.tableParams.update((current) => ({ + ...current, + ...updates, + })); + } + + #fetchDataForCurrentTab(params: QueryParams): void { + this.isLoading.set(true); + const filters = this.#createFilters(params); + const pageNumber = params.page ?? 1; + const pageSize = params.size ?? MY_PROJECTS_TABLE_PARAMS.rows; + + let action$; + switch (this.selectedTab()) { + case 0: + action$ = this.#store.dispatch( + new GetMyProjects(pageNumber, pageSize, filters), + ); + break; + case 1: + action$ = this.#store.dispatch( + new GetMyRegistrations(pageNumber, pageSize, filters), + ); + break; + case 2: + action$ = this.#store.dispatch( + new GetMyPreprints(pageNumber, pageSize, filters), + ); + break; + case 3: + if (this.bookmarksCollectionId()) { + action$ = this.#store.dispatch( + new GetMyBookmarks( + this.bookmarksCollectionId(), + pageNumber, + pageSize, + filters, + ), + ); + } + break; + } + + action$?.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe({ + complete: () => { + this.isLoading.set(false); + }, + error: () => { + this.isLoading.set(false); + }, + }); + + // switch (this.selectedTab()) { + // case 0: + // this.#store.dispatch(new GetMyProjects(pageNumber, pageSize, filters)); + // break; + // case 1: + // this.#store.dispatch( + // new GetMyRegistrations(pageNumber, pageSize, filters), + // ); + // break; + // case 2: + // this.#store.dispatch(new GetMyPreprints(pageNumber, pageSize, filters)); + // break; + // case 3: + // if (this.bookmarksCollectionId()) { + // this.#store.dispatch( + // new GetMyBookmarks( + // this.bookmarksCollectionId(), + // pageNumber, + // pageSize, + // filters, + // ), + // ); + // } + // break; + // } } - createProject(): void { - let dialogWidth = '850px'; + #createFilters(params: QueryParams): MyProjectsSearchFilters { + return { + searchValue: params.search || '', + searchFields: ['title', 'tags', 'description'], + sortColumn: params.sortColumn, + sortOrder: params.sortOrder, + }; + } + + #handleSearch(searchValue: string): void { + const currentParams = this.queryParams() || {}; + this.#updateQueryParams({ + search: searchValue, + page: 1, + sortColumn: currentParams['sortColumn'], + sortOrder: + currentParams['sortOrder'] === 'desc' ? SortOrder.Desc : SortOrder.Asc, + }); + } + + #updateQueryParams(updates: Partial): void { + const currentParams = this.queryParams() || {}; + const queryParams: Record = {}; + + if ('page' in updates || currentParams['page']) { + queryParams['page'] = updates.page?.toString() ?? currentParams['page']; + } + if ('size' in updates || currentParams['size']) { + queryParams['size'] = updates.size?.toString() ?? currentParams['size']; + } + + if ('search' in updates || currentParams['search']) { + const search = updates.search ?? currentParams['search']; + if (search) { + queryParams['search'] = search; + } + } - if (this.isMobile()) { - dialogWidth = '95vw'; + if ('sortColumn' in updates) { + if (updates.sortColumn) { + queryParams['sortColumn'] = updates.sortColumn; + queryParams['sortOrder'] = + updates.sortOrder === SortOrder.Desc ? 'desc' : 'asc'; + } + } else if (currentParams['sortColumn']) { + queryParams['sortColumn'] = currentParams['sortColumn']; + queryParams['sortOrder'] = currentParams['sortOrder']; } + this.#router.navigate([], { + relativeTo: this.#route, + queryParams, + }); + } + + protected onSearchChange(value: string): void { + this.searchValue.set(value); + this.#searchSubject.next(value); + } + + protected onPageChange(event: TablePageEvent): void { + const page = Math.floor(event.first / event.rows) + 1; + const currentParams = this.queryParams() || {}; + + this.#updateQueryParams({ + page, + size: event.rows, + sortColumn: currentParams['sortColumn'], + sortOrder: + currentParams['sortOrder'] === 'desc' ? SortOrder.Desc : SortOrder.Asc, + }); + } + + protected onSort(event: SortEvent): void { + if (event.field) { + this.#updateQueryParams({ + sortColumn: event.field, + sortOrder: event.order === -1 ? SortOrder.Desc : SortOrder.Asc, + }); + } + } + + protected onTabChange(tabIndex: number): void { + this.#store.dispatch(new ClearMyProjects()); + this.selectedTab.set(tabIndex); + const currentParams = this.queryParams() || {}; + + this.#updateQueryParams({ + page: 1, + size: currentParams['size'], + search: '', + sortColumn: undefined, + sortOrder: undefined, + }); + } + + protected createProject(): void { + const dialogWidth = this.isMobile() ? '95vw' : '850px'; + this.#dialogService.open(AddProjectFormComponent, { width: dialogWidth, focusOnShow: false, @@ -104,12 +400,8 @@ export class MyProjectsComponent implements OnInit { }); } - navigateToProject(project: Project): void { + protected navigateToProject(project: MyProjectsItem): void { this.activeProject.set(project); this.#router.navigate(['/my-projects', project.id]); } - - ngOnInit() { - this.#store.dispatch(GetProjects); - } } diff --git a/src/app/features/my-projects/my-projects.service.ts b/src/app/features/my-projects/my-projects.service.ts index 7eb9e1cdd..ce9a73a3d 100644 --- a/src/app/features/my-projects/my-projects.service.ts +++ b/src/app/features/my-projects/my-projects.service.ts @@ -1,6 +1,132 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { Observable } from 'rxjs'; +import { MyProjectsSearchFilters } from '@osf/features/my-projects/entities/my-projects-search-filters.models'; +import { MyProjectsMapper } from '@osf/features/my-projects/mappers/my-projects.mapper'; +import { + MyProjectsItemResponse, + MyProjectsItemGetResponse, + MyProjectsJsonApiResponse, + SparseCollectionsResponse, +} from '@osf/features/my-projects/entities/my-projects.entities'; +import { map } from 'rxjs/operators'; +import { SortOrder } from '@shared/utils/sort-order.enum'; +import { EndpointType } from '@osf/features/my-projects/entities/my-projects.types'; @Injectable({ providedIn: 'root', }) -export class MyProjectsService {} +export class MyProjectsService { + #baseUrl = 'https://api.staging4.osf.io/v2/'; + #jsonApiService = inject(JsonApiService); + #sortFieldMap: Record = { + title: 'title', + dateModified: 'date_modified', + }; + + #getMyItems( + endpoint: EndpointType, + filters?: MyProjectsSearchFilters, + pageNumber?: number, + pageSize?: number, + ): Observable { + const params: Record = { + 'embed[]': ['bibliographic_contributors'], + [`fields[${endpoint}]`]: + 'title,date_modified,public,bibliographic_contributors', + 'fields[users]': 'family_name,full_name,given_name,middle_name', + }; + + 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?.sortColumn && this.#sortFieldMap[filters.sortColumn]) { + const apiField = this.#sortFieldMap[filters.sortColumn]; + const sortPrefix = filters.sortOrder === SortOrder.Desc ? '-' : ''; + params['sort'] = `${sortPrefix}${apiField}`; + } else { + params['sort'] = '-date_modified'; + } + + const url = endpoint.startsWith('collections/') + ? this.#baseUrl + endpoint + : this.#baseUrl + 'users/me/' + endpoint; + + return this.#jsonApiService + .get(url, params) + .pipe( + map((response: MyProjectsJsonApiResponse) => ({ + data: response.data.map((item: MyProjectsItemGetResponse) => + MyProjectsMapper.fromResponse(item), + ), + links: response.links, + })), + ); + } + + getMyProjects( + filters?: MyProjectsSearchFilters, + pageNumber?: number, + pageSize?: number, + ): Observable { + return this.#getMyItems('nodes', filters, pageNumber, pageSize); + } + + getBookmarksCollectionId(): Observable { + const params: Record = { + 'fields[collections]': 'title,bookmarks', + }; + + return this.#jsonApiService + .get(this.#baseUrl + 'collections', params) + .pipe( + map((response) => { + const bookmarksCollection = response.data.find( + (collection) => + collection.attributes.title === 'Bookmarks' && + collection.attributes.bookmarks, + ); + return bookmarksCollection?.id ?? ''; + }), + ); + } + + getMyRegistrations( + filters?: MyProjectsSearchFilters, + pageNumber?: number, + pageSize?: number, + ): Observable { + return this.#getMyItems('registrations', filters, pageNumber, pageSize); + } + + getMyPreprints( + filters?: MyProjectsSearchFilters, + pageNumber?: number, + pageSize?: number, + ): Observable { + return this.#getMyItems('preprints', filters, pageNumber, pageSize); + } + + getMyBookmarks( + collectionId: string, + filters?: MyProjectsSearchFilters, + pageNumber?: number, + pageSize?: number, + ): Observable { + return this.#getMyItems( + `collections/${collectionId}/linked_nodes`, + filters, + pageNumber, + pageSize, + ); + } +} diff --git a/src/app/features/settings/addons/addons.service.ts b/src/app/features/settings/addons/addons.service.ts index 7fb7452b0..39aba6b0e 100644 --- a/src/app/features/settings/addons/addons.service.ts +++ b/src/app/features/settings/addons/addons.service.ts @@ -42,7 +42,9 @@ export class AddonsService { if (!currentUser) throw new Error('Current user not found'); const userUri = `https://staging4.osf.io/${currentUser.id}`; - const params = { 'filter[user_uri]': userUri }; + const params = { + 'filter[user_uri]': userUri, + }; return this.#jsonApiService .get< @@ -55,10 +57,13 @@ export class AddonsService { addonType: string, referenceId: string, ): Observable { + const params = { + [`fields[external-${addonType}-services]`]: 'external_service_name', + }; return this.#jsonApiService .get< JsonApiResponse - >(this.#baseUrl + `user-references/${referenceId}/authorized_${addonType}_accounts?include=external-${addonType}-service`) + >(this.#baseUrl + `user-references/${referenceId}/authorized_${addonType}_accounts?include=external-${addonType}-service`, params) .pipe( map((response) => { return response.data.map((item) => diff --git a/src/app/features/settings/addons/entities/addons.entities.ts b/src/app/features/settings/addons/entities/addons.entities.ts index 3c6435d75..6fb69262b 100644 --- a/src/app/features/settings/addons/entities/addons.entities.ts +++ b/src/app/features/settings/addons/entities/addons.entities.ts @@ -11,18 +11,12 @@ export interface AddonGetResponse { }; relationships: { addon_imp: { - links: { - related: string; - }; data: { type: string; id: string; }; }; }; - links: { - self: string; - }; } export interface AuthorizedAddonGetResponse { @@ -39,46 +33,24 @@ export interface AuthorizedAddonGetResponse { }; relationships: { account_owner: { - links: { - related: string; - }; data: { type: string; id: string; }; }; - authorized_operations: { - links: { - related: string; - }; - }; - configured_storage_addons: { - links: { - related: string; - }; - }; external_storage_service?: { - links: { - related: string; - }; data: { type: string; id: string; }; }; external_citation_service?: { - links: { - related: string; - }; data: { type: string; id: string; }; }; }; - links: { - self: string; - }; } export interface Addon { @@ -117,18 +89,12 @@ export interface IncludedAddonData { relationships?: Record< string, { - links: { - related: string; - }; data?: { type: string; id: string; }; } >; - links?: { - self: string; - }; } export interface UserReference { @@ -137,31 +103,6 @@ export interface UserReference { attributes: { user_uri: string; }; - relationships: { - authorized_storage_accounts: { - links: { - related: string; - }; - }; - authorized_citation_accounts: { - links: { - related: string; - }; - }; - authorized_computing_accounts: { - links: { - related: string; - }; - }; - configured_resources: { - links: { - related: string; - }; - }; - }; - links: { - self: string; - }; } export interface AddonRequest { @@ -214,30 +155,16 @@ export interface AddonResponse { }; relationships: { account_owner: { - links: { - related: string; - }; data: { type: 'user-references'; id: string; }; }; - authorized_operations: { - links: { - related: string; - }; - }; external_storage_service: { - links: { - related: string; - }; data: { type: string; id: string; }; }; }; - links: { - self: string; - }; } diff --git a/src/app/features/settings/tokens/entities/tokens.models.ts b/src/app/features/settings/tokens/entities/tokens.models.ts index bb278dae6..b1cee985b 100644 --- a/src/app/features/settings/tokens/entities/tokens.models.ts +++ b/src/app/features/settings/tokens/entities/tokens.models.ts @@ -19,10 +19,6 @@ export interface TokenCreateResponse { scopes: string; owner: string; }; - links: { - html: string; - self: string; - }; } // API Response Model for GET request @@ -34,10 +30,6 @@ export interface TokenGetResponse { scopes: string; owner: string; }; - links: { - html: string; - self: string; - }; } // Domain Models @@ -47,6 +39,4 @@ export interface Token { tokenId: string; scopes: string[]; ownerId: string; - htmlUrl: string; - apiUrl: string; } diff --git a/src/app/features/settings/tokens/token.mapper.ts b/src/app/features/settings/tokens/token.mapper.ts index 3bd6de110..60060c003 100644 --- a/src/app/features/settings/tokens/token.mapper.ts +++ b/src/app/features/settings/tokens/token.mapper.ts @@ -25,8 +25,6 @@ export class TokenMapper { tokenId: response.attributes.token_id, scopes: response.attributes.scopes.split(' '), ownerId: response.attributes.owner, - htmlUrl: response.links.html, - apiUrl: response.links.self, }; } @@ -37,8 +35,6 @@ export class TokenMapper { tokenId: response.id, scopes: response.attributes.scopes.split(' '), ownerId: response.attributes.owner, - htmlUrl: response.links.html, - apiUrl: response.links.self, }; } } 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 new file mode 100644 index 000000000..2c61deb38 --- /dev/null +++ b/src/app/shared/components/my-projects-table/my-projects-table.component.html @@ -0,0 +1,86 @@ +
+ + @if (isLoading()) { + + + + + Title + + + Contributors + + Modified + + + + + + + + + + + + + } @else { + + + + + Title + + + Contributors + + Modified + + + + + + + +

+ + {{ item.title }} +

+ + + @for (contributor of item.contributors; track contributor) { + {{ contributor.fullName }}{{ $last ? "" : ", " }} + } + + {{ item.dateModified | date: "MMM d, y, h:mm a" }} + +
+
+ } +
diff --git a/src/app/shared/components/my-projects-table/my-projects-table.component.scss b/src/app/shared/components/my-projects-table/my-projects-table.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/my-projects-table/my-projects-table.component.ts b/src/app/shared/components/my-projects-table/my-projects-table.component.ts new file mode 100644 index 000000000..06198fae9 --- /dev/null +++ b/src/app/shared/components/my-projects-table/my-projects-table.component.ts @@ -0,0 +1,53 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TableModule, TablePageEvent } from 'primeng/table'; +import { SortEvent } from 'primeng/api'; +import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; +import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.entities'; +import { TableParameters } from '@shared/entities/table-parameters.interface'; +import { SortOrder } from '@shared/utils/sort-order.enum'; +import { Skeleton } from 'primeng/skeleton'; + +@Component({ + selector: 'osf-my-projects-table', + standalone: true, + imports: [CommonModule, TableModule, SearchInputComponent, Skeleton], + templateUrl: './my-projects-table.component.html', + styleUrl: './my-projects-table.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyProjectsTableComponent { + items = input([]); + tableParams = input.required(); + searchValue = input(''); + sortColumn = input(undefined); + sortOrder = input(SortOrder.Asc); + isLoading = input(false); + searchPlaceholder = input('Filter by title, description, and tags'); + + searchValueChange = output(); + pageChange = output(); + sort = output(); + itemClick = output(); + + protected onSearchChange(value: string | undefined): void { + this.searchValueChange.emit(value ?? ''); + } + + protected onPageChange(event: TablePageEvent): void { + this.pageChange.emit(event); + } + + protected onSort(event: SortEvent): void { + this.sort.emit(event); + } + + protected onItemClick(item: MyProjectsItem): void { + this.itemClick.emit(item); + } +} diff --git a/src/app/shared/entities/query-params.interface.ts b/src/app/shared/entities/query-params.interface.ts new file mode 100644 index 000000000..fcdfda1bb --- /dev/null +++ b/src/app/shared/entities/query-params.interface.ts @@ -0,0 +1,9 @@ +import { SortOrder } from '../utils/sort-order.enum'; + +export interface QueryParams { + page?: number; + size?: number; + search?: string; + sortColumn?: string; + sortOrder?: SortOrder; +} diff --git a/src/app/shared/entities/table-parameters.interface.ts b/src/app/shared/entities/table-parameters.interface.ts new file mode 100644 index 000000000..108614b72 --- /dev/null +++ b/src/app/shared/entities/table-parameters.interface.ts @@ -0,0 +1,12 @@ +import { SortOrder } from '@shared/utils/sort-order.enum'; + +export interface TableParameters { + rows: number; + paginator: boolean; + scrollable: boolean; + rowsPerPageOptions: number[]; + totalRecords: number; + firstRowIndex: number; + defaultSortOrder?: SortOrder | null; + defaultSortColumn?: string | null; +} diff --git a/src/app/shared/utils/sort-order.enum.ts b/src/app/shared/utils/sort-order.enum.ts new file mode 100644 index 000000000..34fa13f9a --- /dev/null +++ b/src/app/shared/utils/sort-order.enum.ts @@ -0,0 +1,4 @@ +export enum SortOrder { + Asc = 0, + Desc = 1, +} diff --git a/src/assets/styles/overrides/table.scss b/src/assets/styles/overrides/table.scss index 44c2e847b..52362e375 100644 --- a/src/assets/styles/overrides/table.scss +++ b/src/assets/styles/overrides/table.scss @@ -47,6 +47,21 @@ } } + .my-projects-table { + &.loading { + table { + tr.loading-row { + td { + background: transparent; + border: none; + padding: 0; + width: 100%; + } + } + } + } + } + .addon-table { tr { &.background-warning td {