diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index 58efb93cc..db007ae47 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -8,7 +8,7 @@ import Aura from '@primeng/themes/aura';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient } from '@angular/common/http';
import { ConfirmationService } from 'primeng/api';
-import { STATES } from '@core/helpers/ngxs-states.constant';
+import { STATES } from '@core/constants/ngxs-states.constant';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
diff --git a/src/app/core/components/breadcrumb/breadcrumb.component.ts b/src/app/core/components/breadcrumb/breadcrumb.component.ts
index 21ace7a94..371a0442b 100644
--- a/src/app/core/components/breadcrumb/breadcrumb.component.ts
+++ b/src/app/core/components/breadcrumb/breadcrumb.component.ts
@@ -13,7 +13,8 @@ export class BreadcrumbComponent {
#destroyRef = inject(DestroyRef);
protected readonly url = signal(this.#router.url);
protected readonly parsedUrl = computed(() => {
- return this.url().split('/').filter(Boolean);
+ const cleanUrl = this.url().split('?')[0].split('#')[0];
+ return cleanUrl.replace('-', ' ').split('/').filter(Boolean);
});
constructor() {
diff --git a/src/app/core/components/header/header.component.html b/src/app/core/components/header/header.component.html
index 7508423be..f73896f19 100644
--- a/src/app/core/components/header/header.component.html
+++ b/src/app/core/components/header/header.component.html
@@ -1,2 +1,2 @@
-
+
{{ authButtonText() }}
diff --git a/src/app/core/components/nav-menu/nav-menu.component.ts b/src/app/core/components/nav-menu/nav-menu.component.ts
index c11d6efb9..e6424ca8f 100644
--- a/src/app/core/components/nav-menu/nav-menu.component.ts
+++ b/src/app/core/components/nav-menu/nav-menu.component.ts
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
-import { NAV_ITEMS } from '@osf/core/helpers/nav-items.constant';
+import { NAV_ITEMS } from '@core/constants/nav-items.constant';
import { PanelMenuModule } from 'primeng/panelmenu';
import { MenuItem } from 'primeng/api';
diff --git a/src/app/core/constants/my-projects-table.constants.ts b/src/app/core/constants/my-projects-table.constants.ts
new file mode 100644
index 000000000..7f204b1ee
--- /dev/null
+++ b/src/app/core/constants/my-projects-table.constants.ts
@@ -0,0 +1,12 @@
+import { TableParameters } from '@shared/entities/table-parameters.interface';
+
+export const MY_PROJECTS_TABLE_PARAMS: TableParameters = {
+ rows: 10,
+ paginator: true,
+ scrollable: false,
+ rowsPerPageOptions: [5, 10, 25],
+ totalRecords: 0,
+ firstRowIndex: 0,
+ defaultSortColumn: null,
+ defaultSortOrder: null,
+};
diff --git a/src/app/core/helpers/nav-items.constant.ts b/src/app/core/constants/nav-items.constant.ts
similarity index 95%
rename from src/app/core/helpers/nav-items.constant.ts
rename to src/app/core/constants/nav-items.constant.ts
index 4382b28b4..f60e067f8 100644
--- a/src/app/core/helpers/nav-items.constant.ts
+++ b/src/app/core/constants/nav-items.constant.ts
@@ -1,4 +1,4 @@
-import { NavItem } from '@osf/shared/entities/nav-item.interface';
+import { NavItem } from '@shared/entities/nav-item.interface';
export const NAV_ITEMS: NavItem[] = [
{ path: '/home', label: 'Home', icon: 'home', useExactMatch: true },
diff --git a/src/app/core/helpers/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts
similarity index 73%
rename from src/app/core/helpers/ngxs-states.constant.ts
rename to src/app/core/constants/ngxs-states.constant.ts
index c0d346532..fa901cc45 100644
--- a/src/app/core/helpers/ngxs-states.constant.ts
+++ b/src/app/core/constants/ngxs-states.constant.ts
@@ -2,7 +2,8 @@ import { AuthState } from '@core/store/auth';
import { TokensState } from '@core/store/settings';
import { AddonsState } from '@core/store/settings/addons';
import { UserState } from '@core/store/user';
-import { HomeState } from 'src/app/features/home/store';
+import { HomeState } from '@osf/features/home/store';
+import { MyProjectsState } from '@core/store/my-projects';
import { SearchState } from '@osf/features/search/store';
export const STATES = [
@@ -12,4 +13,5 @@ export const STATES = [
UserState,
HomeState,
SearchState,
+ MyProjectsState,
];
diff --git a/src/app/core/helpers/http.helper.ts b/src/app/core/helpers/http.helper.ts
new file mode 100644
index 000000000..fbd689092
--- /dev/null
+++ b/src/app/core/helpers/http.helper.ts
@@ -0,0 +1,27 @@
+import { Params } from '@angular/router';
+import { SortOrder } from '@shared/utils/sort-order.enum';
+
+export const parseQueryFilterParams = (
+ params: Params,
+): {
+ page: number;
+ size: number;
+ search: string;
+ sortColumn: string;
+ sortOrder: SortOrder;
+} => {
+ const page = parseInt(params['page'], 10) || 1;
+ const size = parseInt(params['size'], 10);
+ const search = params['search'];
+ const sortColumn = params['sortColumn'];
+ const sortOrder =
+ params['sortOrder'] === 'desc' ? SortOrder.Desc : SortOrder.Asc;
+
+ return {
+ page,
+ size,
+ search,
+ sortColumn,
+ sortOrder,
+ };
+};
diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts
index e4ec8f422..c1ce33697 100644
--- a/src/app/core/services/json-api/json-api.service.ts
+++ b/src/app/core/services/json-api/json-api.service.ts
@@ -10,6 +10,7 @@ export class JsonApiService {
http: HttpClient = inject(HttpClient);
readonly #token =
'Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt';
+ // OBJoUomBgbUuDgQo5JoaSKNya6XaYcd0ojAX1XOLmWi6J2arQPzByxyEi81fHE60drQUWv
readonly #headers = new HttpHeaders({
Authorization: this.#token,
Accept: 'application/vnd.api+json',
diff --git a/src/app/core/store/my-projects/index.ts b/src/app/core/store/my-projects/index.ts
new file mode 100644
index 000000000..48506c4af
--- /dev/null
+++ b/src/app/core/store/my-projects/index.ts
@@ -0,0 +1,4 @@
+export * from './my-projects.state';
+export * from './my-projects.actions';
+export * from './my-projects.selectors';
+export * from './my-projects.model';
diff --git a/src/app/core/store/my-projects/my-projects.actions.ts b/src/app/core/store/my-projects/my-projects.actions.ts
new file mode 100644
index 000000000..170bc8bc1
--- /dev/null
+++ b/src/app/core/store/my-projects/my-projects.actions.ts
@@ -0,0 +1,49 @@
+import { MyProjectsSearchFilters } from '@osf/features/my-projects/entities/my-projects-search-filters.models';
+
+export class GetMyProjects {
+ static readonly type = '[My Projects] Get Projects';
+
+ constructor(
+ public pageNumber: number,
+ public pageSize: number,
+ public filters: MyProjectsSearchFilters,
+ ) {}
+}
+
+export class GetMyRegistrations {
+ static readonly type = '[My Projects] Get Registrations';
+
+ constructor(
+ public pageNumber: number,
+ public pageSize: number,
+ public filters: MyProjectsSearchFilters,
+ ) {}
+}
+
+export class GetMyPreprints {
+ static readonly type = '[My Projects] Get Preprints';
+
+ constructor(
+ public pageNumber: number,
+ public pageSize: number,
+ public filters: MyProjectsSearchFilters,
+ ) {}
+}
+
+export class GetMyBookmarks {
+ static readonly type = '[My Projects] Get Bookmarks';
+ constructor(
+ public bookmarksId: string,
+ public pageNumber: number,
+ public pageSize: number,
+ public filters: MyProjectsSearchFilters,
+ ) {}
+}
+
+export class GetBookmarksCollectionId {
+ static readonly type = '[My Projects] Get Bookmarks Collection Id';
+}
+
+export class ClearMyProjects {
+ static readonly type = '[My Projects] Clear Projects';
+}
diff --git a/src/app/core/store/my-projects/my-projects.model.ts b/src/app/core/store/my-projects/my-projects.model.ts
new file mode 100644
index 000000000..2998362cd
--- /dev/null
+++ b/src/app/core/store/my-projects/my-projects.model.ts
@@ -0,0 +1,13 @@
+import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.entities';
+
+export interface MyProjectsStateModel {
+ projects: MyProjectsItem[];
+ registrations: MyProjectsItem[];
+ preprints: MyProjectsItem[];
+ bookmarks: MyProjectsItem[];
+ totalProjects: number;
+ totalRegistrations: number;
+ totalPreprints: number;
+ totalBookmarks: number;
+ bookmarksId: string;
+}
diff --git a/src/app/core/store/my-projects/my-projects.selectors.ts b/src/app/core/store/my-projects/my-projects.selectors.ts
new file mode 100644
index 000000000..2f35d549f
--- /dev/null
+++ b/src/app/core/store/my-projects/my-projects.selectors.ts
@@ -0,0 +1,50 @@
+import { Selector } from '@ngxs/store';
+import { MyProjectsStateModel } from './my-projects.model';
+import { MyProjectsState } from '@core/store/my-projects/my-projects.state';
+
+export class MyProjectsSelectors {
+ @Selector([MyProjectsState])
+ static getProjects(state: MyProjectsStateModel) {
+ return state.projects;
+ }
+
+ @Selector([MyProjectsState])
+ static getRegistrations(state: MyProjectsStateModel) {
+ return state.registrations;
+ }
+
+ @Selector([MyProjectsState])
+ static getPreprints(state: MyProjectsStateModel) {
+ return state.preprints;
+ }
+
+ @Selector([MyProjectsState])
+ static getBookmarks(state: MyProjectsStateModel) {
+ return state.bookmarks;
+ }
+
+ @Selector([MyProjectsState])
+ static getTotalProjectsCount(state: MyProjectsStateModel) {
+ return state.totalProjects;
+ }
+
+ @Selector([MyProjectsState])
+ static getTotalRegistrationsCount(state: MyProjectsStateModel) {
+ return state.totalRegistrations;
+ }
+
+ @Selector([MyProjectsState])
+ static getTotalPreprintsCount(state: MyProjectsStateModel) {
+ return state.totalPreprints;
+ }
+
+ @Selector([MyProjectsState])
+ static getTotalBookmarksCount(state: MyProjectsStateModel) {
+ return state.totalBookmarks;
+ }
+
+ @Selector([MyProjectsState])
+ static getBookmarksCollectionId(state: MyProjectsStateModel) {
+ return state.bookmarksId;
+ }
+}
diff --git a/src/app/core/store/my-projects/my-projects.state.ts b/src/app/core/store/my-projects/my-projects.state.ts
new file mode 100644
index 000000000..819b34c20
--- /dev/null
+++ b/src/app/core/store/my-projects/my-projects.state.ts
@@ -0,0 +1,127 @@
+import { inject, Injectable } from '@angular/core';
+import { State, Action, StateContext } from '@ngxs/store';
+import { MyProjectsStateModel } from './my-projects.model';
+import {
+ GetMyProjects,
+ GetMyRegistrations,
+ GetMyPreprints,
+ GetMyBookmarks,
+ GetBookmarksCollectionId,
+ ClearMyProjects,
+} from './my-projects.actions';
+import { MyProjectsService } from '@osf/features/my-projects/my-projects.service';
+import { tap } from 'rxjs';
+
+@State({
+ name: 'myProjects',
+ defaults: {
+ projects: [],
+ registrations: [],
+ preprints: [],
+ bookmarks: [],
+ totalProjects: 0,
+ totalRegistrations: 0,
+ totalPreprints: 0,
+ totalBookmarks: 0,
+ bookmarksId: '',
+ },
+})
+@Injectable()
+export class MyProjectsState {
+ myProjectsService = inject(MyProjectsService);
+
+ @Action(GetMyProjects)
+ getProjects(ctx: StateContext, action: GetMyProjects) {
+ return this.myProjectsService
+ .getMyProjects(action.filters, action.pageNumber, action.pageSize)
+ .pipe(
+ tap((res) => {
+ ctx.patchState({
+ projects: res.data,
+ totalProjects: res.links.meta.total,
+ });
+ }),
+ );
+ }
+
+ @Action(GetMyRegistrations)
+ getRegistrations(
+ ctx: StateContext,
+ action: GetMyRegistrations,
+ ) {
+ return this.myProjectsService
+ .getMyRegistrations(action.filters, action.pageNumber, action.pageSize)
+ .pipe(
+ tap((res) => {
+ ctx.patchState({
+ registrations: res.data,
+ totalRegistrations: res.links.meta.total,
+ });
+ }),
+ );
+ }
+
+ @Action(GetMyPreprints)
+ getPreprints(
+ ctx: StateContext,
+ action: GetMyPreprints,
+ ) {
+ return this.myProjectsService
+ .getMyPreprints(action.filters, action.pageNumber, action.pageSize)
+ .pipe(
+ tap((res) => {
+ ctx.patchState({
+ preprints: res.data,
+ totalPreprints: res.links.meta.total,
+ });
+ }),
+ );
+ }
+
+ @Action(GetMyBookmarks)
+ getBookmarks(
+ ctx: StateContext,
+ action: GetMyBookmarks,
+ ) {
+ return this.myProjectsService
+ .getMyBookmarks(
+ action.bookmarksId,
+ action.filters,
+ action.pageNumber,
+ action.pageSize,
+ )
+ .pipe(
+ tap((res) => {
+ ctx.patchState({
+ bookmarks: res.data,
+ totalBookmarks: res.links.meta.total,
+ });
+ }),
+ );
+ }
+
+ @Action(GetBookmarksCollectionId)
+ getBookmarksCollectionId(ctx: StateContext) {
+ return this.myProjectsService.getBookmarksCollectionId().pipe(
+ tap((res) => {
+ ctx.patchState({
+ bookmarksId: res,
+ });
+ }),
+ );
+ }
+
+ @Action(ClearMyProjects)
+ clearMyProjects(ctx: StateContext) {
+ ctx.patchState({
+ projects: [],
+ registrations: [],
+ preprints: [],
+ bookmarks: [],
+ totalProjects: 0,
+ totalRegistrations: 0,
+ totalPreprints: 0,
+ totalBookmarks: 0,
+ });
+ }
+}
diff --git a/src/app/features/home/home.component.html b/src/app/features/home/home.component.html
index fb4e33c2a..6af895913 100644
--- a/src/app/features/home/home.component.html
+++ b/src/app/features/home/home.component.html
@@ -27,43 +27,19 @@
search OSF
-
-
-
-
-
- |
- Title
-
- |
- Contributors |
-
- Modified
-
- |
-
-
-
-
- | {{ project.title }} |
- {{ getContributorsList(project) }} |
- {{ project.dateModified | date: "MMM d, y, h:mm a" }} |
-
-
-
diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts
index efa75655d..dfcabefd3 100644
--- a/src/app/features/home/home.component.ts
+++ b/src/app/features/home/home.component.ts
@@ -1,63 +1,217 @@
-import { Component, computed, inject, OnInit, signal } from '@angular/core';
-import { TableModule } from 'primeng/table';
-import { Project } from '@osf/features/home/models/project.entity';
-import { DatePipe } from '@angular/common';
-import { RouterLink } from '@angular/router';
+import {
+ Component,
+ DestroyRef,
+ inject,
+ OnInit,
+ signal,
+ computed,
+ effect,
+} from '@angular/core';
+import { RouterLink, ActivatedRoute, Router } from '@angular/router';
import { Button } from 'primeng/button';
import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component';
-import { SearchInputComponent } from '@shared/components/search-input/search-input.component';
import { IS_MEDIUM, IS_XSMALL } from '@shared/utils/breakpoints.tokens';
-import { toSignal } from '@angular/core/rxjs-interop';
-import { DashboardService } from '@osf/features/home/dashboard.service';
+import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngxs/store';
-import { GetProjects, HomeSelectors } from 'src/app/features/home/store';
+import { MyProjectsTableComponent } from '@shared/components/my-projects-table/my-projects-table.component';
+import { TableParameters } from '@shared/entities/table-parameters.interface';
+import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants/my-projects-table.constants';
+import { SortOrder } from '@shared/utils/sort-order.enum';
+import { TablePageEvent } from 'primeng/table';
+import { SortEvent } from 'primeng/api';
+import {
+ MyProjectsSelectors,
+ GetMyProjects,
+ ClearMyProjects,
+} from '@core/store/my-projects';
+import { debounceTime, distinctUntilChanged, Subject } from 'rxjs';
+import { MyProjectsSearchFilters } from '@osf/features/my-projects/entities/my-projects-search-filters.models';
+import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.entities';
@Component({
selector: 'osf-home',
standalone: true,
- imports: [
- TableModule,
- DatePipe,
- RouterLink,
- Button,
- SubHeaderComponent,
- SearchInputComponent,
- ],
+ imports: [RouterLink, Button, SubHeaderComponent, MyProjectsTableComponent],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent implements OnInit {
- #store = inject(Store);
+ readonly #destroyRef = inject(DestroyRef);
+ readonly #store = inject(Store);
+ readonly #router = inject(Router);
+ readonly #route = inject(ActivatedRoute);
+ readonly #searchSubject = new Subject
();
+
+ protected readonly isLoading = signal(false);
+
protected readonly isMedium = toSignal(inject(IS_MEDIUM));
protected readonly isMobile = toSignal(inject(IS_XSMALL));
- protected readonly dashboardService: DashboardService =
- inject(DashboardService);
+
+ protected readonly activeProject = signal(null);
+ protected readonly searchValue = signal('');
+ protected readonly sortColumn = signal(undefined);
+ protected readonly sortOrder = signal(SortOrder.Asc);
+ protected readonly tableParams = signal({
+ ...MY_PROJECTS_TABLE_PARAMS,
+ });
protected readonly projects = this.#store.selectSignal(
- HomeSelectors.getProjects,
+ MyProjectsSelectors.getProjects,
+ );
+ protected readonly totalProjectsCount = this.#store.selectSignal(
+ MyProjectsSelectors.getTotalProjectsCount,
);
- searchValue = signal('');
-
- filteredProjects = computed(() => {
+ protected readonly filteredProjects = computed(() => {
const search = this.searchValue().toLowerCase();
- return this.projects().filter(
- (project) =>
- project.title.toLowerCase().includes(search) ||
- project.bibliographicContributors.some((i) =>
- i.users.familyName.toLowerCase().includes(search),
- ),
+ return this.projects().filter((project) =>
+ project.title.toLowerCase().includes(search),
);
});
- getContributorsList(item: Project) {
- return this.projects()
- .find((i) => i.id === item.id)
- ?.bibliographicContributors.map((i) => i.users.familyName)
- .join(', ');
+ constructor() {
+ this.#setupSearchSubscription();
+ this.#setupTotalRecordsEffect();
+ this.#setupCleanup();
}
ngOnInit() {
- this.#store.dispatch(GetProjects);
+ this.#setupQueryParamsSubscription();
+ }
+
+ #setupQueryParamsSubscription(): void {
+ this.#route.queryParams
+ .pipe(takeUntilDestroyed(this.#destroyRef))
+ .subscribe((params) => {
+ const page = Number(params['page']) || 1;
+ const rows = Number(params['rows']) || MY_PROJECTS_TABLE_PARAMS.rows;
+ const sortField = params['sortField'];
+ const sortOrder = params['sortOrder'] as SortOrder;
+ const search = params['search'] || '';
+
+ this.tableParams.update((current) => ({
+ ...current,
+ firstRowIndex: (page - 1) * rows,
+ rows,
+ }));
+
+ if (sortField) {
+ this.sortColumn.set(sortField);
+ this.sortOrder.set(sortOrder || SortOrder.Asc);
+ }
+
+ if (search) {
+ this.searchValue.set(search);
+ }
+
+ this.#fetchProjects();
+ });
+ }
+
+ #setupSearchSubscription(): void {
+ this.#searchSubject
+ .pipe(
+ debounceTime(300),
+ distinctUntilChanged(),
+ takeUntilDestroyed(this.#destroyRef),
+ )
+ .subscribe((searchValue) => {
+ this.#handleSearch(searchValue);
+ });
+ }
+
+ #setupTotalRecordsEffect(): void {
+ effect(() => {
+ const total = this.totalProjectsCount();
+ this.tableParams.update((current) => ({
+ ...current,
+ totalRecords: total,
+ }));
+ });
+ }
+
+ #setupCleanup(): void {
+ this.#destroyRef.onDestroy(() => {
+ this.#store.dispatch(new ClearMyProjects());
+ });
+ }
+
+ #fetchProjects(): void {
+ this.isLoading.set(true);
+ const filters = this.#createFilters();
+ const page =
+ Math.floor(this.tableParams().firstRowIndex / this.tableParams().rows) +
+ 1;
+ this.#store
+ .dispatch(new GetMyProjects(page, this.tableParams().rows, filters))
+ .pipe(takeUntilDestroyed(this.#destroyRef))
+ .subscribe({
+ complete: () => {
+ this.isLoading.set(false);
+ },
+ error: () => {
+ this.isLoading.set(false);
+ },
+ });
+ }
+
+ #createFilters(): MyProjectsSearchFilters {
+ return {
+ searchValue: this.searchValue(),
+ searchFields: ['title'],
+ sortColumn: this.sortColumn(),
+ sortOrder: this.sortOrder(),
+ };
+ }
+
+ #handleSearch(searchValue: string): void {
+ this.searchValue.set(searchValue);
+ this.#updateQueryParams();
+ }
+
+ #updateQueryParams(): void {
+ const page =
+ Math.floor(this.tableParams().firstRowIndex / this.tableParams().rows) +
+ 1;
+ const queryParams = {
+ page,
+ rows: this.tableParams().rows,
+ search: this.searchValue() || undefined,
+ sortField: this.sortColumn() || undefined,
+ sortOrder: this.sortOrder() || undefined,
+ };
+
+ this.#router.navigate([], {
+ relativeTo: this.#route,
+ queryParams,
+ queryParamsHandling: 'merge',
+ });
+ }
+
+ protected onSearchChange(value: string): void {
+ this.searchValue.set(value);
+ this.#searchSubject.next(value);
+ }
+
+ protected onPageChange(event: TablePageEvent): void {
+ this.tableParams.update((current) => ({
+ ...current,
+ rows: event.rows,
+ firstRowIndex: event.first,
+ }));
+ this.#updateQueryParams();
+ }
+
+ protected onSort(event: SortEvent): void {
+ if (event.field) {
+ this.sortColumn.set(event.field);
+ this.sortOrder.set(event.order === -1 ? SortOrder.Desc : SortOrder.Asc);
+ this.#updateQueryParams();
+ }
+ }
+
+ protected navigateToProject(project: MyProjectsItem): void {
+ this.activeProject.set(project);
+ this.#router.navigate(['/my-projects', project.id]);
}
}
diff --git a/src/app/features/my-projects/entities/my-projects-search-filters.models.ts b/src/app/features/my-projects/entities/my-projects-search-filters.models.ts
new file mode 100644
index 000000000..e2f167d16
--- /dev/null
+++ b/src/app/features/my-projects/entities/my-projects-search-filters.models.ts
@@ -0,0 +1,10 @@
+import { SortOrder } from '@shared/utils/sort-order.enum';
+
+export type SearchField = 'tags' | 'title' | 'description';
+
+export interface MyProjectsSearchFilters {
+ searchValue?: string;
+ searchFields?: SearchField[];
+ sortColumn?: string;
+ sortOrder?: SortOrder;
+}
diff --git a/src/app/features/my-projects/entities/my-projects.entities.ts b/src/app/features/my-projects/entities/my-projects.entities.ts
new file mode 100644
index 000000000..4b56578b5
--- /dev/null
+++ b/src/app/features/my-projects/entities/my-projects.entities.ts
@@ -0,0 +1,85 @@
+import { JsonApiResponse } from '@core/services/json-api/json-api.entity';
+
+export interface MyProjectsItemGetResponse {
+ id: string;
+ type: string;
+ attributes: {
+ title: string;
+ date_modified: string;
+ public: boolean;
+ };
+ embeds: {
+ bibliographic_contributors: {
+ data: {
+ embeds: {
+ users: {
+ data: {
+ attributes: {
+ family_name: string;
+ full_name: string;
+ given_name: string;
+ middle_name: string;
+ };
+ };
+ };
+ };
+ }[];
+ links: {
+ meta: {
+ total: number;
+ per_page: number;
+ };
+ };
+ };
+ };
+}
+
+export interface MyProjectsContributor {
+ familyName: string;
+ fullName: string;
+ givenName: string;
+ middleName: string;
+}
+
+export interface MyProjectsItem {
+ id: string;
+ type: string;
+ title: string;
+ dateModified: string;
+ isPublic: boolean;
+ contributors: MyProjectsContributor[];
+}
+
+export interface MyProjectsItemResponse {
+ data: MyProjectsItem[];
+ links: {
+ meta: {
+ total: number;
+ per_page: number;
+ };
+ };
+}
+
+export interface MyProjectsJsonApiResponse
+ extends JsonApiResponse {
+ links: {
+ meta: {
+ total: number;
+ per_page: number;
+ };
+ };
+}
+
+export interface CollectionAttributes {
+ title: string;
+ bookmarks: boolean;
+}
+
+export interface Collection {
+ id: string;
+ attributes: CollectionAttributes;
+}
+
+export interface SparseCollectionsResponse {
+ data: Collection[];
+}
diff --git a/src/app/features/my-projects/entities/my-projects.types.ts b/src/app/features/my-projects/entities/my-projects.types.ts
new file mode 100644
index 000000000..58cbe7285
--- /dev/null
+++ b/src/app/features/my-projects/entities/my-projects.types.ts
@@ -0,0 +1,5 @@
+export type EndpointType =
+ | 'nodes'
+ | 'registrations'
+ | 'preprints'
+ | `collections/${string}/linked_nodes`;
diff --git a/src/app/features/my-projects/mappers/my-preprints.mapper.ts b/src/app/features/my-projects/mappers/my-preprints.mapper.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/app/features/my-projects/mappers/my-projects.mapper.ts b/src/app/features/my-projects/mappers/my-projects.mapper.ts
new file mode 100644
index 000000000..6b39e06e4
--- /dev/null
+++ b/src/app/features/my-projects/mappers/my-projects.mapper.ts
@@ -0,0 +1,24 @@
+import {
+ MyProjectsItem,
+ MyProjectsItemGetResponse,
+} from '@osf/features/my-projects/entities/my-projects.entities';
+
+export class MyProjectsMapper {
+ static fromResponse(response: MyProjectsItemGetResponse): MyProjectsItem {
+ return {
+ id: response.id,
+ type: response.type,
+ title: response.attributes.title,
+ dateModified: response.attributes.date_modified,
+ isPublic: response.attributes.public,
+ contributors: response.embeds.bibliographic_contributors.data.map(
+ (contributor) => ({
+ familyName: contributor.embeds.users.data.attributes.family_name,
+ fullName: contributor.embeds.users.data.attributes.full_name,
+ givenName: contributor.embeds.users.data.attributes.given_name,
+ middleName: contributor.embeds.users.data.attributes.middle_name,
+ }),
+ ),
+ };
+ }
+}
diff --git a/src/app/features/my-projects/mappers/my-registrations.mapper.ts b/src/app/features/my-projects/mappers/my-registrations.mapper.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/app/features/my-projects/my-projects.component.html b/src/app/features/my-projects/my-projects.component.html
index 660e669e8..bff477a51 100644
--- a/src/app/features/my-projects/my-projects.component.html
+++ b/src/app/features/my-projects/my-projects.component.html
@@ -10,7 +10,7 @@
@if (!isMobile()) {
@@ -36,164 +36,73 @@
}
-
-
-
-
-
- |
- Title
-
- |
- Contributors |
-
- Modified
-
- |
-
-
-
-
- |
-
-
- {{ project.title }}
-
- |
- {{ getContributorsList(project) }} |
- {{ project.dateModified | date: "MMM d, y, h:mm a" }} |
-
-
-
-
-
-
-
-
- |
- Title
-
- |
- Contributors |
-
- Modified
-
- |
-
-
-
-
- |
-
-
- {{ project.title }}
-
- |
- {{ getContributorsList(project) }} |
- {{ project.dateModified | date: "MMM d, y, h:mm a" }} |
-
-
-
-
-
-
-
-
- |
- Title
-
- |
- Contributors |
-
- Modified
-
- |
-
-
-
-
- | {{ project.title }} |
- {{ getContributorsList(project) }} |
- {{ project.dateModified | date: "MMM d, y, h:mm a" }} |
-
-
-
-
- You don't have any bookmarks. Click the bookmark icon on projects or
- registrations to add them here.
-
+ @if (!bookmarks().length && !isLoading()) {
+
+ You don't have any bookmarks. Click the bookmark icon on projects
+ or registrations to add them here.
+
+ } @else {
+
+ }
diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts
index b2a9fdf36..8fdcaf079 100644
--- a/src/app/features/my-projects/my-projects.component.ts
+++ b/src/app/features/my-projects/my-projects.component.ts
@@ -1,47 +1,59 @@
import {
ChangeDetectionStrategy,
Component,
- computed,
+ DestroyRef,
+ effect,
inject,
OnInit,
signal,
+ untracked,
} from '@angular/core';
import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component';
-import { toSignal } from '@angular/core/rxjs-interop';
+import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { IS_MEDIUM, IS_WEB, IS_XSMALL } from '@shared/utils/breakpoints.tokens';
import { DropdownModule } from 'primeng/dropdown';
-import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { FormsModule } from '@angular/forms';
import { Select } from 'primeng/select';
import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs';
import { TabOption } from '@shared/entities/tab-option.interface';
-import { SearchInputComponent } from '@shared/components/search-input/search-input.component';
-import { Project } from '@osf/features/home/models/project.entity';
-import { DatePipe, NgClass } from '@angular/common';
-import { TableModule } from 'primeng/table';
+import { TablePageEvent } from 'primeng/table';
+import type { SortEvent } from 'primeng/api';
import { DialogService } from 'primeng/dynamicdialog';
import { AddProjectFormComponent } from './add-project-form/add-project-form.component';
-import { GetProjects, HomeSelectors } from 'src/app/features/home/store';
import { Store } from '@ngxs/store';
-import { Router, RouterOutlet } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
+import {
+ GetMyProjects,
+ MyProjectsSelectors,
+ ClearMyProjects,
+ GetMyRegistrations,
+ GetMyPreprints,
+ GetBookmarksCollectionId,
+ GetMyBookmarks,
+} from '@core/store/my-projects';
+import { debounceTime, distinctUntilChanged, Subject } from 'rxjs';
+import { MyProjectsSearchFilters } from '@osf/features/my-projects/entities/my-projects-search-filters.models';
+import { TableParameters } from '@shared/entities/table-parameters.interface';
+import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants/my-projects-table.constants';
+import { parseQueryFilterParams } from '@core/helpers/http.helper';
+import { SortOrder } from '@shared/utils/sort-order.enum';
+import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.entities';
+import { QueryParams } from '@osf/shared/entities/query-params.interface';
+import { MyProjectsTableComponent } from '@shared/components/my-projects-table/my-projects-table.component';
@Component({
selector: 'osf-my-projects',
imports: [
SubHeaderComponent,
DropdownModule,
- ReactiveFormsModule,
+ FormsModule,
Select,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
- SearchInputComponent,
- FormsModule,
- DatePipe,
- TableModule,
- NgClass,
- RouterOutlet,
+ MyProjectsTableComponent,
],
templateUrl: './my-projects.component.html',
styleUrl: './my-projects.component.scss',
@@ -49,10 +61,15 @@ import { Router, RouterOutlet } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MyProjectsComponent implements OnInit {
+ readonly #destroyRef = inject(DestroyRef);
readonly #dialogService = inject(DialogService);
readonly #store = inject(Store);
readonly #router = inject(Router);
+ readonly #route = inject(ActivatedRoute);
+ readonly #searchSubject = new Subject
();
+
protected readonly defaultTabValue = 0;
+ protected readonly isLoading = signal(false);
protected readonly isDesktop = toSignal(inject(IS_WEB));
protected readonly isTablet = toSignal(inject(IS_MEDIUM));
protected readonly isMobile = toSignal(inject(IS_XSMALL));
@@ -62,38 +79,317 @@ export class MyProjectsComponent implements OnInit {
{ label: 'My Preprints', value: 2 },
{ label: 'Bookmarks', value: 3 },
];
+
+ protected readonly queryParams = toSignal(this.#route.queryParams);
+ protected readonly currentPage = signal(1);
+ protected readonly currentPageSize = signal(MY_PROJECTS_TABLE_PARAMS.rows);
protected readonly searchValue = signal('');
+ protected readonly selectedTab = signal(this.defaultTabValue);
+ protected readonly activeProject = signal(null);
+ protected readonly sortColumn = signal(undefined);
+ protected readonly sortOrder = signal(SortOrder.Asc);
+ protected readonly tableParams = signal({
+ ...MY_PROJECTS_TABLE_PARAMS,
+ firstRowIndex: 0,
+ });
+
protected readonly projects = this.#store.selectSignal(
- HomeSelectors.getProjects,
+ MyProjectsSelectors.getProjects,
);
- protected readonly selectedTab = signal(this.defaultTabValue);
- readonly activeProject = signal(null);
-
- filteredProjects = computed(() => {
- const search = this.searchValue().toLowerCase();
- return this.projects().filter(
- (project) =>
- project.title.toLowerCase().includes(search) ||
- project.bibliographicContributors.some((i) =>
- i.users.familyName.toLowerCase().includes(search),
- ),
- );
- });
+ protected readonly registrations = this.#store.selectSignal(
+ MyProjectsSelectors.getRegistrations,
+ );
+ protected readonly preprints = this.#store.selectSignal(
+ MyProjectsSelectors.getPreprints,
+ );
+ protected readonly bookmarks = this.#store.selectSignal(
+ MyProjectsSelectors.getBookmarks,
+ );
+ protected readonly totalProjectsCount = this.#store.selectSignal(
+ MyProjectsSelectors.getTotalProjectsCount,
+ );
+ protected readonly totalRegistrationsCount = this.#store.selectSignal(
+ MyProjectsSelectors.getTotalRegistrationsCount,
+ );
+ protected readonly totalPreprintsCount = this.#store.selectSignal(
+ MyProjectsSelectors.getTotalPreprintsCount,
+ );
+ protected readonly totalBookmarksCount = this.#store.selectSignal(
+ MyProjectsSelectors.getTotalBookmarksCount,
+ );
+
+ protected readonly bookmarksCollectionId = this.#store.selectSignal(
+ MyProjectsSelectors.getBookmarksCollectionId,
+ );
+
+ constructor() {
+ this.#setupQueryParamsEffect();
+ this.#setupSearchSubscription();
+ this.#setupTotalRecordsEffect();
+ this.#setupCleanup();
+ }
+
+ ngOnInit(): void {
+ this.#store.dispatch(new GetBookmarksCollectionId());
+ }
+
+ #setupCleanup(): void {
+ this.#destroyRef.onDestroy(() => {
+ this.#store.dispatch(new ClearMyProjects());
+ });
+ }
+
+ #setupSearchSubscription(): void {
+ this.#searchSubject
+ .pipe(
+ debounceTime(300),
+ distinctUntilChanged(),
+ takeUntilDestroyed(this.#destroyRef),
+ )
+ .subscribe((searchValue) => {
+ this.#handleSearch(searchValue);
+ });
+ }
+
+ #setupTotalRecordsEffect(): void {
+ effect(() => {
+ const totalRecords = this.#getTotalRecordsForCurrentTab();
+ untracked(() => {
+ this.#updateTableParams({ totalRecords });
+ });
+ });
+ }
+
+ #getTotalRecordsForCurrentTab(): number {
+ switch (this.selectedTab()) {
+ case 0:
+ return this.totalProjectsCount();
+ case 1:
+ return this.totalRegistrationsCount();
+ case 2:
+ return this.totalPreprintsCount();
+ case 3:
+ return this.totalBookmarksCount();
+ default:
+ return 0;
+ }
+ }
+
+ #setupQueryParamsEffect(): void {
+ effect(() => {
+ const params = this.queryParams();
+ if (!params) return;
+
+ const { page, size, search, sortColumn, sortOrder } =
+ parseQueryFilterParams(params);
+
+ this.#updateComponentState({ page, size, search, sortColumn, sortOrder });
+ this.#fetchDataForCurrentTab({
+ page,
+ size,
+ search,
+ sortColumn,
+ sortOrder,
+ });
+ });
+ }
+
+ #updateComponentState(params: QueryParams): void {
+ untracked(() => {
+ const size = params.size || MY_PROJECTS_TABLE_PARAMS.rows;
+
+ this.currentPage.set(params.page ?? 1);
+ this.currentPageSize.set(size);
+ this.searchValue.set(params.search || '');
+ this.sortColumn.set(params.sortColumn);
+ this.sortOrder.set(params.sortOrder ?? SortOrder.Asc);
- getContributorsList(item: Project) {
- return this.projects()
- .find((i) => i.id === item.id)
- ?.bibliographicContributors.map((i) => i.users.familyName)
- .join(', ');
+ this.#updateTableParams({
+ rows: size,
+ firstRowIndex: ((params.page ?? 1) - 1) * size,
+ });
+ });
+ }
+
+ #updateTableParams(updates: Partial): void {
+ this.tableParams.update((current) => ({
+ ...current,
+ ...updates,
+ }));
+ }
+
+ #fetchDataForCurrentTab(params: QueryParams): void {
+ this.isLoading.set(true);
+ const filters = this.#createFilters(params);
+ const pageNumber = params.page ?? 1;
+ const pageSize = params.size ?? MY_PROJECTS_TABLE_PARAMS.rows;
+
+ let action$;
+ switch (this.selectedTab()) {
+ case 0:
+ action$ = this.#store.dispatch(
+ new GetMyProjects(pageNumber, pageSize, filters),
+ );
+ break;
+ case 1:
+ action$ = this.#store.dispatch(
+ new GetMyRegistrations(pageNumber, pageSize, filters),
+ );
+ break;
+ case 2:
+ action$ = this.#store.dispatch(
+ new GetMyPreprints(pageNumber, pageSize, filters),
+ );
+ break;
+ case 3:
+ if (this.bookmarksCollectionId()) {
+ action$ = this.#store.dispatch(
+ new GetMyBookmarks(
+ this.bookmarksCollectionId(),
+ pageNumber,
+ pageSize,
+ filters,
+ ),
+ );
+ }
+ break;
+ }
+
+ action$?.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe({
+ complete: () => {
+ this.isLoading.set(false);
+ },
+ error: () => {
+ this.isLoading.set(false);
+ },
+ });
+
+ // switch (this.selectedTab()) {
+ // case 0:
+ // this.#store.dispatch(new GetMyProjects(pageNumber, pageSize, filters));
+ // break;
+ // case 1:
+ // this.#store.dispatch(
+ // new GetMyRegistrations(pageNumber, pageSize, filters),
+ // );
+ // break;
+ // case 2:
+ // this.#store.dispatch(new GetMyPreprints(pageNumber, pageSize, filters));
+ // break;
+ // case 3:
+ // if (this.bookmarksCollectionId()) {
+ // this.#store.dispatch(
+ // new GetMyBookmarks(
+ // this.bookmarksCollectionId(),
+ // pageNumber,
+ // pageSize,
+ // filters,
+ // ),
+ // );
+ // }
+ // break;
+ // }
}
- createProject(): void {
- let dialogWidth = '850px';
+ #createFilters(params: QueryParams): MyProjectsSearchFilters {
+ return {
+ searchValue: params.search || '',
+ searchFields: ['title', 'tags', 'description'],
+ sortColumn: params.sortColumn,
+ sortOrder: params.sortOrder,
+ };
+ }
+
+ #handleSearch(searchValue: string): void {
+ const currentParams = this.queryParams() || {};
+ this.#updateQueryParams({
+ search: searchValue,
+ page: 1,
+ sortColumn: currentParams['sortColumn'],
+ sortOrder:
+ currentParams['sortOrder'] === 'desc' ? SortOrder.Desc : SortOrder.Asc,
+ });
+ }
+
+ #updateQueryParams(updates: Partial): void {
+ const currentParams = this.queryParams() || {};
+ const queryParams: Record = {};
+
+ if ('page' in updates || currentParams['page']) {
+ queryParams['page'] = updates.page?.toString() ?? currentParams['page'];
+ }
+ if ('size' in updates || currentParams['size']) {
+ queryParams['size'] = updates.size?.toString() ?? currentParams['size'];
+ }
+
+ if ('search' in updates || currentParams['search']) {
+ const search = updates.search ?? currentParams['search'];
+ if (search) {
+ queryParams['search'] = search;
+ }
+ }
- if (this.isMobile()) {
- dialogWidth = '95vw';
+ if ('sortColumn' in updates) {
+ if (updates.sortColumn) {
+ queryParams['sortColumn'] = updates.sortColumn;
+ queryParams['sortOrder'] =
+ updates.sortOrder === SortOrder.Desc ? 'desc' : 'asc';
+ }
+ } else if (currentParams['sortColumn']) {
+ queryParams['sortColumn'] = currentParams['sortColumn'];
+ queryParams['sortOrder'] = currentParams['sortOrder'];
}
+ this.#router.navigate([], {
+ relativeTo: this.#route,
+ queryParams,
+ });
+ }
+
+ protected onSearchChange(value: string): void {
+ this.searchValue.set(value);
+ this.#searchSubject.next(value);
+ }
+
+ protected onPageChange(event: TablePageEvent): void {
+ const page = Math.floor(event.first / event.rows) + 1;
+ const currentParams = this.queryParams() || {};
+
+ this.#updateQueryParams({
+ page,
+ size: event.rows,
+ sortColumn: currentParams['sortColumn'],
+ sortOrder:
+ currentParams['sortOrder'] === 'desc' ? SortOrder.Desc : SortOrder.Asc,
+ });
+ }
+
+ protected onSort(event: SortEvent): void {
+ if (event.field) {
+ this.#updateQueryParams({
+ sortColumn: event.field,
+ sortOrder: event.order === -1 ? SortOrder.Desc : SortOrder.Asc,
+ });
+ }
+ }
+
+ protected onTabChange(tabIndex: number): void {
+ this.#store.dispatch(new ClearMyProjects());
+ this.selectedTab.set(tabIndex);
+ const currentParams = this.queryParams() || {};
+
+ this.#updateQueryParams({
+ page: 1,
+ size: currentParams['size'],
+ search: '',
+ sortColumn: undefined,
+ sortOrder: undefined,
+ });
+ }
+
+ protected createProject(): void {
+ const dialogWidth = this.isMobile() ? '95vw' : '850px';
+
this.#dialogService.open(AddProjectFormComponent, {
width: dialogWidth,
focusOnShow: false,
@@ -104,12 +400,8 @@ export class MyProjectsComponent implements OnInit {
});
}
- navigateToProject(project: Project): void {
+ protected navigateToProject(project: MyProjectsItem): void {
this.activeProject.set(project);
this.#router.navigate(['/my-projects', project.id]);
}
-
- ngOnInit() {
- this.#store.dispatch(GetProjects);
- }
}
diff --git a/src/app/features/my-projects/my-projects.service.ts b/src/app/features/my-projects/my-projects.service.ts
index 7eb9e1cdd..ce9a73a3d 100644
--- a/src/app/features/my-projects/my-projects.service.ts
+++ b/src/app/features/my-projects/my-projects.service.ts
@@ -1,6 +1,132 @@
-import { Injectable } from '@angular/core';
+import { inject, Injectable } from '@angular/core';
+import { JsonApiService } from '@core/services/json-api/json-api.service';
+import { Observable } from 'rxjs';
+import { MyProjectsSearchFilters } from '@osf/features/my-projects/entities/my-projects-search-filters.models';
+import { MyProjectsMapper } from '@osf/features/my-projects/mappers/my-projects.mapper';
+import {
+ MyProjectsItemResponse,
+ MyProjectsItemGetResponse,
+ MyProjectsJsonApiResponse,
+ SparseCollectionsResponse,
+} from '@osf/features/my-projects/entities/my-projects.entities';
+import { map } from 'rxjs/operators';
+import { SortOrder } from '@shared/utils/sort-order.enum';
+import { EndpointType } from '@osf/features/my-projects/entities/my-projects.types';
@Injectable({
providedIn: 'root',
})
-export class MyProjectsService {}
+export class MyProjectsService {
+ #baseUrl = 'https://api.staging4.osf.io/v2/';
+ #jsonApiService = inject(JsonApiService);
+ #sortFieldMap: Record = {
+ title: 'title',
+ dateModified: 'date_modified',
+ };
+
+ #getMyItems(
+ endpoint: EndpointType,
+ filters?: MyProjectsSearchFilters,
+ pageNumber?: number,
+ pageSize?: number,
+ ): Observable {
+ const params: Record = {
+ 'embed[]': ['bibliographic_contributors'],
+ [`fields[${endpoint}]`]:
+ 'title,date_modified,public,bibliographic_contributors',
+ 'fields[users]': 'family_name,full_name,given_name,middle_name',
+ };
+
+ if (filters?.searchValue && filters.searchFields?.length) {
+ params[`filter[${filters.searchFields.join(',')}]`] = filters.searchValue;
+ }
+
+ if (pageNumber) {
+ params['page'] = pageNumber;
+ }
+
+ if (pageSize) {
+ params['page[size]'] = pageSize;
+ }
+
+ if (filters?.sortColumn && this.#sortFieldMap[filters.sortColumn]) {
+ const apiField = this.#sortFieldMap[filters.sortColumn];
+ const sortPrefix = filters.sortOrder === SortOrder.Desc ? '-' : '';
+ params['sort'] = `${sortPrefix}${apiField}`;
+ } else {
+ params['sort'] = '-date_modified';
+ }
+
+ const url = endpoint.startsWith('collections/')
+ ? this.#baseUrl + endpoint
+ : this.#baseUrl + 'users/me/' + endpoint;
+
+ return this.#jsonApiService
+ .get(url, params)
+ .pipe(
+ map((response: MyProjectsJsonApiResponse) => ({
+ data: response.data.map((item: MyProjectsItemGetResponse) =>
+ MyProjectsMapper.fromResponse(item),
+ ),
+ links: response.links,
+ })),
+ );
+ }
+
+ getMyProjects(
+ filters?: MyProjectsSearchFilters,
+ pageNumber?: number,
+ pageSize?: number,
+ ): Observable {
+ return this.#getMyItems('nodes', filters, pageNumber, pageSize);
+ }
+
+ getBookmarksCollectionId(): Observable {
+ const params: Record = {
+ 'fields[collections]': 'title,bookmarks',
+ };
+
+ return this.#jsonApiService
+ .get(this.#baseUrl + 'collections', params)
+ .pipe(
+ map((response) => {
+ const bookmarksCollection = response.data.find(
+ (collection) =>
+ collection.attributes.title === 'Bookmarks' &&
+ collection.attributes.bookmarks,
+ );
+ return bookmarksCollection?.id ?? '';
+ }),
+ );
+ }
+
+ getMyRegistrations(
+ filters?: MyProjectsSearchFilters,
+ pageNumber?: number,
+ pageSize?: number,
+ ): Observable {
+ return this.#getMyItems('registrations', filters, pageNumber, pageSize);
+ }
+
+ getMyPreprints(
+ filters?: MyProjectsSearchFilters,
+ pageNumber?: number,
+ pageSize?: number,
+ ): Observable {
+ return this.#getMyItems('preprints', filters, pageNumber, pageSize);
+ }
+
+ getMyBookmarks(
+ collectionId: string,
+ filters?: MyProjectsSearchFilters,
+ pageNumber?: number,
+ pageSize?: number,
+ ): Observable {
+ return this.#getMyItems(
+ `collections/${collectionId}/linked_nodes`,
+ filters,
+ pageNumber,
+ pageSize,
+ );
+ }
+}
diff --git a/src/app/features/settings/addons/addons.service.ts b/src/app/features/settings/addons/addons.service.ts
index 7fb7452b0..39aba6b0e 100644
--- a/src/app/features/settings/addons/addons.service.ts
+++ b/src/app/features/settings/addons/addons.service.ts
@@ -42,7 +42,9 @@ export class AddonsService {
if (!currentUser) throw new Error('Current user not found');
const userUri = `https://staging4.osf.io/${currentUser.id}`;
- const params = { 'filter[user_uri]': userUri };
+ const params = {
+ 'filter[user_uri]': userUri,
+ };
return this.#jsonApiService
.get<
@@ -55,10 +57,13 @@ export class AddonsService {
addonType: string,
referenceId: string,
): Observable {
+ const params = {
+ [`fields[external-${addonType}-services]`]: 'external_service_name',
+ };
return this.#jsonApiService
.get<
JsonApiResponse
- >(this.#baseUrl + `user-references/${referenceId}/authorized_${addonType}_accounts?include=external-${addonType}-service`)
+ >(this.#baseUrl + `user-references/${referenceId}/authorized_${addonType}_accounts?include=external-${addonType}-service`, params)
.pipe(
map((response) => {
return response.data.map((item) =>
diff --git a/src/app/features/settings/addons/entities/addons.entities.ts b/src/app/features/settings/addons/entities/addons.entities.ts
index 3c6435d75..6fb69262b 100644
--- a/src/app/features/settings/addons/entities/addons.entities.ts
+++ b/src/app/features/settings/addons/entities/addons.entities.ts
@@ -11,18 +11,12 @@ export interface AddonGetResponse {
};
relationships: {
addon_imp: {
- links: {
- related: string;
- };
data: {
type: string;
id: string;
};
};
};
- links: {
- self: string;
- };
}
export interface AuthorizedAddonGetResponse {
@@ -39,46 +33,24 @@ export interface AuthorizedAddonGetResponse {
};
relationships: {
account_owner: {
- links: {
- related: string;
- };
data: {
type: string;
id: string;
};
};
- authorized_operations: {
- links: {
- related: string;
- };
- };
- configured_storage_addons: {
- links: {
- related: string;
- };
- };
external_storage_service?: {
- links: {
- related: string;
- };
data: {
type: string;
id: string;
};
};
external_citation_service?: {
- links: {
- related: string;
- };
data: {
type: string;
id: string;
};
};
};
- links: {
- self: string;
- };
}
export interface Addon {
@@ -117,18 +89,12 @@ export interface IncludedAddonData {
relationships?: Record<
string,
{
- links: {
- related: string;
- };
data?: {
type: string;
id: string;
};
}
>;
- links?: {
- self: string;
- };
}
export interface UserReference {
@@ -137,31 +103,6 @@ export interface UserReference {
attributes: {
user_uri: string;
};
- relationships: {
- authorized_storage_accounts: {
- links: {
- related: string;
- };
- };
- authorized_citation_accounts: {
- links: {
- related: string;
- };
- };
- authorized_computing_accounts: {
- links: {
- related: string;
- };
- };
- configured_resources: {
- links: {
- related: string;
- };
- };
- };
- links: {
- self: string;
- };
}
export interface AddonRequest {
@@ -214,30 +155,16 @@ export interface AddonResponse {
};
relationships: {
account_owner: {
- links: {
- related: string;
- };
data: {
type: 'user-references';
id: string;
};
};
- authorized_operations: {
- links: {
- related: string;
- };
- };
external_storage_service: {
- links: {
- related: string;
- };
data: {
type: string;
id: string;
};
};
};
- links: {
- self: string;
- };
}
diff --git a/src/app/features/settings/tokens/entities/tokens.models.ts b/src/app/features/settings/tokens/entities/tokens.models.ts
index bb278dae6..b1cee985b 100644
--- a/src/app/features/settings/tokens/entities/tokens.models.ts
+++ b/src/app/features/settings/tokens/entities/tokens.models.ts
@@ -19,10 +19,6 @@ export interface TokenCreateResponse {
scopes: string;
owner: string;
};
- links: {
- html: string;
- self: string;
- };
}
// API Response Model for GET request
@@ -34,10 +30,6 @@ export interface TokenGetResponse {
scopes: string;
owner: string;
};
- links: {
- html: string;
- self: string;
- };
}
// Domain Models
@@ -47,6 +39,4 @@ export interface Token {
tokenId: string;
scopes: string[];
ownerId: string;
- htmlUrl: string;
- apiUrl: string;
}
diff --git a/src/app/features/settings/tokens/token.mapper.ts b/src/app/features/settings/tokens/token.mapper.ts
index 3bd6de110..60060c003 100644
--- a/src/app/features/settings/tokens/token.mapper.ts
+++ b/src/app/features/settings/tokens/token.mapper.ts
@@ -25,8 +25,6 @@ export class TokenMapper {
tokenId: response.attributes.token_id,
scopes: response.attributes.scopes.split(' '),
ownerId: response.attributes.owner,
- htmlUrl: response.links.html,
- apiUrl: response.links.self,
};
}
@@ -37,8 +35,6 @@ export class TokenMapper {
tokenId: response.id,
scopes: response.attributes.scopes.split(' '),
ownerId: response.attributes.owner,
- htmlUrl: response.links.html,
- apiUrl: response.links.self,
};
}
}
diff --git a/src/app/shared/components/my-projects-table/my-projects-table.component.html b/src/app/shared/components/my-projects-table/my-projects-table.component.html
new file mode 100644
index 000000000..2c61deb38
--- /dev/null
+++ b/src/app/shared/components/my-projects-table/my-projects-table.component.html
@@ -0,0 +1,86 @@
+
+
+ @if (isLoading()) {
+
+
+
+ |
+ Title
+
+ |
+ Contributors |
+
+ Modified
+
+ |
+
+
+
+
+ |
+
+ |
+
+
+
+ } @else {
+
+
+
+ |
+ Title
+
+ |
+ Contributors |
+
+ Modified
+
+ |
+
+
+
+
+ |
+
+
+ {{ item.title }}
+
+ |
+
+ @for (contributor of item.contributors; track contributor) {
+ {{ contributor.fullName }}{{ $last ? "" : ", " }}
+ }
+ |
+ {{ item.dateModified | date: "MMM d, y, h:mm a" }} |
+
+
+
+ }
+
diff --git a/src/app/shared/components/my-projects-table/my-projects-table.component.scss b/src/app/shared/components/my-projects-table/my-projects-table.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/app/shared/components/my-projects-table/my-projects-table.component.ts b/src/app/shared/components/my-projects-table/my-projects-table.component.ts
new file mode 100644
index 000000000..06198fae9
--- /dev/null
+++ b/src/app/shared/components/my-projects-table/my-projects-table.component.ts
@@ -0,0 +1,53 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ input,
+ output,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TableModule, TablePageEvent } from 'primeng/table';
+import { SortEvent } from 'primeng/api';
+import { SearchInputComponent } from '@shared/components/search-input/search-input.component';
+import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.entities';
+import { TableParameters } from '@shared/entities/table-parameters.interface';
+import { SortOrder } from '@shared/utils/sort-order.enum';
+import { Skeleton } from 'primeng/skeleton';
+
+@Component({
+ selector: 'osf-my-projects-table',
+ standalone: true,
+ imports: [CommonModule, TableModule, SearchInputComponent, Skeleton],
+ templateUrl: './my-projects-table.component.html',
+ styleUrl: './my-projects-table.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class MyProjectsTableComponent {
+ items = input([]);
+ tableParams = input.required();
+ searchValue = input('');
+ sortColumn = input(undefined);
+ sortOrder = input(SortOrder.Asc);
+ isLoading = input(false);
+ searchPlaceholder = input('Filter by title, description, and tags');
+
+ searchValueChange = output();
+ pageChange = output();
+ sort = output();
+ itemClick = output();
+
+ protected onSearchChange(value: string | undefined): void {
+ this.searchValueChange.emit(value ?? '');
+ }
+
+ protected onPageChange(event: TablePageEvent): void {
+ this.pageChange.emit(event);
+ }
+
+ protected onSort(event: SortEvent): void {
+ this.sort.emit(event);
+ }
+
+ protected onItemClick(item: MyProjectsItem): void {
+ this.itemClick.emit(item);
+ }
+}
diff --git a/src/app/shared/entities/query-params.interface.ts b/src/app/shared/entities/query-params.interface.ts
new file mode 100644
index 000000000..fcdfda1bb
--- /dev/null
+++ b/src/app/shared/entities/query-params.interface.ts
@@ -0,0 +1,9 @@
+import { SortOrder } from '../utils/sort-order.enum';
+
+export interface QueryParams {
+ page?: number;
+ size?: number;
+ search?: string;
+ sortColumn?: string;
+ sortOrder?: SortOrder;
+}
diff --git a/src/app/shared/entities/table-parameters.interface.ts b/src/app/shared/entities/table-parameters.interface.ts
new file mode 100644
index 000000000..108614b72
--- /dev/null
+++ b/src/app/shared/entities/table-parameters.interface.ts
@@ -0,0 +1,12 @@
+import { SortOrder } from '@shared/utils/sort-order.enum';
+
+export interface TableParameters {
+ rows: number;
+ paginator: boolean;
+ scrollable: boolean;
+ rowsPerPageOptions: number[];
+ totalRecords: number;
+ firstRowIndex: number;
+ defaultSortOrder?: SortOrder | null;
+ defaultSortColumn?: string | null;
+}
diff --git a/src/app/shared/utils/sort-order.enum.ts b/src/app/shared/utils/sort-order.enum.ts
new file mode 100644
index 000000000..34fa13f9a
--- /dev/null
+++ b/src/app/shared/utils/sort-order.enum.ts
@@ -0,0 +1,4 @@
+export enum SortOrder {
+ Asc = 0,
+ Desc = 1,
+}
diff --git a/src/assets/styles/overrides/table.scss b/src/assets/styles/overrides/table.scss
index 44c2e847b..52362e375 100644
--- a/src/assets/styles/overrides/table.scss
+++ b/src/assets/styles/overrides/table.scss
@@ -47,6 +47,21 @@
}
}
+ .my-projects-table {
+ &.loading {
+ table {
+ tr.loading-row {
+ td {
+ background: transparent;
+ border: none;
+ padding: 0;
+ width: 100%;
+ }
+ }
+ }
+ }
+ }
+
.addon-table {
tr {
&.background-warning td {