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;
+ }
+ }
}
}
}