From 5ecfad22fd2ae8fab486d7608f074bd09423af91 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Thu, 1 May 2025 16:05:43 +0300 Subject: [PATCH 1/3] feat(my-projects-api-integration): integrated my projects page api --- src/app/app.config.ts | 2 +- .../breadcrumb/breadcrumb.component.ts | 3 +- .../components/header/header.component.html | 2 +- .../components/nav-menu/nav-menu.component.ts | 2 +- .../constants/my-projects-table.constants.ts | 12 + .../nav-items.constant.ts | 2 +- .../ngxs-states.constant.ts | 4 +- src/app/core/helpers/http.helper.ts | 27 ++ .../services/json-api/json-api.service.ts | 1 + src/app/core/store/my-projects/index.ts | 4 + .../store/my-projects/my-projects.actions.ts | 49 +++ .../store/my-projects/my-projects.model.ts | 13 + .../my-projects/my-projects.selectors.ts | 50 +++ .../store/my-projects/my-projects.state.ts | 127 ++++++ src/app/features/home/home.component.html | 46 +-- src/app/features/home/home.component.ts | 226 +++++++++-- .../my-projects-search-filters.models.ts | 10 + .../entities/my-projects.entities.ts | 85 ++++ .../my-projects/entities/my-projects.types.ts | 5 + .../mappers/my-preprints.mapper.ts | 0 .../my-projects/mappers/my-projects.mapper.ts | 24 ++ .../mappers/my-registrations.mapper.ts | 0 .../my-projects/my-projects.component.html | 191 +++------ .../my-projects/my-projects.component.ts | 380 ++++++++++++++++-- .../my-projects/my-projects.service.ts | 130 +++++- .../settings/addons/addons.service.ts | 9 +- .../addons/entities/addons.entities.ts | 73 ---- .../settings/tokens/entities/tokens.models.ts | 10 - .../features/settings/tokens/token.mapper.ts | 4 - .../my-projects-table.component.html | 86 ++++ .../my-projects-table.component.scss | 0 .../my-projects-table.component.ts | 54 +++ .../shared/entities/query-params.interface.ts | 9 + .../entities/table-parameters.interface.ts | 12 + src/app/shared/utils/sort-order.enum.ts | 4 + src/assets/styles/overrides/table.scss | 15 + 36 files changed, 1318 insertions(+), 353 deletions(-) create mode 100644 src/app/core/constants/my-projects-table.constants.ts rename src/app/core/{helpers => constants}/nav-items.constant.ts (95%) rename src/app/core/{helpers => constants}/ngxs-states.constant.ts (73%) create mode 100644 src/app/core/helpers/http.helper.ts create mode 100644 src/app/core/store/my-projects/index.ts create mode 100644 src/app/core/store/my-projects/my-projects.actions.ts create mode 100644 src/app/core/store/my-projects/my-projects.model.ts create mode 100644 src/app/core/store/my-projects/my-projects.selectors.ts create mode 100644 src/app/core/store/my-projects/my-projects.state.ts create mode 100644 src/app/features/my-projects/entities/my-projects-search-filters.models.ts create mode 100644 src/app/features/my-projects/entities/my-projects.entities.ts create mode 100644 src/app/features/my-projects/entities/my-projects.types.ts create mode 100644 src/app/features/my-projects/mappers/my-preprints.mapper.ts create mode 100644 src/app/features/my-projects/mappers/my-projects.mapper.ts create mode 100644 src/app/features/my-projects/mappers/my-registrations.mapper.ts create mode 100644 src/app/shared/components/my-projects-table/my-projects-table.component.html create mode 100644 src/app/shared/components/my-projects-table/my-projects-table.component.scss create mode 100644 src/app/shared/components/my-projects-table/my-projects-table.component.ts create mode 100644 src/app/shared/entities/query-params.interface.ts create mode 100644 src/app/shared/entities/table-parameters.interface.ts create mode 100644 src/app/shared/utils/sort-order.enum.ts 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..5e853cba0 --- /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..f25d0fce1 --- /dev/null +++ b/src/app/shared/components/my-projects-table/my-projects-table.component.ts @@ -0,0 +1,54 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + Output, + EventEmitter, +} 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 { + @Input() items: MyProjectsItem[] = []; + @Input() tableParams!: TableParameters; + @Input() searchValue = ''; + @Input() sortColumn?: string; + @Input() sortOrder: SortOrder = SortOrder.Asc; + @Input() isLoading = false; + @Input() searchPlaceholder = 'Filter by title, description, and tags'; + + @Output() searchValueChange = new EventEmitter(); + @Output() pageChange = new EventEmitter(); + @Output() sort = new EventEmitter(); + @Output() itemClick = new EventEmitter(); + + 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 { From fc03067ca329c6835bdea58c41b4d056057b010d Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Thu, 1 May 2025 16:19:13 +0300 Subject: [PATCH 2/3] feat(my-projects-api-integration): replaced old input/output syntax with modern signals approach --- .../my-projects-table.component.html | 18 ++++++------- .../my-projects-table.component.ts | 27 +++++++++---------- 2 files changed, 22 insertions(+), 23 deletions(-) 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 5e853cba0..2c61deb38 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 @@ -1,10 +1,10 @@
- @if (isLoading) { + @if (isLoading()) { @@ -30,12 +30,12 @@ } @else { 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 index f25d0fce1..06198fae9 100644 --- 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 @@ -1,9 +1,8 @@ import { ChangeDetectionStrategy, Component, - Input, - Output, - EventEmitter, + input, + output, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TableModule, TablePageEvent } from 'primeng/table'; @@ -23,18 +22,18 @@ import { Skeleton } from 'primeng/skeleton'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyProjectsTableComponent { - @Input() items: MyProjectsItem[] = []; - @Input() tableParams!: TableParameters; - @Input() searchValue = ''; - @Input() sortColumn?: string; - @Input() sortOrder: SortOrder = SortOrder.Asc; - @Input() isLoading = false; - @Input() searchPlaceholder = 'Filter by title, description, and tags'; + 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'); - @Output() searchValueChange = new EventEmitter(); - @Output() pageChange = new EventEmitter(); - @Output() sort = new EventEmitter(); - @Output() itemClick = new EventEmitter(); + searchValueChange = output(); + pageChange = output(); + sort = output(); + itemClick = output(); protected onSearchChange(value: string | undefined): void { this.searchValueChange.emit(value ?? ''); From cea101d803248d248755c5efe5ab6653bce73ddf Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Fri, 2 May 2025 18:15:18 +0300 Subject: [PATCH 3/3] feat(create-project-api-integration): added create project functionality --- .../core/constants/ngxs-states.constant.ts | 2 + .../constants/storage-locations.constant.ts | 8 ++ .../services/json-api/json-api.service.ts | 11 ++- src/app/core/store/institutions/index.ts | 4 + .../institutions/institutions.actions.ts | 3 + .../store/institutions/institutions.model.ts | 5 ++ .../institutions/institutions.selectors.ts | 10 +++ .../store/institutions/institutions.state.ts | 28 ++++++ .../store/my-projects/my-projects.actions.ts | 12 +++ .../store/my-projects/my-projects.state.ts | 25 ++++++ src/app/features/home/home.component.html | 1 + src/app/features/home/home.component.ts | 31 ++++++- .../entities/institutions.models.ts | 35 ++++++++ .../institutions/institutions.service.ts | 29 ++++++ .../mappers/institutions.mapper.ts | 22 +++++ .../entities/create-project.entities.ts | 25 ++++++ .../my-projects/entities/my-projects.types.ts | 2 +- .../entities/storage-location.interface.ts | 4 + .../mappers/my-preprints.mapper.ts | 0 .../mappers/my-registrations.mapper.ts | 0 .../my-projects/my-projects.component.ts | 30 +------ .../my-projects/my-projects.service.ts | 58 +++++++++++- .../settings/addons/addons.service.ts | 10 +-- .../settings/tokens/tokens.service.ts | 10 +-- .../add-project-form.component.html | 88 ++++++++++--------- .../add-project-form.component.scss | 0 .../add-project-form.component.spec.ts | 0 .../add-project-form.component.ts | 82 ++++++++++------- .../my-projects-table.component.html | 2 +- .../create-project-form-controls.enum.ts | 7 ++ .../entities/create-project-form.interface.ts | 10 +++ src/assets/styles/overrides/input.scss | 1 + src/assets/styles/overrides/table.scss | 12 +++ 33 files changed, 447 insertions(+), 120 deletions(-) create mode 100644 src/app/core/constants/storage-locations.constant.ts create mode 100644 src/app/core/store/institutions/index.ts create mode 100644 src/app/core/store/institutions/institutions.actions.ts create mode 100644 src/app/core/store/institutions/institutions.model.ts create mode 100644 src/app/core/store/institutions/institutions.selectors.ts create mode 100644 src/app/core/store/institutions/institutions.state.ts create mode 100644 src/app/features/institutions/entities/institutions.models.ts create mode 100644 src/app/features/institutions/institutions.service.ts create mode 100644 src/app/features/institutions/mappers/institutions.mapper.ts create mode 100644 src/app/features/my-projects/entities/create-project.entities.ts create mode 100644 src/app/features/my-projects/entities/storage-location.interface.ts delete mode 100644 src/app/features/my-projects/mappers/my-preprints.mapper.ts delete mode 100644 src/app/features/my-projects/mappers/my-registrations.mapper.ts rename src/app/{features/my-projects => shared/components}/add-project-form/add-project-form.component.html (52%) rename src/app/{features/my-projects => shared/components}/add-project-form/add-project-form.component.scss (100%) rename src/app/{features/my-projects => shared/components}/add-project-form/add-project-form.component.spec.ts (100%) rename src/app/{features/my-projects => shared/components}/add-project-form/add-project-form.component.ts (63%) create mode 100644 src/app/shared/entities/create-project-form-controls.enum.ts create mode 100644 src/app/shared/entities/create-project-form.interface.ts diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 4c70b0a90..afb99d181 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -4,6 +4,7 @@ import { AddonsState } from '@core/store/settings/addons'; import { UserState } from '@core/store/user'; import { MyProjectsState } from '@core/store/my-projects'; import { SearchState } from '@osf/features/search/store'; +import { InstitutionsState } from '@core/store/institutions'; export const STATES = [ AuthState, @@ -12,4 +13,5 @@ export const STATES = [ UserState, SearchState, MyProjectsState, + InstitutionsState, ]; diff --git a/src/app/core/constants/storage-locations.constant.ts b/src/app/core/constants/storage-locations.constant.ts new file mode 100644 index 000000000..bfa9226e6 --- /dev/null +++ b/src/app/core/constants/storage-locations.constant.ts @@ -0,0 +1,8 @@ +import { StorageLocation } from '@osf/features/my-projects/entities/storage-location.interface'; + +export const STORAGE_LOCATIONS: StorageLocation[] = [ + { label: 'United States', value: 'us' }, + { label: 'Canada - Montréal', value: 'ca-1' }, + { label: 'Germany - Frankfurt', value: 'de-1' }, + { label: 'Australia - Sydney', value: 'de-1' }, +]; 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 c1ce33697..b5eb8e6ac 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -44,9 +44,16 @@ export class JsonApiService { return httpParams; } - post(url: string, body: unknown): Observable { + post( + url: string, + body: unknown, + params?: Record, + ): Observable { return this.http - .post>(url, body, { headers: this.#headers }) + .post>(url, body, { + headers: this.#headers, + params: this.buildHttpParams(params), + }) .pipe(map((response) => response.data)); } diff --git a/src/app/core/store/institutions/index.ts b/src/app/core/store/institutions/index.ts new file mode 100644 index 000000000..63e4169a1 --- /dev/null +++ b/src/app/core/store/institutions/index.ts @@ -0,0 +1,4 @@ +export * from './institutions.state'; +export * from './institutions.selectors'; +export * from './institutions.model'; +export * from './institutions.actions'; diff --git a/src/app/core/store/institutions/institutions.actions.ts b/src/app/core/store/institutions/institutions.actions.ts new file mode 100644 index 000000000..2af0d870e --- /dev/null +++ b/src/app/core/store/institutions/institutions.actions.ts @@ -0,0 +1,3 @@ +export class GetUserInstitutions { + static readonly type = '[Institutions] Get User Institutions'; +} diff --git a/src/app/core/store/institutions/institutions.model.ts b/src/app/core/store/institutions/institutions.model.ts new file mode 100644 index 000000000..1849f2abf --- /dev/null +++ b/src/app/core/store/institutions/institutions.model.ts @@ -0,0 +1,5 @@ +import { Institution } from '@osf/features/institutions/entities/institutions.models'; + +export interface InstitutionsStateModel { + userInstitutions: Institution[]; +} diff --git a/src/app/core/store/institutions/institutions.selectors.ts b/src/app/core/store/institutions/institutions.selectors.ts new file mode 100644 index 000000000..cb6ab2b0b --- /dev/null +++ b/src/app/core/store/institutions/institutions.selectors.ts @@ -0,0 +1,10 @@ +import { Selector } from '@ngxs/store'; +import { InstitutionsStateModel } from './institutions.model'; +import { InstitutionsState } from './institutions.state'; + +export class InstitutionsSelectors { + @Selector([InstitutionsState]) + static getUserInstitutions(state: InstitutionsStateModel) { + return state.userInstitutions; + } +} diff --git a/src/app/core/store/institutions/institutions.state.ts b/src/app/core/store/institutions/institutions.state.ts new file mode 100644 index 000000000..0ef67fe23 --- /dev/null +++ b/src/app/core/store/institutions/institutions.state.ts @@ -0,0 +1,28 @@ +import { inject, Injectable } from '@angular/core'; +import { State, Action, StateContext } from '@ngxs/store'; +import { InstitutionsStateModel } from './institutions.model'; +import { GetUserInstitutions } from './institutions.actions'; +import { InstitutionsService } from '@osf/features/institutions/institutions.service'; +import { tap } from 'rxjs'; + +@State({ + name: 'institutions', + defaults: { + userInstitutions: [], + }, +}) +@Injectable() +export class InstitutionsState { + #institutionsService = inject(InstitutionsService); + + @Action(GetUserInstitutions) + getUserInstitutions(ctx: StateContext) { + return this.#institutionsService.getUserInstitutions().pipe( + tap((institutions) => { + ctx.patchState({ + userInstitutions: institutions, + }); + }), + ); + } +} diff --git a/src/app/core/store/my-projects/my-projects.actions.ts b/src/app/core/store/my-projects/my-projects.actions.ts index 170bc8bc1..1773823fa 100644 --- a/src/app/core/store/my-projects/my-projects.actions.ts +++ b/src/app/core/store/my-projects/my-projects.actions.ts @@ -47,3 +47,15 @@ export class GetBookmarksCollectionId { export class ClearMyProjects { static readonly type = '[My Projects] Clear Projects'; } + +export class CreateProject { + static readonly type = '[MyProjects] Create Project'; + + constructor( + public title: string, + public description: string, + public templateFrom: string, + public region: string, + public affiliations: string[], + ) {} +} diff --git a/src/app/core/store/my-projects/my-projects.state.ts b/src/app/core/store/my-projects/my-projects.state.ts index 819b34c20..afdca89b8 100644 --- a/src/app/core/store/my-projects/my-projects.state.ts +++ b/src/app/core/store/my-projects/my-projects.state.ts @@ -8,6 +8,7 @@ import { GetMyBookmarks, GetBookmarksCollectionId, ClearMyProjects, + CreateProject, } from './my-projects.actions'; import { MyProjectsService } from '@osf/features/my-projects/my-projects.service'; import { tap } from 'rxjs'; @@ -124,4 +125,28 @@ export class MyProjectsState { totalBookmarks: 0, }); } + + @Action(CreateProject) + createProject( + ctx: StateContext, + action: CreateProject, + ) { + return this.myProjectsService + .createProject( + action.title, + action.description, + action.templateFrom, + action.region, + action.affiliations, + ) + .pipe( + tap((project) => { + const state = ctx.getState(); + ctx.patchState({ + projects: [project, ...state.projects], + totalProjects: state.totalProjects + 1, + }); + }), + ); + } } diff --git a/src/app/features/home/home.component.html b/src/app/features/home/home.component.html index 9e1317b38..bb496f3d0 100644 --- a/src/app/features/home/home.component.html +++ b/src/app/features/home/home.component.html @@ -8,6 +8,7 @@ [title]="'Dashboard'" [icon]="'home'" [buttonLabel]="'Create New Project'" + (buttonClick)="createProject()" />
diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts index dfcabefd3..fd210e573 100644 --- a/src/app/features/home/home.component.ts +++ b/src/app/features/home/home.component.ts @@ -27,6 +27,9 @@ import { 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'; +import { GetUserInstitutions } from '@osf/core/store/institutions'; +import { DialogService } from 'primeng/dynamicdialog'; +import { AddProjectFormComponent } from '@shared/components/add-project-form/add-project-form.component'; @Component({ selector: 'osf-home', @@ -34,18 +37,23 @@ import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.e imports: [RouterLink, Button, SubHeaderComponent, MyProjectsTableComponent], templateUrl: './home.component.html', styleUrl: './home.component.scss', + providers: [DialogService], }) export class HomeComponent implements OnInit { readonly #destroyRef = inject(DestroyRef); readonly #store = inject(Store); readonly #router = inject(Router); readonly #route = inject(ActivatedRoute); + readonly #dialogService = inject(DialogService); + readonly #isXSmall$ = inject(IS_XSMALL); + readonly #isMedium$ = inject(IS_MEDIUM); readonly #searchSubject = new Subject(); protected readonly isLoading = signal(false); + protected readonly isSubmitting = signal(false); - protected readonly isMedium = toSignal(inject(IS_MEDIUM)); - protected readonly isMobile = toSignal(inject(IS_XSMALL)); + protected readonly isMedium = toSignal(this.#isMedium$); + protected readonly isMobile = toSignal(this.#isXSmall$); protected readonly activeProject = signal(null); protected readonly searchValue = signal(''); @@ -77,6 +85,7 @@ export class HomeComponent implements OnInit { ngOnInit() { this.#setupQueryParamsSubscription(); + this.#store.dispatch(new GetUserInstitutions()); } #setupQueryParamsSubscription(): void { @@ -214,4 +223,22 @@ export class HomeComponent implements OnInit { this.activeProject.set(project); this.#router.navigate(['/my-projects', project.id]); } + + protected createProject(): void { + const dialogWidth = this.isMobile() ? '95vw' : '850px'; + this.isSubmitting.set(true); + + const dialogRef = this.#dialogService.open(AddProjectFormComponent, { + width: dialogWidth, + focusOnShow: false, + header: 'Create Project', + closeOnEscape: true, + modal: true, + closable: true, + }); + + dialogRef.onClose.subscribe(() => { + this.isSubmitting.set(false); + }); + } } diff --git a/src/app/features/institutions/entities/institutions.models.ts b/src/app/features/institutions/entities/institutions.models.ts new file mode 100644 index 000000000..4f1f8b8a9 --- /dev/null +++ b/src/app/features/institutions/entities/institutions.models.ts @@ -0,0 +1,35 @@ +export interface InstitutionAssets { + logo: string; + logo_rounded: string; + banner: string; +} + +export interface InstitutionAttributes { + name: string; + description: string; + iri: string; + ror_iri: string | null; + iris: string[]; + assets: InstitutionAssets; + institutional_request_access_enabled: boolean; + logo_path: string; +} + +export interface UserInstitutionGetResponse { + id: string; + type: string; + attributes: InstitutionAttributes; +} + +export interface Institution { + id: string; + type: string; + name: string; + description: string; + iri: string; + rorIri: string | null; + iris: string[]; + assets: InstitutionAssets; + institutionalRequestAccessEnabled: boolean; + logoPath: string; +} diff --git a/src/app/features/institutions/institutions.service.ts b/src/app/features/institutions/institutions.service.ts new file mode 100644 index 000000000..21d453ddd --- /dev/null +++ b/src/app/features/institutions/institutions.service.ts @@ -0,0 +1,29 @@ +import { inject, Injectable } from '@angular/core'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { Observable } from 'rxjs'; +import { + Institution, + UserInstitutionGetResponse, +} from './entities/institutions.models'; +import { JsonApiResponse } from '@core/services/json-api/json-api.entity'; +import { map } from 'rxjs/operators'; +import { InstitutionsMapper } from './mappers/institutions.mapper'; + +@Injectable({ + providedIn: 'root', +}) +export class InstitutionsService { + #baseUrl = 'https://api.staging4.osf.io/v2/'; + #jsonApiService = inject(JsonApiService); + + getUserInstitutions(): Observable { + const url = this.#baseUrl + 'users/me/institutions/'; + return this.#jsonApiService + .get>(url) + .pipe( + map((response) => + response.data.map((item) => InstitutionsMapper.fromResponse(item)), + ), + ); + } +} diff --git a/src/app/features/institutions/mappers/institutions.mapper.ts b/src/app/features/institutions/mappers/institutions.mapper.ts new file mode 100644 index 000000000..c4e4f9254 --- /dev/null +++ b/src/app/features/institutions/mappers/institutions.mapper.ts @@ -0,0 +1,22 @@ +import { + Institution, + UserInstitutionGetResponse, +} from '../entities/institutions.models'; + +export class InstitutionsMapper { + static fromResponse(response: UserInstitutionGetResponse): Institution { + return { + id: response.id, + type: response.type, + name: response.attributes.name, + description: response.attributes.description, + iri: response.attributes.iri, + rorIri: response.attributes.ror_iri, + iris: response.attributes.iris, + assets: response.attributes.assets, + institutionalRequestAccessEnabled: + response.attributes.institutional_request_access_enabled, + logoPath: response.attributes.logo_path, + }; + } +} diff --git a/src/app/features/my-projects/entities/create-project.entities.ts b/src/app/features/my-projects/entities/create-project.entities.ts new file mode 100644 index 000000000..ea95d68ef --- /dev/null +++ b/src/app/features/my-projects/entities/create-project.entities.ts @@ -0,0 +1,25 @@ +export interface CreateProjectPayload { + data: { + type: 'nodes'; + attributes: { + title: string; + description?: string; + category: 'project'; + template_from?: string; + }; + relationships: { + region: { + data: { + type: 'regions'; + id: string; + }; + }; + affiliated_institutions?: { + data: { + type: 'institutions'; + id: string; + }[]; + }; + }; + }; +} diff --git a/src/app/features/my-projects/entities/my-projects.types.ts b/src/app/features/my-projects/entities/my-projects.types.ts index 58cbe7285..9bf202d9a 100644 --- a/src/app/features/my-projects/entities/my-projects.types.ts +++ b/src/app/features/my-projects/entities/my-projects.types.ts @@ -2,4 +2,4 @@ export type EndpointType = | 'nodes' | 'registrations' | 'preprints' - | `collections/${string}/linked_nodes`; + | `collections/${string}/linked_nodes/`; diff --git a/src/app/features/my-projects/entities/storage-location.interface.ts b/src/app/features/my-projects/entities/storage-location.interface.ts new file mode 100644 index 000000000..e41bb3b43 --- /dev/null +++ b/src/app/features/my-projects/entities/storage-location.interface.ts @@ -0,0 +1,4 @@ +export interface StorageLocation { + label: string; + value: string; +} diff --git a/src/app/features/my-projects/mappers/my-preprints.mapper.ts b/src/app/features/my-projects/mappers/my-preprints.mapper.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/my-projects/mappers/my-registrations.mapper.ts b/src/app/features/my-projects/mappers/my-registrations.mapper.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 8fdcaf079..83f194776 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -19,7 +19,7 @@ import { TabOption } from '@shared/entities/tab-option.interface'; 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 { AddProjectFormComponent } from '@shared/components/add-project-form/add-project-form.component'; import { Store } from '@ngxs/store'; import { ActivatedRoute, Router } from '@angular/router'; import { @@ -40,6 +40,7 @@ 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'; +import { GetUserInstitutions } from '@core/store/institutions'; @Component({ selector: 'osf-my-projects', @@ -130,6 +131,7 @@ export class MyProjectsComponent implements OnInit { } ngOnInit(): void { + this.#store.dispatch(new GetUserInstitutions()); this.#store.dispatch(new GetBookmarksCollectionId()); } @@ -263,32 +265,6 @@ export class MyProjectsComponent implements OnInit { 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; - // } } #createFilters(params: QueryParams): MyProjectsSearchFilters { diff --git a/src/app/features/my-projects/my-projects.service.ts b/src/app/features/my-projects/my-projects.service.ts index ce9a73a3d..2d8e7be38 100644 --- a/src/app/features/my-projects/my-projects.service.ts +++ b/src/app/features/my-projects/my-projects.service.ts @@ -8,10 +8,12 @@ import { MyProjectsItemGetResponse, MyProjectsJsonApiResponse, SparseCollectionsResponse, + MyProjectsItem, } 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'; +import { CreateProjectPayload } from './entities/create-project.entities'; @Injectable({ providedIn: 'root', @@ -56,7 +58,7 @@ export class MyProjectsService { } else { params['sort'] = '-date_modified'; } - + // const url = this.#baseUrl + endpoint + '/'; const url = endpoint.startsWith('collections/') ? this.#baseUrl + endpoint : this.#baseUrl + 'users/me/' + endpoint; @@ -87,7 +89,7 @@ export class MyProjectsService { }; return this.#jsonApiService - .get(this.#baseUrl + 'collections', params) + .get(this.#baseUrl + 'collections/', params) .pipe( map((response) => { const bookmarksCollection = response.data.find( @@ -123,10 +125,60 @@ export class MyProjectsService { pageSize?: number, ): Observable { return this.#getMyItems( - `collections/${collectionId}/linked_nodes`, + `collections/${collectionId}/linked_nodes/`, filters, pageNumber, pageSize, ); } + + createProject( + title: string, + description: string, + templateFrom: string, + region: string, + affiliations: string[], + ): Observable { + const payload: CreateProjectPayload = { + data: { + type: 'nodes', + attributes: { + title, + ...(description && { description }), + category: 'project', + ...(templateFrom && { template_from: templateFrom }), + }, + relationships: { + region: { + data: { + type: 'regions', + id: region, + }, + }, + ...(affiliations.length > 0 && { + affiliated_institutions: { + data: affiliations.map((id) => ({ + type: 'institutions', + id, + })), + }, + }), + }, + }, + }; + + const params: Record = { + 'embed[]': ['bibliographic_contributors'], + 'fields[nodes]': 'title,date_modified,public,bibliographic_contributors', + 'fields[users]': 'family_name,full_name,given_name,middle_name', + }; + + return this.#jsonApiService + .post( + `${this.#baseUrl}nodes/`, + payload, + params, + ) + .pipe(map((response) => MyProjectsMapper.fromResponse(response))); + } } diff --git a/src/app/features/settings/addons/addons.service.ts b/src/app/features/settings/addons/addons.service.ts index 39aba6b0e..f4abc3843 100644 --- a/src/app/features/settings/addons/addons.service.ts +++ b/src/app/features/settings/addons/addons.service.ts @@ -49,7 +49,7 @@ export class AddonsService { return this.#jsonApiService .get< JsonApiResponse - >(this.#baseUrl + 'user-references', params) + >(this.#baseUrl + 'user-references/', params) .pipe(map((response) => response.data)); } @@ -63,7 +63,7 @@ export class AddonsService { return this.#jsonApiService .get< JsonApiResponse - >(this.#baseUrl + `user-references/${referenceId}/authorized_${addonType}_accounts?include=external-${addonType}-service`, params) + >(this.#baseUrl + `user-references/${referenceId}/authorized_${addonType}_accounts/?include=external-${addonType}-service`, params) .pipe( map((response) => { return response.data.map((item) => @@ -78,7 +78,7 @@ export class AddonsService { addonType: string, ): Observable { return this.#jsonApiService.post( - this.#baseUrl + `authorized-${addonType}-accounts`, + this.#baseUrl + `authorized-${addonType}-accounts/`, addonRequestPayload, ); } @@ -89,14 +89,14 @@ export class AddonsService { addonId: string, ): Observable { return this.#jsonApiService.patch( - this.#baseUrl + `authorized-${addonType}-accounts/${addonId}`, + this.#baseUrl + `authorized-${addonType}-accounts/${addonId}/`, addonRequestPayload, ); } deleteAuthorizedAddon(id: string, addonType: string): Observable { return this.#jsonApiService.delete( - this.#baseUrl + `authorized-${addonType}-accounts/${id}`, + this.#baseUrl + `authorized-${addonType}-accounts/${id}/`, ); } } diff --git a/src/app/features/settings/tokens/tokens.service.ts b/src/app/features/settings/tokens/tokens.service.ts index 8017bbe47..b6f6c4034 100644 --- a/src/app/features/settings/tokens/tokens.service.ts +++ b/src/app/features/settings/tokens/tokens.service.ts @@ -20,13 +20,13 @@ export class TokensService { getScopes(): Observable { return this.jsonApiService - .get>(this.baseUrl + 'scopes') + .get>(this.baseUrl + 'scopes/') .pipe(map((responses) => responses.data)); } getTokens(): Observable { return this.jsonApiService - .get>(this.baseUrl + 'tokens') + .get>(this.baseUrl + 'tokens/') .pipe( map((responses) => { return responses.data.map((response) => @@ -40,7 +40,7 @@ export class TokensService { return this.jsonApiService .get< JsonApiResponse - >(this.baseUrl + `tokens/${tokenId}`) + >(this.baseUrl + `tokens/${tokenId}/`) .pipe(map((response) => TokenMapper.fromGetResponse(response.data))); } @@ -60,11 +60,11 @@ export class TokensService { const request = TokenMapper.toRequest(name, scopes); return this.jsonApiService - .patch(this.baseUrl + `tokens/${tokenId}`, request) + .patch(this.baseUrl + `tokens/${tokenId}/`, request) .pipe(map((response) => TokenMapper.fromCreateResponse(response))); } deleteToken(tokenId: string): Observable { - return this.jsonApiService.delete(this.baseUrl + `tokens/${tokenId}`); + return this.jsonApiService.delete(this.baseUrl + `tokens/${tokenId}/`); } } diff --git a/src/app/features/my-projects/add-project-form/add-project-form.component.html b/src/app/shared/components/add-project-form/add-project-form.component.html similarity index 52% rename from src/app/features/my-projects/add-project-form/add-project-form.component.html rename to src/app/shared/components/add-project-form/add-project-form.component.html index 8cca85a2b..59711c971 100644 --- a/src/app/features/my-projects/add-project-form/add-project-form.component.html +++ b/src/app/shared/components/add-project-form/add-project-form.component.html @@ -25,50 +25,52 @@
-
-
-

Affiliation

-
- - Select All - - - Remove All - + @if (affiliations().length) { +
+
+

Affiliation

+
+ + Select All + + + Remove All + +
-
-
- @for (affiliation of affiliations; track affiliation.value) { -
- - OSF Logo -
- } +
+ @for (affiliation of affiliations(); track affiliation.id) { +
+ + OSF Logo +
+ } +
-
+ }
@@ -103,12 +105,14 @@

Affiliation

label="Cancel" severity="info" (click)="dialogRef.close()" + [disabled]="isSubmitting()" />
diff --git a/src/app/features/my-projects/add-project-form/add-project-form.component.scss b/src/app/shared/components/add-project-form/add-project-form.component.scss similarity index 100% rename from src/app/features/my-projects/add-project-form/add-project-form.component.scss rename to src/app/shared/components/add-project-form/add-project-form.component.scss diff --git a/src/app/features/my-projects/add-project-form/add-project-form.component.spec.ts b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts similarity index 100% rename from src/app/features/my-projects/add-project-form/add-project-form.component.spec.ts rename to src/app/shared/components/add-project-form/add-project-form.component.spec.ts diff --git a/src/app/features/my-projects/add-project-form/add-project-form.component.ts b/src/app/shared/components/add-project-form/add-project-form.component.ts similarity index 63% rename from src/app/features/my-projects/add-project-form/add-project-form.component.ts rename to src/app/shared/components/add-project-form/add-project-form.component.ts index 40ca025ab..102e7a8ef 100644 --- a/src/app/features/my-projects/add-project-form/add-project-form.component.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.ts @@ -4,6 +4,7 @@ import { computed, inject, signal, + OnInit, } from '@angular/core'; import { CommonModule, NgOptimizedImage } from '@angular/common'; import { @@ -22,23 +23,16 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { Store } from '@ngxs/store'; -import { MyProjectsSelectors } from '@core/store/my-projects'; - -enum ProjectFormControls { - Title = 'title', - StorageLocation = 'storageLocation', - Affiliations = 'affiliations', - Description = 'description', - Template = 'template', -} - -interface ProjectForm { - [ProjectFormControls.Title]: FormControl; - [ProjectFormControls.StorageLocation]: FormControl; - [ProjectFormControls.Affiliations]: FormControl; - [ProjectFormControls.Description]: FormControl; - [ProjectFormControls.Template]: FormControl; -} +import { STORAGE_LOCATIONS } from '@core/constants/storage-locations.constant'; +import { + CreateProject, + GetMyProjects, + MyProjectsSelectors, +} from '@core/store/my-projects'; +import { InstitutionsSelectors } from '@core/store/institutions'; +import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants/my-projects-table.constants'; +import { ProjectForm } from '@shared/entities/create-project-form.interface'; +import { ProjectFormControls } from '@osf/shared/entities/create-project-form-controls.enum'; @Component({ selector: 'osf-add-project-form', @@ -58,7 +52,7 @@ interface ProjectForm { styleUrl: './add-project-form.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AddProjectFormComponent { +export class AddProjectFormComponent implements OnInit { #store = inject(Store); protected readonly projects = this.#store.selectSignal( MyProjectsSelectors.getProjects, @@ -67,23 +61,18 @@ export class AddProjectFormComponent { protected readonly dialogRef = inject(DynamicDialogRef); protected readonly ProjectFormControls = ProjectFormControls; protected readonly hasTemplateSelected = signal(false); + protected readonly isSubmitting = signal(false); - protected readonly storageLocations = [ - { label: 'United States', value: 'us' }, - { label: 'Canada - Montréal', value: 'ca' }, - { label: 'Germany - Frankfurt', value: 'de-1' }, - ]; + protected readonly storageLocations = STORAGE_LOCATIONS; - protected readonly affiliations = [ - { label: 'Affiliation 1', value: 'aff1' }, - { label: 'Affiliation 2', value: 'aff2' }, - { label: 'Affiliation 3', value: 'aff3' }, - ]; + protected readonly affiliations = this.#store.selectSignal( + InstitutionsSelectors.getUserInstitutions, + ); protected projectTemplateOptions = computed(() => { return this.projects().map((project) => ({ label: project.title, - value: project.title, + value: project.id, })); }); @@ -115,8 +104,14 @@ export class AddProjectFormComponent { }); } + ngOnInit(): void { + this.#store.dispatch( + new GetMyProjects(1, MY_PROJECTS_TABLE_PARAMS.rows, {}), + ); + } + selectAllAffiliations(): void { - const allAffiliationValues = this.affiliations.map((aff) => aff.value); + const allAffiliationValues = this.affiliations().map((aff) => aff.id); this.projectForm .get(ProjectFormControls.Affiliations) ?.setValue(allAffiliationValues); @@ -132,7 +127,30 @@ export class AddProjectFormComponent { return; } - // TODO: Integrate with API - this.dialogRef.close(); + const formValue = this.projectForm.getRawValue(); + this.isSubmitting.set(true); + + this.#store + .dispatch( + new CreateProject( + formValue.title, + formValue.description, + formValue.template, + formValue.storageLocation, + formValue.affiliations, + ), + ) + .subscribe({ + next: () => { + this.#store.dispatch( + new GetMyProjects(1, MY_PROJECTS_TABLE_PARAMS.rows, {}), + ); + this.dialogRef.close(); + }, + error: (error) => { + console.error('Failed to create project:', error); + this.isSubmitting.set(false); + }, + }); } } 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 2c61deb38..5b9193a72 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 @@ -1,4 +1,4 @@ -
+
; + [ProjectFormControls.StorageLocation]: FormControl; + [ProjectFormControls.Affiliations]: FormControl; + [ProjectFormControls.Description]: FormControl; + [ProjectFormControls.Template]: FormControl; +} diff --git a/src/assets/styles/overrides/input.scss b/src/assets/styles/overrides/input.scss index 4c9313f92..6de8e1c53 100644 --- a/src/assets/styles/overrides/input.scss +++ b/src/assets/styles/overrides/input.scss @@ -63,6 +63,7 @@ p-password.ng-touched.ng-invalid { font-size: 16px; border-radius: 0.57rem; box-shadow: none; + resize: none; } .addon-input { diff --git a/src/assets/styles/overrides/table.scss b/src/assets/styles/overrides/table.scss index 52362e375..5953d0892 100644 --- a/src/assets/styles/overrides/table.scss +++ b/src/assets/styles/overrides/table.scss @@ -48,6 +48,10 @@ } .my-projects-table { + tr:not(.loading-row) { + cursor: pointer; + } + &.loading { table { tr.loading-row { @@ -58,6 +62,14 @@ width: 100%; } } + + tr:hover { + background: inherit; + + td { + background: inherit; + } + } } } }