diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 5189ee730..222f578b8 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -1,7 +1,6 @@ import { AuthState } from '@core/store/auth'; import { UserState } from '@core/store/user'; import { MeetingsState } from '@osf/features/meetings/store'; -import { MyProjectsState } from '@osf/features/my-projects/store'; import { ProjectMetadataState } from '@osf/features/project/metadata/store'; import { ProjectOverviewState } from '@osf/features/project/overview/store'; import { RegistrationsState } from '@osf/features/project/registrations/store'; @@ -14,11 +13,13 @@ import { AddonsState, InstitutionsState } from '@shared/stores'; import { LicensesState } from '@shared/stores/licenses'; import { RegionsState } from '@shared/stores/regions'; +import { MyResourcesState } from 'src/app/shared/stores/my-resources'; + export const STATES = [ AuthState, AddonsState, UserState, - MyProjectsState, + MyResourcesState, InstitutionsState, ProfileSettingsState, DeveloperAppsState, diff --git a/src/app/features/home/home.component.spec.ts b/src/app/features/home/home.component.spec.ts index 73ec858cf..9b7b19fb7 100644 --- a/src/app/features/home/home.component.spec.ts +++ b/src/app/features/home/home.component.spec.ts @@ -7,7 +7,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; -import { MyProjectsState } from '@osf/features/my-projects/store/my-projects.state'; +import { MyResourcesState } from '@shared/stores/my-resources/my-resources.state'; import { HomeComponent } from './home.component'; @@ -22,7 +22,7 @@ describe('HomeComponent', () => { provideRouter([]), provideHttpClient(withFetch()), provideHttpClientTesting(), - provideStore([MyProjectsState]), + provideStore([MyResourcesState]), ], }).compileComponents(); diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts index dc66eb9b5..2a9aa83ce 100644 --- a/src/app/features/home/home.component.ts +++ b/src/app/features/home/home.component.ts @@ -15,20 +15,19 @@ import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MY_PROJECTS_TABLE_PARAMS } from '@osf/core/constants'; -import { MyProjectsItem } from '@osf/features/my-projects/models'; import { MyProjectsTableComponent, SubHeaderComponent } from '@osf/shared/components'; import { SortOrder } from '@osf/shared/enums'; -import { TableParameters } from '@osf/shared/models'; +import { MyResourcesItem, MyResourcesSearchFilters, TableParameters } from '@osf/shared/models'; import { IS_MEDIUM } from '@osf/shared/utils'; import { FetchUserInstitutions } from '@shared/stores'; import { CreateProjectDialogComponent } from '../my-projects/components'; -import { MyProjectsSearchFilters } from '../my-projects/models'; -import { ClearMyProjects, GetMyProjects, MyProjectsSelectors } from '../my-projects/store'; import { AccountSettingsService } from '../settings/account-settings/services'; import { ConfirmEmailComponent } from './components'; +import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from 'src/app/shared/stores/my-resources'; + @Component({ selector: 'osf-home', imports: [RouterLink, Button, SubHeaderComponent, MyProjectsTableComponent, TranslatePipe], @@ -51,15 +50,15 @@ export class HomeComponent implements OnInit { protected readonly isMedium = toSignal(inject(IS_MEDIUM)); protected readonly searchControl = new FormControl(''); - protected readonly activeProject = signal(null); + protected readonly activeProject = signal(null); protected readonly sortColumn = signal(undefined); protected readonly sortOrder = signal(SortOrder.Asc); protected readonly tableParams = signal({ ...MY_PROJECTS_TABLE_PARAMS, }); - protected readonly projects = select(MyProjectsSelectors.getProjects); - protected readonly totalProjectsCount = select(MyProjectsSelectors.getTotalProjects); + protected readonly projects = select(MyResourcesSelectors.getProjects); + protected readonly totalProjectsCount = select(MyResourcesSelectors.getTotalProjects); protected readonly filteredProjects = computed(() => { const search = this.searchControl.value?.toLowerCase() ?? ''; @@ -159,7 +158,7 @@ export class HomeComponent implements OnInit { setupCleanup(): void { this.destroyRef.onDestroy(() => { - this.store.dispatch(new ClearMyProjects()); + this.store.dispatch(new ClearMyResources()); }); } @@ -180,7 +179,7 @@ export class HomeComponent implements OnInit { }); } - createFilters(): MyProjectsSearchFilters { + createFilters(): MyResourcesSearchFilters { return { searchValue: this.searchControl.value ?? '', searchFields: ['title'], @@ -224,7 +223,7 @@ export class HomeComponent implements OnInit { } } - protected navigateToProject(project: MyProjectsItem): void { + protected navigateToProject(project: MyResourcesItem): void { this.activeProject.set(project); this.router.navigate(['/my-projects', project.id]); } diff --git a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts index 7e876ed95..07384496a 100644 --- a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts +++ b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts @@ -9,12 +9,13 @@ import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@a import { FormControl, FormGroup, Validators } from '@angular/forms'; import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants'; -import { CreateProject, GetMyProjects, MyProjectsSelectors } from '@osf/features/my-projects/store'; import { AddProjectFormComponent } from '@shared/components'; import { ProjectFormControls } from '@shared/enums'; import { IdName, ProjectForm } from '@shared/models'; import { CustomValidators } from '@shared/utils'; +import { CreateProject, GetMyProjects, MyResourcesSelectors } from 'src/app/shared/stores/my-resources'; + @Component({ selector: 'osf-create-project-dialog', imports: [AddProjectFormComponent, Button, TranslatePipe], @@ -30,7 +31,7 @@ export class CreateProjectDialogComponent implements OnInit { createProject: CreateProject, }); - private projects = select(MyProjectsSelectors.getProjects); + private projects = select(MyResourcesSelectors.getProjects); readonly templates = computed(() => { return this.projects().map( @@ -41,7 +42,7 @@ export class CreateProjectDialogComponent implements OnInit { }) as IdName ); }); - readonly isProjectSubmitting = select(MyProjectsSelectors.isProjectSubmitting); + readonly isProjectSubmitting = select(MyResourcesSelectors.isProjectSubmitting); readonly projectForm = new FormGroup({ [ProjectFormControls.Title]: new FormControl('', { diff --git a/src/app/features/my-projects/mappers/index.ts b/src/app/features/my-projects/mappers/index.ts index d365af523..649917ac6 100644 --- a/src/app/features/my-projects/mappers/index.ts +++ b/src/app/features/my-projects/mappers/index.ts @@ -1 +1 @@ -export { MyProjectsMapper } from './my-projects.mapper'; +export { MyResourcesMapper } from './my-resources.mapper'; diff --git a/src/app/features/my-projects/mappers/my-projects.mapper.ts b/src/app/features/my-projects/mappers/my-resources.mapper.ts similarity index 68% rename from src/app/features/my-projects/mappers/my-projects.mapper.ts rename to src/app/features/my-projects/mappers/my-resources.mapper.ts index c2579892a..a95885995 100644 --- a/src/app/features/my-projects/mappers/my-projects.mapper.ts +++ b/src/app/features/my-projects/mappers/my-resources.mapper.ts @@ -1,11 +1,12 @@ -import { MyProjectsItem, MyProjectsItemGetResponseJsonApi } from '../models'; +import { MyResourcesItem, MyResourcesItemGetResponseJsonApi } from 'src/app/shared/models/my-resources'; -export class MyProjectsMapper { - static fromResponse(response: MyProjectsItemGetResponseJsonApi): MyProjectsItem { +export class MyResourcesMapper { + static fromResponse(response: MyResourcesItemGetResponseJsonApi): MyResourcesItem { return { id: response.id, type: response.type, title: response.attributes.title, + dateCreated: response.attributes.date_created, dateModified: response.attributes.date_modified, isPublic: response.attributes.public, contributors: response.embeds.bibliographic_contributors.data.map((contributor) => ({ diff --git a/src/app/features/my-projects/models/index.ts b/src/app/features/my-projects/models/index.ts deleted file mode 100644 index b01abcc9f..000000000 --- a/src/app/features/my-projects/models/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './my-projects.models'; -export * from './my-projects-endpoint.type'; -export * from './my-projects-search-filters.models'; diff --git a/src/app/features/my-projects/models/my-projects-endpoint.type.ts b/src/app/features/my-projects/models/my-projects-endpoint.type.ts deleted file mode 100644 index 8a8bdf7d4..000000000 --- a/src/app/features/my-projects/models/my-projects-endpoint.type.ts +++ /dev/null @@ -1 +0,0 @@ -export type EndpointType = 'nodes' | 'registrations' | 'preprints' | `collections/${string}/${string}/`; diff --git a/src/app/features/my-projects/my-projects.component.spec.ts b/src/app/features/my-projects/my-projects.component.spec.ts index 267c30699..63cb22e57 100644 --- a/src/app/features/my-projects/my-projects.component.spec.ts +++ b/src/app/features/my-projects/my-projects.component.spec.ts @@ -12,11 +12,11 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { MyResourcesState } from '@shared/stores/my-resources/my-resources.state'; import { IS_MEDIUM } from '@shared/utils'; import { InstitutionsState } from '../../shared/stores/institutions'; -import { MyProjectsState } from './store/my-projects.state'; import { MyProjectsComponent } from './my-projects.component'; describe('MyProjectsComponent', () => { @@ -30,7 +30,7 @@ describe('MyProjectsComponent', () => { await TestBed.configureTestingModule({ imports: [MyProjectsComponent, TranslateModule.forRoot()], providers: [ - provideStore([MyProjectsState, InstitutionsState]), + provideStore([MyResourcesState, InstitutionsState]), provideHttpClient(), provideHttpClientTesting(), MockProvider(DialogService), diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 1bb9c9110..e315b2da5 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -34,15 +34,16 @@ import { IS_MEDIUM } from '@osf/shared/utils'; import { MY_PROJECTS_TABS } from './constants'; import { MyProjectsTab } from './enums'; -import { MyProjectsItem, MyProjectsSearchFilters } from './models'; + +import { MyResourcesItem, MyResourcesSearchFilters } from 'src/app/shared/models/my-resources'; import { - ClearMyProjects, + ClearMyResources, GetMyBookmarks, GetMyPreprints, GetMyProjects, GetMyRegistrations, - MyProjectsSelectors, -} from './store'; + MyResourcesSelectors, +} from 'src/app/shared/stores/my-resources'; @Component({ selector: 'osf-my-projects', @@ -81,7 +82,7 @@ export class MyProjectsComponent implements OnInit { protected readonly currentPage = signal(1); protected readonly currentPageSize = signal(MY_PROJECTS_TABLE_PARAMS.rows); protected readonly selectedTab = signal(MyProjectsTab.Projects); - protected readonly activeProject = signal(null); + protected readonly activeProject = signal(null); protected readonly sortColumn = signal(undefined); protected readonly sortOrder = signal(SortOrder.Asc); protected readonly tableParams = signal({ @@ -89,20 +90,20 @@ export class MyProjectsComponent implements OnInit { firstRowIndex: 0, }); - protected readonly projects = select(MyProjectsSelectors.getProjects); - protected readonly registrations = select(MyProjectsSelectors.getRegistrations); - protected readonly preprints = select(MyProjectsSelectors.getPreprints); - protected readonly bookmarks = select(MyProjectsSelectors.getBookmarks); - protected readonly totalProjectsCount = select(MyProjectsSelectors.getTotalProjects); - protected readonly totalRegistrationsCount = select(MyProjectsSelectors.getTotalRegistrations); - protected readonly totalPreprintsCount = select(MyProjectsSelectors.getTotalPreprints); - protected readonly totalBookmarksCount = select(MyProjectsSelectors.getTotalBookmarks); + protected readonly projects = select(MyResourcesSelectors.getProjects); + protected readonly registrations = select(MyResourcesSelectors.getRegistrations); + protected readonly preprints = select(MyResourcesSelectors.getPreprints); + protected readonly bookmarks = select(MyResourcesSelectors.getBookmarks); + protected readonly totalProjectsCount = select(MyResourcesSelectors.getTotalProjects); + protected readonly totalRegistrationsCount = select(MyResourcesSelectors.getTotalRegistrations); + protected readonly totalPreprintsCount = select(MyResourcesSelectors.getTotalPreprints); + protected readonly totalBookmarksCount = select(MyResourcesSelectors.getTotalBookmarks); protected readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); protected readonly actions = createDispatchMap({ getBookmarksCollectionId: GetBookmarksCollectionId, - clearMyProjects: ClearMyProjects, + clearMyProjects: ClearMyResources, getMyProjects: GetMyProjects, getMyRegistrations: GetMyRegistrations, getMyPreprints: GetMyPreprints, @@ -240,7 +241,7 @@ export class MyProjectsComponent implements OnInit { }); } - createFilters(params: QueryParams): MyProjectsSearchFilters { + createFilters(params: QueryParams): MyResourcesSearchFilters { return { searchValue: params.search || '', searchFields: ['title', 'tags', 'description'], @@ -341,12 +342,12 @@ export class MyProjectsComponent implements OnInit { }); } - protected navigateToProject(project: MyProjectsItem): void { + protected navigateToProject(project: MyResourcesItem): void { this.activeProject.set(project); this.router.navigate(['/my-projects', project.id]); } - protected navigateToRegistry(registry: MyProjectsItem): void { + protected navigateToRegistry(registry: MyResourcesItem): void { this.activeProject.set(registry); this.router.navigate(['/registries', registry.id]); } diff --git a/src/app/features/my-projects/services/index.ts b/src/app/features/my-projects/services/index.ts deleted file mode 100644 index 511da1be2..000000000 --- a/src/app/features/my-projects/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MyProjectsService } from './my-projects.service'; diff --git a/src/app/features/my-projects/store/index.ts b/src/app/features/my-projects/store/index.ts deleted file mode 100644 index 26c7035b3..000000000 --- a/src/app/features/my-projects/store/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './my-projects.actions'; -export * from './my-projects.model'; -export * from './my-projects.selectors'; -export * from './my-projects.state'; diff --git a/src/app/features/my-projects/store/my-projects.actions.ts b/src/app/features/my-projects/store/my-projects.actions.ts deleted file mode 100644 index e2dd9a2e3..000000000 --- a/src/app/features/my-projects/store/my-projects.actions.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ResourceType } from '@shared/enums'; - -import { MyProjectsSearchFilters } from '../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, - public resourceType: ResourceType - ) {} -} - -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/features/my-projects/store/my-projects.selectors.ts b/src/app/features/my-projects/store/my-projects.selectors.ts deleted file mode 100644 index 8a85cff91..000000000 --- a/src/app/features/my-projects/store/my-projects.selectors.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { MyProjectsItem } from '../models/my-projects.models'; - -import { MyProjectsStateModel } from './my-projects.model'; -import { MyProjectsState } from './my-projects.state'; - -export class MyProjectsSelectors { - @Selector([MyProjectsState]) - static getProjects(state: MyProjectsStateModel): MyProjectsItem[] { - return state.projects.data; - } - - @Selector([MyProjectsState]) - static isProjectSubmitting(state: MyProjectsStateModel): boolean { - return state.projects.isSubmitting || false; - } - - @Selector([MyProjectsState]) - static getRegistrations(state: MyProjectsStateModel): MyProjectsItem[] { - return state.registrations.data; - } - - @Selector([MyProjectsState]) - static getPreprints(state: MyProjectsStateModel): MyProjectsItem[] { - return state.preprints.data; - } - - @Selector([MyProjectsState]) - static getBookmarks(state: MyProjectsStateModel): MyProjectsItem[] { - return state.bookmarks.data; - } - - @Selector([MyProjectsState]) - static getTotalProjects(state: MyProjectsStateModel): number { - return state.totalProjects; - } - - @Selector([MyProjectsState]) - static getTotalRegistrations(state: MyProjectsStateModel): number { - return state.totalRegistrations; - } - - @Selector([MyProjectsState]) - static getTotalPreprints(state: MyProjectsStateModel): number { - return state.totalPreprints; - } - - @Selector([MyProjectsState]) - static getTotalBookmarks(state: MyProjectsStateModel): number { - return state.totalBookmarks; - } - - @Selector([MyProjectsState]) - static getBookmarksLoading(state: MyProjectsStateModel): boolean { - return state.bookmarks.isLoading; - } -} diff --git a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.html b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.html new file mode 100644 index 000000000..d4984dc7d --- /dev/null +++ b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.html @@ -0,0 +1,17 @@ +

{{ 'project.overview.dialog.deleteNodeLink.message' | translate }}

+
+ + +
diff --git a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.scss b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts new file mode 100644 index 000000000..775a6b6cd --- /dev/null +++ b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DeleteNodeLinkDialogComponent } from './delete-node-link-dialog.component'; + +describe('DeleteNodeLinkDialogComponent', () => { + let component: DeleteNodeLinkDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteNodeLinkDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DeleteNodeLinkDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts new file mode 100644 index 000000000..3fb34d7be --- /dev/null +++ b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts @@ -0,0 +1,45 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; + +import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { ToastService } from '@shared/services'; +import { DeleteNodeLink, GetLinkedResources, NodeLinksSelectors } from '@shared/stores'; + +@Component({ + selector: 'osf-delete-node-link-dialog', + imports: [Button, TranslatePipe], + templateUrl: './delete-node-link-dialog.component.html', + styleUrl: './delete-node-link-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeleteNodeLinkDialogComponent { + private toastService = inject(ToastService); + private dialogConfig = inject(DynamicDialogConfig); + protected dialogRef = inject(DynamicDialogRef); + protected destroyRef = inject(DestroyRef); + protected currentProject = select(ProjectOverviewSelectors.getProject); + protected isSubmitting = select(NodeLinksSelectors.getNodeLinksSubmitting); + + protected actions = createDispatchMap({ deleteNodeLink: DeleteNodeLink, getLinkedResources: GetLinkedResources }); + + handleDeleteNodeLink(): void { + const project = this.currentProject(); + const nodeLinkId = this.dialogConfig.data.nodeLinkId; + + if (!nodeLinkId || !project) return; + + this.actions.deleteNodeLink(project.id, nodeLinkId).subscribe({ + next: () => { + this.dialogRef.close(); + this.actions.getLinkedResources(project.id); + this.toastService.showSuccess('project.overview.dialog.toast.deleteNodeLink.success'); + }, + }); + } +} diff --git a/src/app/features/project/overview/components/index.ts b/src/app/features/project/overview/components/index.ts index 0f0468e51..92001bf88 100644 --- a/src/app/features/project/overview/components/index.ts +++ b/src/app/features/project/overview/components/index.ts @@ -2,9 +2,11 @@ export { AddComponentDialogComponent } from './add-component-dialog/add-componen export { DeleteComponentDialogComponent } from './delete-component-dialog/delete-component-dialog.component'; export { DuplicateDialogComponent } from './duplicate-dialog/duplicate-dialog.component'; export { ForkDialogComponent } from './fork-dialog/fork-dialog.component'; -export { LinkedProjectsComponent } from './linked-projects/linked-projects.component'; export { OverviewComponentsComponent } from './overview-components/overview-components.component'; export { OverviewToolbarComponent } from './overview-toolbar/overview-toolbar.component'; export { OverviewWikiComponent } from './overview-wiki/overview-wiki.component'; export { RecentActivityComponent } from './recent-activity/recent-activity.component'; export { TogglePublicityDialogComponent } from './toggle-publicity-dialog/toggle-publicity-dialog.component'; +export { DeleteNodeLinkDialogComponent } from '@osf/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component'; +export { LinkResourceDialogComponent } from '@osf/features/project/overview/components/link-resource-dialog/link-resource-dialog.component'; +export { LinkedResourcesComponent } from '@osf/features/project/overview/components/linked-resources/linked-resources.component'; diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html new file mode 100644 index 000000000..d12a1701e --- /dev/null +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html @@ -0,0 +1,127 @@ +
+ + + + + + + + + + + @if (isCurrentTableLoading() || isNodeLinksLoading()) { + + + + + {{ 'project.overview.dialog.linkProject.table.title' | translate }} + + {{ 'project.overview.dialog.linkProject.table.created' | translate }} + + {{ 'project.overview.dialog.linkProject.table.modified' | translate }} + + + {{ 'project.overview.dialog.linkProject.table.contributors' | translate }} + + + + + + + + + + + + } @else { + + + + + {{ 'project.overview.dialog.linkProject.table.title' | translate }} + + {{ 'project.overview.dialog.linkProject.table.created' | translate }} + + {{ 'project.overview.dialog.linkProject.table.modified' | translate }} + + + {{ 'project.overview.dialog.linkProject.table.contributors' | translate }} + + + + + + +

+ + {{ item.title }} +

+ + {{ item.dateCreated | date: 'MMM d, y' }} + {{ item.dateModified | date: 'MMM d, y' }} + + @for (contributor of item.contributors; track contributor.fullName) { + {{ contributor.fullName }}{{ $last ? '' : ', ' }} + } + + +
+ + + + {{ 'common.search.noResultsFound' | translate }} + + +
+ } + + +
diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.scss b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts new file mode 100644 index 000000000..056e11a86 --- /dev/null +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LinkResourceDialogComponent } from './link-resource-dialog.component'; + +describe('LinkProjectDialogComponent', () => { + let component: LinkResourceDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LinkResourceDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(LinkResourceDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts new file mode 100644 index 000000000..618270b12 --- /dev/null +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts @@ -0,0 +1,207 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { ButtonGroupModule } from 'primeng/buttongroup'; +import { Checkbox, CheckboxChangeEvent } from 'primeng/checkbox'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Skeleton } from 'primeng/skeleton'; +import { TableModule, TablePageEvent } from 'primeng/table'; + +import { debounceTime, distinctUntilChanged } from 'rxjs'; + +import { DatePipe, NgClass } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, FormsModule } from '@angular/forms'; + +import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { SearchInputComponent } from '@shared/components'; +import { ResourceSearchMode, ResourceType } from '@shared/enums'; +import { MyResourcesSearchFilters } from '@shared/models'; +import { + CreateNodeLink, + DeleteNodeLink, + GetAllNodeLinks, + GetLinkedResources, + GetMyProjects, + GetMyRegistrations, + MyResourcesSelectors, + NodeLinksSelectors, +} from '@shared/stores'; + +@Component({ + selector: 'osf-link-resource-dialog', + imports: [ + SearchInputComponent, + TranslatePipe, + ButtonGroupModule, + Button, + NgClass, + Skeleton, + TableModule, + DatePipe, + Checkbox, + FormsModule, + ], + templateUrl: './link-resource-dialog.component.html', + styleUrl: './link-resource-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LinkResourceDialogComponent { + private readonly tableRows = 6; + private readonly destroyRef = inject(DestroyRef); + protected readonly dialogRef = inject(DynamicDialogRef); + protected readonly ResourceSearchMode = ResourceSearchMode; + protected readonly ResourceType = ResourceType; + protected currentPage = signal(1); + protected searchMode = signal(ResourceSearchMode.User); + protected resourceType = signal(ResourceType.Project); + protected searchControl = new FormControl(''); + + protected currentProject = select(ProjectOverviewSelectors.getProject); + protected myProjects = select(MyResourcesSelectors.getProjects); + protected isMyProjectsLoading = select(MyResourcesSelectors.getProjectsLoading); + protected myRegistrations = select(MyResourcesSelectors.getRegistrations); + protected isMyRegistrationsLoading = select(MyResourcesSelectors.getRegistrationsLoading); + protected totalProjectsCount = select(MyResourcesSelectors.getTotalProjects); + protected totalRegistrationsCount = select(MyResourcesSelectors.getTotalRegistrations); + protected isNodeLinksSubmitting = select(NodeLinksSelectors.getNodeLinksSubmitting); + protected isNodeLinksLoading = select(NodeLinksSelectors.getNodeLinksLoading); + protected nodeLinks = select(NodeLinksSelectors.getNodeLinks); + + protected currentTableItems = computed(() => { + return this.resourceType() === ResourceType.Project ? this.myProjects() : this.myRegistrations(); + }); + + protected isCurrentTableLoading = computed(() => { + return this.resourceType() === ResourceType.Project ? this.isMyProjectsLoading() : this.isMyRegistrationsLoading(); + }); + + protected currentTotalCount = computed(() => { + return this.resourceType() === ResourceType.Project ? this.totalProjectsCount() : this.totalRegistrationsCount(); + }); + + protected isItemLinked = computed(() => { + const nodeLinks = this.nodeLinks(); + const linkedTargetIds = new Set(nodeLinks.map((link) => link.targetNode.id)); + + return (itemId: string) => linkedTargetIds.has(itemId); + }); + + protected actions = createDispatchMap({ + getProjects: GetMyProjects, + getRegistrations: GetMyRegistrations, + createNodeLink: CreateNodeLink, + getAllNodeLinks: GetAllNodeLinks, + deleteNodeLink: DeleteNodeLink, + getLinkedProjects: GetLinkedResources, + }); + + constructor() { + this.setupSearchEffect(); + this.setupSearchSubscription(); + this.setupNodeLinksEffect(); + } + + onSearchModeChange(mode: ResourceSearchMode): void { + this.searchMode.set(mode); + this.currentPage.set(1); + } + + onObjectTypeChange(type: ResourceType): void { + this.resourceType.set(type); + this.currentPage.set(1); + } + + onPageChange(event: TablePageEvent): void { + const newPage = Math.floor(event.first / event.rows) + 1; + this.currentPage.set(newPage); + this.handleSearch(this.searchControl.value || '', this.searchMode(), this.resourceType()); + } + + setupSearchEffect() { + effect(() => { + this.currentPage.set(1); + this.handleSearch(this.searchControl.value || '', this.searchMode(), this.resourceType()); + }); + } + + setupNodeLinksEffect() { + effect(() => { + const currentProject = this.currentProject(); + if (currentProject) { + this.actions.getAllNodeLinks(currentProject.id); + } + }); + } + + setupSearchSubscription(): void { + this.searchControl.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((searchValue) => { + this.currentPage.set(1); + this.handleSearch(searchValue ?? '', this.searchMode(), this.resourceType()); + }); + } + + handleCloseDialog() { + const currentProjectId = this.currentProject()?.id; + + if (!currentProjectId) { + this.dialogRef.close(); + return; + } + + this.actions.getLinkedProjects(currentProjectId); + this.dialogRef.close(); + } + + handleToggleNodeLink($event: CheckboxChangeEvent, linkProjectId: string) { + const currentProjectId = this.currentProject()?.id; + + if (!currentProjectId) { + return; + } + + const isCurrentlyLinked = this.isItemLinked()(linkProjectId); + + if (isCurrentlyLinked) { + const nodeLinks = this.nodeLinks(); + const linkToDelete = nodeLinks.find((link) => link.targetNode.id === linkProjectId); + + if (!linkToDelete) return; + + this.actions.deleteNodeLink(currentProjectId, linkToDelete.id); + } else { + this.actions.createNodeLink(currentProjectId, linkProjectId); + } + } + + private handleSearch(searchValue: string, searchMode: ResourceSearchMode, resourceType: ResourceType): void { + const searchFilters: MyResourcesSearchFilters = { + searchValue, + searchFields: ['title', 'tags', 'description'], + }; + + untracked(() => { + if (resourceType === ResourceType.Project) { + const currentProjectId = this.currentProject()?.id; + if (!currentProjectId) return; + this.actions.getProjects(this.currentPage(), this.tableRows, searchFilters, searchMode, currentProjectId); + } else if (resourceType === ResourceType.Registration) { + this.actions.getRegistrations(this.currentPage(), this.tableRows, searchFilters); + } + }); + } +} diff --git a/src/app/features/project/overview/components/linked-projects/linked-projects.component.ts b/src/app/features/project/overview/components/linked-projects/linked-projects.component.ts deleted file mode 100644 index dd3196bb8..000000000 --- a/src/app/features/project/overview/components/linked-projects/linked-projects.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { Menu } from 'primeng/menu'; -import { Skeleton } from 'primeng/skeleton'; - -import { NgClass } from '@angular/common'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; - -import { TruncatedTextComponent } from '@osf/shared/components'; - -import { ProjectOverviewSelectors } from '../../store'; - -@Component({ - selector: 'osf-linked-projects', - imports: [TruncatedTextComponent, Skeleton, Button, NgClass, Menu, TranslatePipe], - templateUrl: './linked-projects.component.html', - styleUrl: './linked-projects.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class LinkedProjectsComponent { - protected linkedProjects = select(ProjectOverviewSelectors.getLinkedProjects); - protected isLinkedProjectsLoading = select(ProjectOverviewSelectors.getLinkedProjectsLoading); -} diff --git a/src/app/features/project/overview/components/linked-projects/linked-projects.component.html b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html similarity index 55% rename from src/app/features/project/overview/components/linked-projects/linked-projects.component.html rename to src/app/features/project/overview/components/linked-resources/linked-resources.component.html index 89b51d278..a80536c42 100644 --- a/src/app/features/project/overview/components/linked-projects/linked-projects.component.html +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html @@ -1,47 +1,47 @@

{{ 'project.overview.linkedProjects.title' | translate }}

- +
- @if (isLinkedProjectsLoading()) { + @if (isLinkedResourcesLoading()) { } @else { - @if (linkedProjects().length) { - @for (linkedProject of linkedProjects(); track linkedProject.id) { + @if (linkedResources().length) { + @for (linkedResource of linkedResources(); track linkedResource.id) {
- @for (contributor of linkedProject.contributors; track contributor.id) { + @for (contributor of linkedResource.contributors; track contributor.id) {
{{ contributor.fullName }} {{ $last ? '' : ',' }}
}
- +
diff --git a/src/app/features/project/overview/components/linked-projects/linked-projects.component.scss b/src/app/features/project/overview/components/linked-resources/linked-resources.component.scss similarity index 100% rename from src/app/features/project/overview/components/linked-projects/linked-projects.component.scss rename to src/app/features/project/overview/components/linked-resources/linked-resources.component.scss diff --git a/src/app/features/project/overview/components/linked-projects/linked-projects.component.spec.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts similarity index 56% rename from src/app/features/project/overview/components/linked-projects/linked-projects.component.spec.ts rename to src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts index bc142dc96..87c5617ea 100644 --- a/src/app/features/project/overview/components/linked-projects/linked-projects.component.spec.ts +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts @@ -1,17 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { LinkedProjectsComponent } from './linked-projects.component'; +import { LinkedResourcesComponent } from './linked-resources.component'; describe('LinkedProjectsComponent', () => { - let component: LinkedProjectsComponent; - let fixture: ComponentFixture; + let component: LinkedResourcesComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LinkedProjectsComponent], + imports: [LinkedResourcesComponent], }).compileComponents(); - fixture = TestBed.createComponent(LinkedProjectsComponent); + fixture = TestBed.createComponent(LinkedResourcesComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts new file mode 100644 index 000000000..6e44c70e0 --- /dev/null +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts @@ -0,0 +1,70 @@ +import { select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DialogService } from 'primeng/dynamicdialog'; +import { Skeleton } from 'primeng/skeleton'; + +import { NgClass } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; + +import { DeleteNodeLinkDialogComponent, LinkResourceDialogComponent } from '@osf/features/project/overview/components'; +import { TruncatedTextComponent } from '@osf/shared/components'; +import { NodeLinksSelectors } from '@shared/stores'; +import { IS_XSMALL } from '@shared/utils'; + +@Component({ + selector: 'osf-linked-resources', + imports: [Button, NgClass, Skeleton, TranslatePipe, TruncatedTextComponent], + templateUrl: './linked-resources.component.html', + styleUrl: './linked-resources.component.scss', + providers: [DialogService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LinkedResourcesComponent { + private dialogService = inject(DialogService); + private translateService = inject(TranslateService); + protected linkedResources = select(NodeLinksSelectors.getLinkedResources); + protected isLinkedResourcesLoading = select(NodeLinksSelectors.getLinkedResourcesLoading); + protected isMobile = toSignal(inject(IS_XSMALL)); + protected nodeLinks = select(NodeLinksSelectors.getNodeLinks); + + openLinkProjectModal() { + const dialogWidth = this.isMobile() ? '95vw' : '850px'; + + this.dialogService.open(LinkResourceDialogComponent, { + width: dialogWidth, + focusOnShow: false, + header: this.translateService.instant('project.overview.dialog.linkProject.header'), + closeOnEscape: true, + modal: true, + closable: true, + }); + } + + openDeleteResourceModal(resourceId: string): void { + const dialogWidth = this.isMobile() ? '95vw' : '650px'; + + const currentLink = this.getCurrentResourceNodeLink(resourceId); + + if (!currentLink) return; + + this.dialogService.open(DeleteNodeLinkDialogComponent, { + width: dialogWidth, + focusOnShow: false, + header: this.translateService.instant('project.overview.dialog.deleteNodeLink.header'), + closeOnEscape: true, + modal: true, + closable: true, + data: { + nodeLinkId: currentLink.id, + }, + }); + } + + private getCurrentResourceNodeLink(resourceId: string) { + return this.nodeLinks().find((link) => link.targetNode.id === resourceId); + } +} diff --git a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts index a09b20184..063f82392 100644 --- a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts +++ b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts @@ -16,7 +16,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; -import { GetMyBookmarks, MyProjectsSelectors } from '@osf/features/my-projects/store'; import { DuplicateDialogComponent, TogglePublicityDialogComponent } from '@osf/features/project/overview/components'; import { IconComponent } from '@osf/shared/components'; import { ToastService } from '@osf/shared/services'; @@ -28,6 +27,8 @@ import { AddResourceToBookmarks, BookmarksSelectors, RemoveResourceFromBookmarks import { SOCIAL_ACTION_ITEMS } from '../../constants'; import { ForkDialogComponent } from '../fork-dialog/fork-dialog.component'; +import { GetMyBookmarks, MyResourcesSelectors } from 'src/app/shared/stores/my-resources'; + @Component({ selector: 'osf-overview-toolbar', imports: [ @@ -58,10 +59,10 @@ export class OverviewToolbarComponent { currentResource = input.required(); visibilityToggle = input(true); showViewOnlyLinks = input(true); - protected isBookmarksLoading = select(MyProjectsSelectors.getBookmarksLoading); + protected isBookmarksLoading = select(MyResourcesSelectors.getBookmarksLoading); protected isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); protected bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); - protected bookmarkedProjects = select(MyProjectsSelectors.getBookmarks); + protected bookmarkedProjects = select(MyResourcesSelectors.getBookmarks); protected readonly socialsActionItems = SOCIAL_ACTION_ITEMS; protected readonly forkActionItems = [ { diff --git a/src/app/features/project/overview/mappers/project-overview.mapper.ts b/src/app/features/project/overview/mappers/project-overview.mapper.ts index b840f42b1..8fcc92437 100644 --- a/src/app/features/project/overview/mappers/project-overview.mapper.ts +++ b/src/app/features/project/overview/mappers/project-overview.mapper.ts @@ -1,9 +1,4 @@ -import { - ComponentGetResponseJsoApi, - ComponentOverview, - ProjectOverview, - ProjectOverviewGetResponseJsoApi, -} from '../models'; +import { ProjectOverview, ProjectOverviewGetResponseJsoApi } from '../models'; export class ProjectOverviewMapper { static fromGetProjectResponse(response: ProjectOverviewGetResponseJsoApi): ProjectOverview { @@ -84,22 +79,4 @@ export class ProjectOverviewMapper { }, }; } - - static fromGetComponentResponse(response: ComponentGetResponseJsoApi): ComponentOverview { - return { - id: response.id, - type: response.type, - title: response.attributes.title, - description: response.attributes.description, - public: response.attributes.public, - contributors: response.embeds.bibliographic_contributors.data.map((contributor) => ({ - id: contributor.embeds.users.data.id, - 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, - type: contributor.embeds.users.data.type, - })), - }; - } } diff --git a/src/app/features/project/overview/models/project-overview.models.ts b/src/app/features/project/overview/models/project-overview.models.ts index ce507c7ac..fdb5a7519 100644 --- a/src/app/features/project/overview/models/project-overview.models.ts +++ b/src/app/features/project/overview/models/project-overview.models.ts @@ -9,45 +9,6 @@ export interface ProjectOverviewContributor { type: string; } -export interface ComponentOverview { - id: string; - type: string; - title: string; - description: string; - public: boolean; - contributors: ProjectOverviewContributor[]; -} - -export interface ComponentGetResponseJsoApi { - id: string; - type: string; - attributes: { - title: string; - description: string; - public: boolean; - }; - embeds: { - bibliographic_contributors: { - data: { - embeds: { - users: { - data: { - id: string; - type: string; - attributes: { - family_name: string; - full_name: string; - given_name: string; - middle_name: string; - }; - }; - }; - }; - }[]; - }; - }; -} - export interface ProjectOverview { id: string; type: string; diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index 50413a0de..88b061b15 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -13,7 +13,7 @@
- +
diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index fdfed3824..d869bb6c2 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -13,24 +13,18 @@ import { ClearCollections } from '@osf/features/collections/store/collections'; import { LoadingSpinnerComponent, ResourceMetadataComponent, SubHeaderComponent } from '@shared/components'; import { ResourceType } from '@shared/enums'; import { MapProjectOverview } from '@shared/mappers/resource-overview.mappers'; -import { GetBookmarksCollectionId } from '@shared/stores'; +import { GetAllNodeLinks, GetBookmarksCollectionId, GetLinkedResources } from '@shared/stores'; import { ClearWiki, GetHomeWiki } from '../wiki/store'; import { - LinkedProjectsComponent, + LinkedResourcesComponent, OverviewComponentsComponent, OverviewToolbarComponent, OverviewWikiComponent, RecentActivityComponent, } from './components'; -import { - ClearProjectOverview, - GetComponents, - GetLinkedProjects, - GetProjectById, - ProjectOverviewSelectors, -} from './store'; +import { ClearProjectOverview, GetComponents, GetProjectById, ProjectOverviewSelectors } from './store'; @Component({ selector: 'osf-project-overview', @@ -45,7 +39,7 @@ import { LoadingSpinnerComponent, OverviewWikiComponent, OverviewComponentsComponent, - LinkedProjectsComponent, + LinkedResourcesComponent, RecentActivityComponent, OverviewToolbarComponent, ResourceMetadataComponent, @@ -64,7 +58,8 @@ export class ProjectOverviewComponent implements OnInit { getBookmarksId: GetBookmarksCollectionId, getHomeWiki: GetHomeWiki, getComponents: GetComponents, - getLinkedProjects: GetLinkedProjects, + getLinkedProjects: GetLinkedResources, + getNodeLinks: GetAllNodeLinks, clearProjectOverview: ClearProjectOverview, clearWiki: ClearWiki, clearCollections: ClearCollections, @@ -104,6 +99,7 @@ export class ProjectOverviewComponent implements OnInit { this.actions.getBookmarksId(); this.actions.getHomeWiki(projectId); this.actions.getComponents(projectId); + this.actions.getNodeLinks(projectId); this.actions.getLinkedProjects(projectId); } } diff --git a/src/app/features/project/overview/services/project-overview.service.ts b/src/app/features/project/overview/services/project-overview.service.ts index c66ffaf90..0740c63a9 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -3,15 +3,13 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; +import { JsonApiResponse } from '@core/models'; import { JsonApiService } from '@osf/core/services'; +import { ComponentGetResponseJsonApi, ComponentOverview } from '@osf/shared/models'; +import { ComponentsMapper } from '@shared/mappers'; import { ProjectOverviewMapper } from '../mappers'; -import { - ComponentGetResponseJsoApi, - ComponentOverview, - ProjectOverview, - ProjectOverviewResponseJsonApi, -} from '../models'; +import { ProjectOverview, ProjectOverviewResponseJsonApi } from '../models'; import { environment } from 'src/environments/environment'; @@ -128,18 +126,9 @@ export class ProjectOverviewService { }; return this.#jsonApiService - .get<{ data: ComponentGetResponseJsoApi[] }>(`${environment.apiUrl}/nodes/${projectId}/children`, params) - .pipe(map((response) => response.data.map((item) => ProjectOverviewMapper.fromGetComponentResponse(item)))); - } - - getLinkedProjects(projectId: string): Observable { - const params: Record = { - embed: 'bibliographic_contributors', - 'fields[users]': 'family_name,full_name,given_name,middle_name', - }; - - return this.#jsonApiService - .get<{ data: ComponentGetResponseJsoApi[] }>(`${environment.apiUrl}/nodes/${projectId}/linked_nodes`, params) - .pipe(map((response) => response.data.map((item) => ProjectOverviewMapper.fromGetComponentResponse(item)))); + .get< + JsonApiResponse + >(`${environment.apiUrl}/nodes/${projectId}/children`, params) + .pipe(map((response) => response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)))); } } diff --git a/src/app/features/project/overview/store/project-overview.actions.ts b/src/app/features/project/overview/store/project-overview.actions.ts index c7a5fd259..0e7b27370 100644 --- a/src/app/features/project/overview/store/project-overview.actions.ts +++ b/src/app/features/project/overview/store/project-overview.actions.ts @@ -62,9 +62,3 @@ export class GetComponents { constructor(public projectId: string) {} } - -export class GetLinkedProjects { - static readonly type = '[Project Overview] Get Linked Projects'; - - constructor(public projectId: string) {} -} diff --git a/src/app/features/project/overview/store/project-overview.model.ts b/src/app/features/project/overview/store/project-overview.model.ts index 2a79bd70f..d6f7f35d5 100644 --- a/src/app/features/project/overview/store/project-overview.model.ts +++ b/src/app/features/project/overview/store/project-overview.model.ts @@ -1,9 +1,8 @@ -import { AsyncStateModel } from '@osf/shared/models'; +import { AsyncStateModel, ComponentOverview } from '@osf/shared/models'; -import { ComponentOverview, ProjectOverview } from '../models'; +import { ProjectOverview } from '../models'; export interface ProjectOverviewStateModel { project: AsyncStateModel; components: AsyncStateModel; - linkedProjects: AsyncStateModel; } diff --git a/src/app/features/project/overview/store/project-overview.selectors.ts b/src/app/features/project/overview/store/project-overview.selectors.ts index b385f6d20..6378e3ab9 100644 --- a/src/app/features/project/overview/store/project-overview.selectors.ts +++ b/src/app/features/project/overview/store/project-overview.selectors.ts @@ -29,16 +29,6 @@ export class ProjectOverviewSelectors { return state.components.isSubmitting; } - @Selector([ProjectOverviewState]) - static getLinkedProjects(state: ProjectOverviewStateModel) { - return state.linkedProjects.data; - } - - @Selector([ProjectOverviewState]) - static getLinkedProjectsLoading(state: ProjectOverviewStateModel) { - return state.linkedProjects.isLoading; - } - @Selector([ProjectOverviewState]) static getForkProjectSubmitting(state: ProjectOverviewStateModel) { return state.project.isSubmitting; diff --git a/src/app/features/project/overview/store/project-overview.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index dfa899118..796c0c1a8 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -15,7 +15,6 @@ import { DuplicateProject, ForkResource, GetComponents, - GetLinkedProjects, GetProjectById, UpdateProjectPublicStatus, } from './project-overview.actions'; @@ -34,12 +33,6 @@ const PROJECT_OVERVIEW_DEFAULTS: ProjectOverviewStateModel = { isSubmitting: false, error: null, }, - linkedProjects: { - data: [], - isLoading: false, - isSubmitting: false, - error: null, - }, }; @State({ @@ -250,30 +243,6 @@ export class ProjectOverviewState { ); } - @Action(GetLinkedProjects) - getLinkedProjects(ctx: StateContext, action: GetLinkedProjects) { - const state = ctx.getState(); - ctx.patchState({ - linkedProjects: { - ...state.linkedProjects, - isLoading: true, - }, - }); - - return this.projectOverviewService.getLinkedProjects(action.projectId).pipe( - tap((linkedProjects) => { - ctx.patchState({ - linkedProjects: { - data: linkedProjects, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => this.handleError(ctx, 'linkedProjects', error)) - ); - } - private handleError( ctx: StateContext, section: keyof ProjectOverviewStateModel, diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 94cbef903..fa7f1fd30 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -3,7 +3,7 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { ResourceType } from '@osf/shared/enums'; -import { ContributorsState, SubjectsState, ViewOnlyLinkState } from '@osf/shared/stores'; +import { ContributorsState, NodeLinksState, SubjectsState, ViewOnlyLinkState } from '@osf/shared/stores'; import { AnalyticsState } from './analytics/store'; import { ProjectFilesState } from './files/store'; @@ -23,6 +23,7 @@ export const projectRoutes: Routes = [ path: 'overview', loadComponent: () => import('../project/overview/project-overview.component').then((mod) => mod.ProjectOverviewComponent), + providers: [provideStates([NodeLinksState])], }, { path: 'metadata', diff --git a/src/app/features/project/settings/store/settings.state.ts b/src/app/features/project/settings/store/settings.state.ts index 9ef016d59..9263c66b2 100644 --- a/src/app/features/project/settings/store/settings.state.ts +++ b/src/app/features/project/settings/store/settings.state.ts @@ -5,7 +5,6 @@ import { catchError, tap } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; -import { MyProjectsService } from '@osf/features/my-projects/services'; import { SettingsService } from '@osf/features/project/settings/services'; import { DeleteProject, @@ -16,6 +15,7 @@ import { } from '@osf/features/project/settings/store/settings.actions'; import { SettingsStateModel } from '@osf/features/project/settings/store/settings.model'; import { NodeData } from '@shared/models'; +import { MyResourcesService } from '@shared/services'; import { ProjectSettingsModel } from '../models'; @@ -37,7 +37,7 @@ import { ProjectSettingsModel } from '../models'; @Injectable() export class SettingsState { private readonly settingsService = inject(SettingsService); - private readonly myProjectService = inject(MyProjectsService); + private readonly myProjectService = inject(MyResourcesService); private readonly REFRESH_INTERVAL = 5 * 60 * 1000; diff --git a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts index 756445ad3..88be3e1cb 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts @@ -10,12 +10,12 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MY_PROJECTS_TABLE_PARAMS } from '@osf/core/constants/my-projects-table.constants'; -import { CreateProject, GetMyProjects, MyProjectsState } from '@osf/features/my-projects/store'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; import { AddProjectFormComponent } from './add-project-form.component'; import { InstitutionsState } from 'src/app/shared/stores/institutions'; +import { CreateProject, GetMyProjects, MyResourcesState } from 'src/app/shared/stores/my-resources'; describe('AddProjectFormComponent', () => { let component: AddProjectFormComponent; @@ -36,7 +36,7 @@ describe('AddProjectFormComponent', () => { await TestBed.configureTestingModule({ imports: [AddProjectFormComponent, MockPipe(TranslatePipe)], providers: [ - provideStore([MyProjectsState, InstitutionsState]), + provideStore([MyResourcesState, InstitutionsState]), provideHttpClient(), provideHttpClientTesting(), MockProvider(DynamicDialogRef, { close: jest.fn() }), 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 e86aabfc6..f15e71df9 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 @@ -8,10 +8,10 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { MyProjectsItem } from '@osf/features/my-projects/models/my-projects.models'; import { SortOrder } from '@osf/shared/enums/sort-order.enum'; import { TableParameters } from '@osf/shared/models/table-parameters.model'; import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; +import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; @Component({ selector: 'osf-my-projects-table', @@ -21,7 +21,7 @@ import { SearchInputComponent } from '@shared/components/search-input/search-inp changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyProjectsTableComponent { - items = input([]); + items = input([]); tableParams = input.required(); searchControl = input(new FormControl('')); sortColumn = input(undefined); @@ -31,7 +31,7 @@ export class MyProjectsTableComponent { pageChange = output(); sort = output(); - itemClick = output(); + itemClick = output(); protected onPageChange(event: TablePageEvent): void { this.pageChange.emit(event); @@ -41,7 +41,7 @@ export class MyProjectsTableComponent { this.sort.emit(event); } - protected onItemClick(item: MyProjectsItem): void { + protected onItemClick(item: MyResourcesItem): void { this.itemClick.emit(item); } } diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index 64dbb2988..c85f58c5d 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -12,6 +12,7 @@ export * from './get-resources-request-type.enum'; export * from './profile-addons-stepper.enum'; export * from './registration-review-states.enum'; export * from './registry-status.enum'; +export * from './resource-search-mode.enum'; export * from './resource-tab.enum'; export * from './resource-type.enum'; export * from './reusable-filter-type.enum'; diff --git a/src/app/shared/enums/resource-search-mode.enum.ts b/src/app/shared/enums/resource-search-mode.enum.ts new file mode 100644 index 000000000..74508a4f0 --- /dev/null +++ b/src/app/shared/enums/resource-search-mode.enum.ts @@ -0,0 +1,4 @@ +export enum ResourceSearchMode { + User = 'user', + All = 'all', +} diff --git a/src/app/shared/mappers/components/components.mapper.ts b/src/app/shared/mappers/components/components.mapper.ts new file mode 100644 index 000000000..f300d04f2 --- /dev/null +++ b/src/app/shared/mappers/components/components.mapper.ts @@ -0,0 +1,21 @@ +import { ComponentGetResponseJsonApi, ComponentOverview } from '@shared/models'; + +export class ComponentsMapper { + static fromGetComponentResponse(response: ComponentGetResponseJsonApi): ComponentOverview { + return { + id: response.id, + type: response.type, + title: response.attributes.title, + description: response.attributes.description, + public: response.attributes.public, + contributors: response.embeds.bibliographic_contributors.data.map((contributor) => ({ + id: contributor.embeds.users.data.id, + 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, + type: contributor.embeds.users.data.type, + })), + }; + } +} diff --git a/src/app/shared/mappers/components/index.ts b/src/app/shared/mappers/components/index.ts new file mode 100644 index 000000000..8d7634b2b --- /dev/null +++ b/src/app/shared/mappers/components/index.ts @@ -0,0 +1 @@ +export * from './components.mapper'; diff --git a/src/app/shared/mappers/index.ts b/src/app/shared/mappers/index.ts index 41661151c..c1470f13e 100644 --- a/src/app/shared/mappers/index.ts +++ b/src/app/shared/mappers/index.ts @@ -1,8 +1,10 @@ export * from './addon.mapper'; +export * from './components'; export * from './contributors'; export * from './filters'; export * from './institutions'; export * from './licenses.mapper'; +export * from './node-links'; export * from './registry'; export * from './resource-card'; export * from './resource-overview.mappers'; diff --git a/src/app/shared/mappers/node-links/index.ts b/src/app/shared/mappers/node-links/index.ts new file mode 100644 index 000000000..7feb2c976 --- /dev/null +++ b/src/app/shared/mappers/node-links/index.ts @@ -0,0 +1 @@ +export * from './node-links.mapper'; diff --git a/src/app/shared/mappers/node-links/node-links.mapper.ts b/src/app/shared/mappers/node-links/node-links.mapper.ts new file mode 100644 index 000000000..ede7b6176 --- /dev/null +++ b/src/app/shared/mappers/node-links/node-links.mapper.ts @@ -0,0 +1,14 @@ +import { NodeLink, NodeLinkJsonApi } from '@shared/models/node-links'; + +export class NodeLinksMapper { + static fromNodeLinkResponse(response: NodeLinkJsonApi): NodeLink { + return { + type: response.type, + id: response.id, + targetNode: { + type: response.relationships.target_node.data.type, + id: response.relationships.target_node.data.id, + }, + }; + } +} diff --git a/src/app/shared/mappers/projects/projects.mapper.ts b/src/app/shared/mappers/projects/projects.mapper.ts index 74a9d7d14..c4ce00446 100644 --- a/src/app/shared/mappers/projects/projects.mapper.ts +++ b/src/app/shared/mappers/projects/projects.mapper.ts @@ -4,25 +4,10 @@ import { Project, ProjectJsonApi, ProjectsResponseJsonApi } from '@shared/models export class ProjectsMapper { static fromGetAllProjectsResponse(response: ProjectsResponseJsonApi): Project[] { - return response.data.map((project) => ({ - id: project.id, - type: project.type, - title: project.attributes.title, - dateModified: project.attributes.date_modified, - isPublic: project.attributes.public, - licenseId: project.relationships.license?.data?.id || null, - licenseOptions: project.attributes.node_license - ? { - year: project.attributes.node_license.year, - copyrightHolders: project.attributes.node_license.copyright_holders.join(','), - } - : null, - description: project.attributes.description, - tags: project.attributes.tags || [], - })); + return response.data.map((project) => this.fromProjectResponse(project)); } - static fromPatchProjectResponse(project: ProjectJsonApi): Project { + static fromProjectResponse(project: ProjectJsonApi): Project { return { id: project.id, type: project.type, diff --git a/src/app/shared/models/components/components.models.ts b/src/app/shared/models/components/components.models.ts new file mode 100644 index 000000000..08a48cd38 --- /dev/null +++ b/src/app/shared/models/components/components.models.ts @@ -0,0 +1,45 @@ +export interface ComponentOverview { + id: string; + type: string; + title: string; + description: string; + public: boolean; + contributors: { + familyName: string; + fullName: string; + givenName: string; + middleName: string; + id: string; + type: string; + }[]; +} + +export interface ComponentGetResponseJsonApi { + id: string; + type: string; + attributes: { + title: string; + description: string; + public: boolean; + }; + embeds: { + bibliographic_contributors: { + data: { + embeds: { + users: { + data: { + id: string; + type: string; + attributes: { + family_name: string; + full_name: string; + given_name: string; + middle_name: string; + }; + }; + }; + }; + }[]; + }; + }; +} diff --git a/src/app/shared/models/components/index.ts b/src/app/shared/models/components/index.ts new file mode 100644 index 000000000..87746b718 --- /dev/null +++ b/src/app/shared/models/components/index.ts @@ -0,0 +1 @@ +export * from './components.models'; diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index 3b9d4951c..28c8654e0 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -3,6 +3,7 @@ export * from './brand.json-api.model'; export * from './brand.model'; export * from './can-deactivate.interface'; export * from './charts'; +export * from './components'; export * from './confirmation-options.model'; export * from './contributors'; export * from './create-component-form.model'; @@ -20,6 +21,7 @@ export * from './license.model'; export * from './license.model'; export * from './licenses-json-api.model'; export * from './metadata-field.model'; +export * from './my-resources'; export * from './nodes/create-project-form.model'; export * from './nodes/nodes-json-api.model'; export * from './paginated-data.model'; diff --git a/src/app/shared/models/my-resources/index.ts b/src/app/shared/models/my-resources/index.ts new file mode 100644 index 000000000..e007cc622 --- /dev/null +++ b/src/app/shared/models/my-resources/index.ts @@ -0,0 +1,3 @@ +export * from './my-resources.models'; +export * from './my-resources-endpoint.type'; +export * from './my-resources-search-filters.models'; diff --git a/src/app/shared/models/my-resources/my-resources-endpoint.type.ts b/src/app/shared/models/my-resources/my-resources-endpoint.type.ts new file mode 100644 index 000000000..ee5655146 --- /dev/null +++ b/src/app/shared/models/my-resources/my-resources-endpoint.type.ts @@ -0,0 +1 @@ +export type EndpointType = 'nodes/' | 'registrations/' | 'preprints/' | `collections/${string}/${string}/`; diff --git a/src/app/features/my-projects/models/my-projects-search-filters.models.ts b/src/app/shared/models/my-resources/my-resources-search-filters.models.ts similarity index 65% rename from src/app/features/my-projects/models/my-projects-search-filters.models.ts rename to src/app/shared/models/my-resources/my-resources-search-filters.models.ts index 1e046f523..d148d0c25 100644 --- a/src/app/features/my-projects/models/my-projects-search-filters.models.ts +++ b/src/app/shared/models/my-resources/my-resources-search-filters.models.ts @@ -1,8 +1,8 @@ -import { SortOrder } from '@osf/shared/enums'; +import { SortOrder } from '@shared/enums'; export type SearchField = 'tags' | 'title' | 'description'; -export interface MyProjectsSearchFilters { +export interface MyResourcesSearchFilters { searchValue?: string; searchFields?: SearchField[]; sortColumn?: string; diff --git a/src/app/features/my-projects/models/my-projects.models.ts b/src/app/shared/models/my-resources/my-resources.models.ts similarity index 67% rename from src/app/features/my-projects/models/my-projects.models.ts rename to src/app/shared/models/my-resources/my-resources.models.ts index 215e0c4fc..219c157d2 100644 --- a/src/app/features/my-projects/models/my-projects.models.ts +++ b/src/app/shared/models/my-resources/my-resources.models.ts @@ -1,10 +1,11 @@ -import { JsonApiResponse } from '@osf/core/models'; +import { JsonApiResponse } from '@core/models'; -export interface MyProjectsItemGetResponseJsonApi { +export interface MyResourcesItemGetResponseJsonApi { id: string; type: string; attributes: { title: string; + date_created: string; date_modified: string; public: boolean; }; @@ -34,24 +35,25 @@ export interface MyProjectsItemGetResponseJsonApi { }; } -export interface MyProjectsContributor { +export interface MyResourcesContributor { familyName: string; fullName: string; givenName: string; middleName: string; } -export interface MyProjectsItem { +export interface MyResourcesItem { id: string; type: string; title: string; + dateCreated: string; dateModified: string; isPublic: boolean; - contributors: MyProjectsContributor[]; + contributors: MyResourcesContributor[]; } -export interface MyProjectsItemResponseJsonApi { - data: MyProjectsItem[]; +export interface MyResourcesItemResponseJsonApi { + data: MyResourcesItem[]; links: { meta: { total: number; @@ -60,7 +62,7 @@ export interface MyProjectsItemResponseJsonApi { }; } -export interface MyProjectsResponseJsonApi extends JsonApiResponse { +export interface MyResourcesResponseJsonApi extends JsonApiResponse { links: { meta: { total: number; diff --git a/src/app/shared/models/node-links/index.ts b/src/app/shared/models/node-links/index.ts new file mode 100644 index 000000000..90243b652 --- /dev/null +++ b/src/app/shared/models/node-links/index.ts @@ -0,0 +1,2 @@ +export * from './node-link.model'; +export * from './node-link-json-api.model'; diff --git a/src/app/shared/models/node-links/node-link-json-api.model.ts b/src/app/shared/models/node-links/node-link-json-api.model.ts new file mode 100644 index 000000000..bb0936009 --- /dev/null +++ b/src/app/shared/models/node-links/node-link-json-api.model.ts @@ -0,0 +1,12 @@ +export interface NodeLinkJsonApi { + relationships: { + target_node: { + data: { + id: string; + type: string; + }; + }; + }; + id: string; + type: string; +} diff --git a/src/app/shared/models/node-links/node-link.model.ts b/src/app/shared/models/node-links/node-link.model.ts new file mode 100644 index 000000000..e3cc18684 --- /dev/null +++ b/src/app/shared/models/node-links/node-link.model.ts @@ -0,0 +1,8 @@ +export interface NodeLink { + type: string; + id: string; + targetNode: { + id: string; + type: string; + }; +} diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index cd9230ebd..be5219379 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -8,6 +8,8 @@ export { FiltersOptionsService } from './filters-options.service'; export { InstitutionsService } from './institutions.service'; export { LicensesService } from './licenses.service'; export { LoaderService } from './loader.service'; +export { MyResourcesService } from './my-resources.service'; +export { NodeLinksService } from './node-links.service'; export { RegionsService } from './regions.service'; export { ResourceCardService } from './resource-card.service'; export { SearchService } from './search.service'; diff --git a/src/app/features/my-projects/services/my-projects.service.ts b/src/app/shared/services/my-resources.service.ts similarity index 50% rename from src/app/features/my-projects/services/my-projects.service.ts rename to src/app/shared/services/my-resources.service.ts index 9effb9750..dbfb223cb 100644 --- a/src/app/features/my-projects/services/my-projects.service.ts +++ b/src/app/shared/services/my-resources.service.ts @@ -4,49 +4,49 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { JsonApiResponse } from '@core/models'; -import { JsonApiService } from '@osf/core/services'; -import { SparseCollectionsResponseJsonApi } from '@osf/features/collections/models'; -import { ResourceType, SortOrder } from '@osf/shared/enums'; -import { CreateProjectPayloadJsoApi, NodeResponseModel, UpdateNodeRequestModel } from '@shared/models'; - -import { MyProjectsMapper } from '../mappers'; +import { JsonApiService } from '@core/services'; +import { MyResourcesMapper } from '@osf/features/my-projects/mappers'; +import { ResourceSearchMode, ResourceType, SortOrder } from '@shared/enums'; import { + CreateProjectPayloadJsoApi, EndpointType, - MyProjectsItem, - MyProjectsItemGetResponseJsonApi, - MyProjectsItemResponseJsonApi, - MyProjectsResponseJsonApi, - MyProjectsSearchFilters, -} from '../models'; + MyResourcesItem, + MyResourcesItemGetResponseJsonApi, + MyResourcesItemResponseJsonApi, + MyResourcesResponseJsonApi, + MyResourcesSearchFilters, + NodeResponseModel, + UpdateNodeRequestModel, +} from '@shared/models'; import { environment } from 'src/environments/environment'; @Injectable({ providedIn: 'root', }) -export class MyProjectsService { +export class MyResourcesService { private apiUrl = environment.apiUrl; private sortFieldMap: Record = { title: 'title', dateModified: 'date_modified', + dateCreated: 'date_created', }; private readonly jsonApiService = inject(JsonApiService); - private getMyItems( - endpoint: EndpointType, - filters?: MyProjectsSearchFilters, + private buildCommonParams( + filters?: MyResourcesSearchFilters, pageNumber?: number, pageSize?: number, - fields?: string - ): Observable { + resourceType?: string + ): Record { const params: Record = { 'embed[]': ['bibliographic_contributors'], 'fields[users]': 'family_name,full_name,given_name,middle_name', }; - if (fields) { - params[`fields[${fields}]`] = 'title,date_modified,public,bibliographic_contributors'; + if (resourceType) { + params[`fields[${resourceType}]`] = 'title,date_created,date_modified,public,bibliographic_contributors'; } if (filters?.searchValue && filters.searchFields?.length) { @@ -61,77 +61,90 @@ export class MyProjectsService { 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}`; + return params; + } + + private getResources( + endpoint: EndpointType, + filters?: MyResourcesSearchFilters, + pageNumber?: number, + pageSize?: number, + resourceType?: string, + searchMode?: ResourceSearchMode, + rootProjectId?: string + ): Observable { + const params = this.buildCommonParams(filters, pageNumber, pageSize, resourceType); + + if (searchMode !== ResourceSearchMode.All) { + 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'; + } + } + + if (rootProjectId) { + params['filter[root][ne]'] = rootProjectId; + } + + let url; + if (searchMode === ResourceSearchMode.All) { + url = environment.apiUrl + '/' + endpoint + '/'; } else { - params['sort'] = '-date_modified'; + url = endpoint.startsWith('collections/') + ? environment.apiUrl + '/' + endpoint + : environment.apiUrl + '/users/me/' + endpoint; } - // const url = environment.apiUrl + '/' + endpoint + '/'; - const url = endpoint.startsWith('collections/') - ? environment.apiUrl + '/' + endpoint - : environment.apiUrl + '/users/me/' + endpoint; - - return this.jsonApiService.get(url, params).pipe( - map((response: MyProjectsResponseJsonApi) => ({ - data: response.data.map((item: MyProjectsItemGetResponseJsonApi) => MyProjectsMapper.fromResponse(item)), + + return this.jsonApiService.get(url, params).pipe( + map((response: MyResourcesResponseJsonApi) => ({ + data: response.data.map((item: MyResourcesItemGetResponseJsonApi) => MyResourcesMapper.fromResponse(item)), links: response.links, })) ); } getMyProjects( - filters?: MyProjectsSearchFilters, + filters?: MyResourcesSearchFilters, pageNumber?: number, - pageSize?: number - ): Observable { - return this.getMyItems('nodes', filters, pageNumber, pageSize, 'nodes'); - } - - getBookmarksCollectionId(): Observable { - const params: Record = { - 'fields[collections]': 'title,bookmarks', - }; - - return this.jsonApiService.get(environment.apiUrl + '/collections/', params).pipe( - map((response) => { - const bookmarksCollection = response.data.find( - (collection) => collection.attributes.title === 'Bookmarks' && collection.attributes.bookmarks - ); - return bookmarksCollection?.id ?? ''; - }) - ); + pageSize?: number, + searchMode?: ResourceSearchMode, + rootProjectId?: string + ): Observable { + return this.getResources('nodes/', filters, pageNumber, pageSize, 'nodes', searchMode, rootProjectId); } getMyRegistrations( - filters?: MyProjectsSearchFilters, + filters?: MyResourcesSearchFilters, pageNumber?: number, - pageSize?: number - ): Observable { - return this.getMyItems('registrations', filters, pageNumber, pageSize, 'registrations'); + pageSize?: number, + searchMode?: ResourceSearchMode + ): Observable { + return this.getResources('registrations/', filters, pageNumber, pageSize, 'registrations', searchMode); } getMyPreprints( - filters?: MyProjectsSearchFilters, + filters?: MyResourcesSearchFilters, pageNumber?: number, pageSize?: number - ): Observable { - return this.getMyItems('preprints', filters, pageNumber, pageSize, 'preprints'); + ): Observable { + return this.getResources('preprints/', filters, pageNumber, pageSize, 'preprints'); } getMyBookmarks( collectionId: string, resourceType: ResourceType, - filters?: MyProjectsSearchFilters, + filters?: MyResourcesSearchFilters, pageNumber?: number, pageSize?: number - ): Observable { + ): Observable { switch (resourceType) { case ResourceType.Project: - return this.getMyItems(`collections/${collectionId}/linked_nodes/`, filters, pageNumber, pageSize, 'nodes'); + return this.getResources(`collections/${collectionId}/linked_nodes/`, filters, pageNumber, pageSize, 'nodes'); case ResourceType.Registration: - return this.getMyItems( + return this.getResources( `collections/${collectionId}/linked_registrations/`, filters, pageNumber, @@ -139,7 +152,7 @@ export class MyProjectsService { 'registrations' ); case ResourceType.Preprint: - return this.getMyItems( + return this.getResources( `collections/${collectionId}/linked_preprints/`, filters, pageNumber, @@ -157,7 +170,7 @@ export class MyProjectsService { templateFrom: string, region: string, affiliations: string[] - ): Observable { + ): Observable { const payload: CreateProjectPayloadJsoApi = { data: { type: 'nodes', @@ -193,8 +206,8 @@ export class MyProjectsService { }; return this.jsonApiService - .post>(`${environment.apiUrl}/nodes/`, payload, params) - .pipe(map((response) => MyProjectsMapper.fromResponse(response.data))); + .post>(`${environment.apiUrl}/nodes/`, payload, params) + .pipe(map((response) => MyResourcesMapper.fromResponse(response.data))); } getProjectById(projectId: string): Observable { diff --git a/src/app/shared/services/node-links.service.ts b/src/app/shared/services/node-links.service.ts new file mode 100644 index 000000000..cf834b8d9 --- /dev/null +++ b/src/app/shared/services/node-links.service.ts @@ -0,0 +1,98 @@ +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiResponse } from '@core/models'; +import { JsonApiService } from '@osf/core/services'; +import { NodeLinksMapper } from '@shared/mappers'; +import { ComponentsMapper } from '@shared/mappers/components'; +import { ComponentGetResponseJsonApi, ComponentOverview } from '@shared/models'; +import { NodeLink, NodeLinkJsonApi } from '@shared/models/node-links'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class NodeLinksService { + jsonApiService = inject(JsonApiService); + + createNodeLink(currentProjectId: string, linkProjectId: string): Observable { + const payload = { + data: { + type: 'node_links', + relationships: { + nodes: { + data: { + type: 'nodes', + id: linkProjectId, + }, + }, + }, + }, + }; + + return this.jsonApiService + .post< + JsonApiResponse + >(`${environment.apiUrl}/nodes/${currentProjectId}/node_links/`, payload) + .pipe( + map((response) => { + return NodeLinksMapper.fromNodeLinkResponse(response.data); + }) + ); + } + + fetchAllNodeLinks(projectId: string): Observable { + const params: Record = { + 'fields[nodes]': 'relationships', + }; + + return this.jsonApiService + .get>(`${environment.apiUrl}/nodes/${projectId}/node_links/`, params) + .pipe( + map((response) => { + return response.data.map((item) => NodeLinksMapper.fromNodeLinkResponse(item)); + }) + ); + } + + deleteNodeLink(projectId: string, nodeLinkId: string): Observable { + return this.jsonApiService.delete(`${environment.apiUrl}/nodes/${projectId}/node_links/${nodeLinkId}/`); + } + + fetchLinkedProjects(projectId: string): Observable { + const params: Record = { + embed: 'bibliographic_contributors', + 'fields[users]': 'family_name,full_name,given_name,middle_name', + }; + + return this.jsonApiService + .get< + JsonApiResponse + >(`${environment.apiUrl}/nodes/${projectId}/linked_nodes`, params) + .pipe( + map((response) => { + return response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)); + }) + ); + } + + fetchLinkedRegistrations(projectId: string): Observable { + const params: Record = { + embed: 'bibliographic_contributors', + 'fields[users]': 'family_name,full_name,given_name,middle_name', + }; + + return this.jsonApiService + .get< + JsonApiResponse + >(`${environment.apiUrl}/nodes/${projectId}/linked_registrations`, params) + .pipe( + map((response) => { + return response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)); + }) + ); + } +} diff --git a/src/app/shared/services/projects.service.ts b/src/app/shared/services/projects.service.ts index ef9d8289d..716197674 100644 --- a/src/app/shared/services/projects.service.ts +++ b/src/app/shared/services/projects.service.ts @@ -26,6 +26,6 @@ export class ProjectsService { return this.jsonApiService .patch(`${environment.apiUrl}/nodes/${metadata.id}/`, payload) - .pipe(map((response) => ProjectsMapper.fromPatchProjectResponse(response))); + .pipe(map((response) => ProjectsMapper.fromProjectResponse(response))); } } diff --git a/src/app/shared/stores/index.ts b/src/app/shared/stores/index.ts index c1cd0cb36..4e519e0a1 100644 --- a/src/app/shared/stores/index.ts +++ b/src/app/shared/stores/index.ts @@ -4,6 +4,8 @@ export * from './contributors'; export * from './institutions'; export * from './institutions-search'; export * from './licenses'; +export * from './my-resources'; +export * from './node-links'; export * from './projects'; export * from './subjects'; export * from './view-only-links'; diff --git a/src/app/shared/stores/my-resources/index.ts b/src/app/shared/stores/my-resources/index.ts new file mode 100644 index 000000000..29c41c25e --- /dev/null +++ b/src/app/shared/stores/my-resources/index.ts @@ -0,0 +1,4 @@ +export * from './my-resources.actions'; +export * from './my-resources.model'; +export * from './my-resources.selectors'; +export * from './my-resources.state'; diff --git a/src/app/shared/stores/my-resources/my-resources.actions.ts b/src/app/shared/stores/my-resources/my-resources.actions.ts new file mode 100644 index 000000000..bd3e2947f --- /dev/null +++ b/src/app/shared/stores/my-resources/my-resources.actions.ts @@ -0,0 +1,65 @@ +import { ResourceSearchMode, ResourceType } from '@shared/enums'; + +import { MyResourcesSearchFilters } from 'src/app/shared/models/my-resources'; + +export class GetMyProjects { + static readonly type = '[My Resources] Get Projects'; + + constructor( + public pageNumber: number, + public pageSize: number, + public filters: MyResourcesSearchFilters, + public searchMode?: ResourceSearchMode, + public rootProjectId?: string + ) {} +} + +export class GetMyRegistrations { + static readonly type = '[My Resources] Get Registrations'; + + constructor( + public pageNumber: number, + public pageSize: number, + public filters: MyResourcesSearchFilters, + public searchMode?: ResourceSearchMode, + public rootRegistrationId?: string + ) {} +} + +export class GetMyPreprints { + static readonly type = '[My Resources] Get Preprints'; + + constructor( + public pageNumber: number, + public pageSize: number, + public filters: MyResourcesSearchFilters + ) {} +} + +export class GetMyBookmarks { + static readonly type = '[My Resources] Get Bookmarks'; + + constructor( + public bookmarksId: string, + public pageNumber: number, + public pageSize: number, + public filters: MyResourcesSearchFilters, + public resourceType: ResourceType + ) {} +} + +export class ClearMyResources { + static readonly type = '[My Resources] Clear My Resources'; +} + +export class CreateProject { + static readonly type = '[My Resources] Create Project'; + + constructor( + public title: string, + public description: string, + public templateFrom: string, + public region: string, + public affiliations: string[] + ) {} +} diff --git a/src/app/features/my-projects/store/my-projects.model.ts b/src/app/shared/stores/my-resources/my-resources.model.ts similarity index 53% rename from src/app/features/my-projects/store/my-projects.model.ts rename to src/app/shared/stores/my-resources/my-resources.model.ts index c3e4873e5..b3cc9f276 100644 --- a/src/app/features/my-projects/store/my-projects.model.ts +++ b/src/app/shared/stores/my-resources/my-resources.model.ts @@ -1,19 +1,19 @@ -import { AsyncStateModel } from '@osf/shared/models'; +import { AsyncStateModel } from '@shared/models'; -import { MyProjectsItem } from '../models'; +import { MyResourcesItem } from 'src/app/shared/models/my-resources'; -export interface MyProjectsStateModel { - projects: AsyncStateModel; - registrations: AsyncStateModel; - preprints: AsyncStateModel; - bookmarks: AsyncStateModel; +export interface MyResourcesStateModel { + projects: AsyncStateModel; + registrations: AsyncStateModel; + preprints: AsyncStateModel; + bookmarks: AsyncStateModel; totalProjects: number; totalRegistrations: number; totalPreprints: number; totalBookmarks: number; } -export const MY_PROJECT_STATE_DEFAULTS: MyProjectsStateModel = { +export const MY_RESOURCES_STATE_DEFAULTS: MyResourcesStateModel = { projects: { data: [], isLoading: false, diff --git a/src/app/shared/stores/my-resources/my-resources.selectors.ts b/src/app/shared/stores/my-resources/my-resources.selectors.ts new file mode 100644 index 000000000..c63790ed8 --- /dev/null +++ b/src/app/shared/stores/my-resources/my-resources.selectors.ts @@ -0,0 +1,68 @@ +import { Selector } from '@ngxs/store'; + +import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; + +import { MyResourcesStateModel } from './my-resources.model'; +import { MyResourcesState } from './my-resources.state'; + +export class MyResourcesSelectors { + @Selector([MyResourcesState]) + static getProjects(state: MyResourcesStateModel): MyResourcesItem[] { + return state.projects.data; + } + + @Selector([MyResourcesState]) + static getProjectsLoading(state: MyResourcesStateModel): boolean { + return state.projects.isLoading; + } + + @Selector([MyResourcesState]) + static isProjectSubmitting(state: MyResourcesStateModel): boolean { + return state.projects.isSubmitting || false; + } + + @Selector([MyResourcesState]) + static getRegistrations(state: MyResourcesStateModel): MyResourcesItem[] { + return state.registrations.data; + } + + @Selector([MyResourcesState]) + static getRegistrationsLoading(state: MyResourcesStateModel): boolean { + return state.registrations.isLoading; + } + + @Selector([MyResourcesState]) + static getPreprints(state: MyResourcesStateModel): MyResourcesItem[] { + return state.preprints.data; + } + + @Selector([MyResourcesState]) + static getBookmarks(state: MyResourcesStateModel): MyResourcesItem[] { + return state.bookmarks.data; + } + + @Selector([MyResourcesState]) + static getTotalProjects(state: MyResourcesStateModel): number { + return state.totalProjects; + } + + @Selector([MyResourcesState]) + static getTotalRegistrations(state: MyResourcesStateModel): number { + return state.totalRegistrations; + } + + @Selector([MyResourcesState]) + static getTotalPreprints(state: MyResourcesStateModel): number { + return state.totalPreprints; + } + + @Selector([MyResourcesState]) + static getTotalBookmarks(state: MyResourcesStateModel): number { + return state.totalBookmarks; + } + + @Selector([MyResourcesState]) + static getBookmarksLoading(state: MyResourcesStateModel): boolean { + return state.bookmarks.isLoading; + } +} diff --git a/src/app/features/my-projects/store/my-projects.state.ts b/src/app/shared/stores/my-resources/my-resources.state.ts similarity index 61% rename from src/app/features/my-projects/store/my-projects.state.ts rename to src/app/shared/stores/my-resources/my-resources.state.ts index 1e80ac762..bb746490c 100644 --- a/src/app/features/my-projects/store/my-projects.state.ts +++ b/src/app/shared/stores/my-resources/my-resources.state.ts @@ -4,31 +4,30 @@ import { catchError, forkJoin, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { handleSectionError } from '@osf/core/handlers'; +import { handleSectionError } from '@core/handlers'; import { ResourceType } from '@shared/enums'; - -import { MyProjectsService } from '../services'; +import { MyResourcesService } from '@shared/services'; import { - ClearMyProjects, + ClearMyResources, CreateProject, GetMyBookmarks, GetMyPreprints, GetMyProjects, GetMyRegistrations, -} from './my-projects.actions'; -import { MY_PROJECT_STATE_DEFAULTS, MyProjectsStateModel } from './my-projects.model'; +} from './my-resources.actions'; +import { MY_RESOURCES_STATE_DEFAULTS, MyResourcesStateModel } from './my-resources.model'; -@State({ - name: 'myProjects', - defaults: MY_PROJECT_STATE_DEFAULTS, +@State({ + name: 'myResources', + defaults: MY_RESOURCES_STATE_DEFAULTS, }) @Injectable() -export class MyProjectsState { - private readonly myProjectsService = inject(MyProjectsService); +export class MyResourcesState { + private readonly myResourcesService = inject(MyResourcesService); @Action(GetMyProjects) - getProjects(ctx: StateContext, action: GetMyProjects) { + getProjects(ctx: StateContext, action: GetMyProjects) { const state = ctx.getState(); ctx.patchState({ projects: { @@ -37,23 +36,25 @@ export class MyProjectsState { }, }); - return this.myProjectsService.getMyProjects(action.filters, action.pageNumber, action.pageSize).pipe( - tap((res) => { - ctx.patchState({ - projects: { - data: res.data, - isLoading: false, - error: null, - }, - totalProjects: res.links.meta.total, - }); - }), - catchError((error) => handleSectionError(ctx, 'projects', error)) - ); + return this.myResourcesService + .getMyProjects(action.filters, action.pageNumber, action.pageSize, action.searchMode, action.rootProjectId) + .pipe( + tap((res) => { + ctx.patchState({ + projects: { + data: res.data, + isLoading: false, + error: null, + }, + totalProjects: res.links.meta.total, + }); + }), + catchError((error) => handleSectionError(ctx, 'projects', error)) + ); } @Action(GetMyRegistrations) - getRegistrations(ctx: StateContext, action: GetMyRegistrations) { + getRegistrations(ctx: StateContext, action: GetMyRegistrations) { const state = ctx.getState(); ctx.patchState({ registrations: { @@ -62,23 +63,25 @@ export class MyProjectsState { }, }); - return this.myProjectsService.getMyRegistrations(action.filters, action.pageNumber, action.pageSize).pipe( - tap((res) => { - ctx.patchState({ - registrations: { - data: res.data, - isLoading: false, - error: null, - }, - totalRegistrations: res.links.meta.total, - }); - }), - catchError((error) => handleSectionError(ctx, 'registrations', error)) - ); + return this.myResourcesService + .getMyRegistrations(action.filters, action.pageNumber, action.pageSize, action.searchMode) + .pipe( + tap((res) => { + ctx.patchState({ + registrations: { + data: res.data, + isLoading: false, + error: null, + }, + totalRegistrations: res.links.meta.total, + }); + }), + catchError((error) => handleSectionError(ctx, 'registrations', error)) + ); } @Action(GetMyPreprints) - getPreprints(ctx: StateContext, action: GetMyPreprints) { + getPreprints(ctx: StateContext, action: GetMyPreprints) { const state = ctx.getState(); ctx.patchState({ preprints: { @@ -87,7 +90,7 @@ export class MyProjectsState { }, }); - return this.myProjectsService.getMyPreprints(action.filters, action.pageNumber, action.pageSize).pipe( + return this.myResourcesService.getMyPreprints(action.filters, action.pageNumber, action.pageSize).pipe( tap((res) => { ctx.patchState({ preprints: { @@ -103,7 +106,7 @@ export class MyProjectsState { } @Action(GetMyBookmarks) - getBookmarks(ctx: StateContext, action: GetMyBookmarks) { + getBookmarks(ctx: StateContext, action: GetMyBookmarks) { const state = ctx.getState(); ctx.patchState({ bookmarks: { @@ -114,7 +117,7 @@ export class MyProjectsState { }); if (action.resourceType !== ResourceType.Null) { - return this.myProjectsService + return this.myResourcesService .getMyBookmarks(action.bookmarksId, action.resourceType, action.filters, action.pageNumber, action.pageSize) .pipe( tap((res) => { @@ -131,21 +134,21 @@ export class MyProjectsState { ); } else { return forkJoin({ - projects: this.myProjectsService.getMyBookmarks( + projects: this.myResourcesService.getMyBookmarks( action.bookmarksId, ResourceType.Project, action.filters, action.pageNumber, action.pageSize ), - preprints: this.myProjectsService.getMyBookmarks( + preprints: this.myResourcesService.getMyBookmarks( action.bookmarksId, ResourceType.Preprint, action.filters, action.pageNumber, action.pageSize ), - registrations: this.myProjectsService.getMyBookmarks( + registrations: this.myResourcesService.getMyBookmarks( action.bookmarksId, ResourceType.Registration, action.filters, @@ -174,13 +177,13 @@ export class MyProjectsState { } } - @Action(ClearMyProjects) - clearMyProjects(ctx: StateContext) { - ctx.patchState(MY_PROJECT_STATE_DEFAULTS); + @Action(ClearMyResources) + clearMyResources(ctx: StateContext) { + ctx.patchState(MY_RESOURCES_STATE_DEFAULTS); } @Action(CreateProject) - createProject(ctx: StateContext, action: CreateProject) { + createProject(ctx: StateContext, action: CreateProject) { const state = ctx.getState(); ctx.patchState({ projects: { @@ -189,7 +192,7 @@ export class MyProjectsState { }, }); - return this.myProjectsService + return this.myResourcesService .createProject(action.title, action.description, action.templateFrom, action.region, action.affiliations) .pipe( tap((project) => { diff --git a/src/app/shared/stores/node-links/index.ts b/src/app/shared/stores/node-links/index.ts new file mode 100644 index 000000000..77789941f --- /dev/null +++ b/src/app/shared/stores/node-links/index.ts @@ -0,0 +1,4 @@ +export * from './node-links.actions'; +export * from './node-links.model'; +export * from './node-links.selectors'; +export * from './node-links.state'; diff --git a/src/app/shared/stores/node-links/node-links.actions.ts b/src/app/shared/stores/node-links/node-links.actions.ts new file mode 100644 index 000000000..52aa0e62f --- /dev/null +++ b/src/app/shared/stores/node-links/node-links.actions.ts @@ -0,0 +1,33 @@ +export class CreateNodeLink { + static readonly type = '[Node Links] Create Node Link'; + + constructor( + public currentProjectId: string, + public linkProjectId: string + ) {} +} + +export class GetAllNodeLinks { + static readonly type = '[Node Links] Get All Node Links'; + + constructor(public projectId: string) {} +} + +export class GetLinkedResources { + static readonly type = '[Node Links] Get Linked Resources'; + + constructor(public projectId: string) {} +} + +export class DeleteNodeLink { + static readonly type = '[Node Links] Delete Node Link'; + + constructor( + public projectId: string, + public nodeLinkId: string + ) {} +} + +export class ClearNodeLinks { + static readonly type = '[Node Links] Clear Node Links'; +} diff --git a/src/app/shared/stores/node-links/node-links.model.ts b/src/app/shared/stores/node-links/node-links.model.ts new file mode 100644 index 000000000..bea7e5870 --- /dev/null +++ b/src/app/shared/stores/node-links/node-links.model.ts @@ -0,0 +1,22 @@ +import { AsyncStateModel, ComponentOverview } from '@osf/shared/models'; +import { NodeLink } from '@shared/models/node-links'; + +export interface NodeLinksStateModel { + nodeLinks: AsyncStateModel; + linkedResources: AsyncStateModel; +} + +export const NODE_LINKS_DEFAULTS: NodeLinksStateModel = { + nodeLinks: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, + linkedResources: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, +}; diff --git a/src/app/shared/stores/node-links/node-links.selectors.ts b/src/app/shared/stores/node-links/node-links.selectors.ts new file mode 100644 index 000000000..731bc5155 --- /dev/null +++ b/src/app/shared/stores/node-links/node-links.selectors.ts @@ -0,0 +1,36 @@ +import { Selector } from '@ngxs/store'; + +import { NodeLinksStateModel } from './node-links.model'; +import { NodeLinksState } from './node-links.state'; + +export class NodeLinksSelectors { + @Selector([NodeLinksState]) + static getNodeLinks(state: NodeLinksStateModel) { + return state.nodeLinks.data; + } + + @Selector([NodeLinksState]) + static getNodeLinksLoading(state: NodeLinksStateModel) { + return state.nodeLinks.isLoading; + } + + @Selector([NodeLinksState]) + static getNodeLinksSubmitting(state: NodeLinksStateModel) { + return state.nodeLinks.isSubmitting || false; + } + + @Selector([NodeLinksState]) + static getLinkedResources(state: NodeLinksStateModel) { + return state.linkedResources.data; + } + + @Selector([NodeLinksState]) + static getLinkedResourcesLoading(state: NodeLinksStateModel) { + return state.linkedResources.isLoading; + } + + @Selector([NodeLinksState]) + static getLinkedResourcesSubmitting(state: NodeLinksStateModel) { + return state.linkedResources.isSubmitting; + } +} diff --git a/src/app/shared/stores/node-links/node-links.state.ts b/src/app/shared/stores/node-links/node-links.state.ts new file mode 100644 index 000000000..87fd15358 --- /dev/null +++ b/src/app/shared/stores/node-links/node-links.state.ts @@ -0,0 +1,146 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { catchError, forkJoin, tap, throwError } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { NodeLinksService } from '@shared/services/node-links.service'; + +import { + ClearNodeLinks, + CreateNodeLink, + DeleteNodeLink, + GetAllNodeLinks, + GetLinkedResources, +} from './node-links.actions'; +import { NODE_LINKS_DEFAULTS, NodeLinksStateModel } from './node-links.model'; + +@State({ + name: 'nodeLinks', + defaults: NODE_LINKS_DEFAULTS, +}) +@Injectable() +export class NodeLinksState { + nodeLinksService = inject(NodeLinksService); + + @Action(CreateNodeLink) + createNodeLink(ctx: StateContext, action: CreateNodeLink) { + const state = ctx.getState(); + ctx.patchState({ + nodeLinks: { + ...state.nodeLinks, + isSubmitting: true, + }, + }); + + return this.nodeLinksService.createNodeLink(action.currentProjectId, action.linkProjectId).pipe( + tap((nodeLink) => { + ctx.patchState({ + nodeLinks: { + data: [...state.nodeLinks.data, nodeLink], + isSubmitting: false, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'nodeLinks', error)) + ); + } + + @Action(GetAllNodeLinks) + getAllNodeLinks(ctx: StateContext, action: GetAllNodeLinks) { + const state = ctx.getState(); + ctx.patchState({ + nodeLinks: { + ...state.nodeLinks, + isLoading: true, + }, + }); + + return this.nodeLinksService.fetchAllNodeLinks(action.projectId).pipe( + tap((nodeLinks) => { + ctx.patchState({ + nodeLinks: { + data: nodeLinks, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'nodeLinks', error)) + ); + } + + @Action(GetLinkedResources) + getLinkedResources(ctx: StateContext, action: GetLinkedResources) { + const state = ctx.getState(); + ctx.patchState({ + linkedResources: { + ...state.linkedResources, + isLoading: true, + }, + }); + + return forkJoin({ + linkedProjects: this.nodeLinksService.fetchLinkedProjects(action.projectId), + linkedRegistrations: this.nodeLinksService.fetchLinkedRegistrations(action.projectId), + }).pipe( + tap(({ linkedProjects, linkedRegistrations }) => { + const combinedResources = [...linkedProjects, ...linkedRegistrations]; + ctx.patchState({ + linkedResources: { + data: combinedResources, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'linkedResources', error)) + ); + } + + @Action(DeleteNodeLink) + deleteNodeLink(ctx: StateContext, action: DeleteNodeLink) { + const state = ctx.getState(); + + ctx.patchState({ + nodeLinks: { + ...state.nodeLinks, + isSubmitting: true, + }, + }); + + return this.nodeLinksService.deleteNodeLink(action.projectId, action.nodeLinkId).pipe( + tap(() => { + const updatedNodeLinks = state.nodeLinks.data.filter((link) => link.id !== action.nodeLinkId); + ctx.patchState({ + nodeLinks: { + data: updatedNodeLinks, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + }) + ); + } + + @Action(ClearNodeLinks) + clearNodeLinks(ctx: StateContext) { + ctx.patchState(NODE_LINKS_DEFAULTS); + } + + private handleError(ctx: StateContext, section: keyof NodeLinksStateModel, error: Error) { + ctx.patchState({ + [section]: { + ...ctx.getState()[section], + isLoading: false, + isSubmitting: false, + error: error.message, + }, + }); + return throwError(() => error); + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 56a20cb47..28d29aa4d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -619,6 +619,9 @@ "deleteComponent": { "success": "Component has been deleted successfully" }, + "deleteNodeLink": { + "success": "Node link has been deleted successfully" + }, "fork": { "success": "Project has been forked successfully" }, @@ -630,6 +633,20 @@ "remove": "Project has been removed from bookmarks" } }, + "linkProject": { + "header": "Link to another OSF project or registration objects", + "searchAllObjects": "Search All Objects", + "searchMyObjects": "Search My Objects", + "searchObjectsPlaceholder": "Search objects", + "projects": "Projects", + "registrations": "Registrations", + "table": { + "title": "Title", + "created": "Created", + "modified": "Modified", + "contributors": "Contributors" + } + }, "addComponent": { "header": "Create New Component", "confirmButton": "Create", @@ -658,6 +675,10 @@ "message": "It will no longer be available to other contributors on the project.", "confirmation": "Type the following to continue:" }, + "deleteNodeLink": { + "header": "Delete Link", + "message": "Are you sure you want to delete this link? This will not remove the project or registration this link refers to." + }, "fork": { "headerProject": "Fork This Project", "headerRegistry": "Fork This Registry", diff --git a/src/assets/styles/overrides/button.scss b/src/assets/styles/overrides/button.scss index cbd3f8fa1..a5f230e55 100644 --- a/src/assets/styles/overrides/button.scss +++ b/src/assets/styles/overrides/button.scss @@ -50,3 +50,17 @@ --p-button-text-danger-color: var(--red-3); } } + +.link-project-button { + &:not(.active) { + opacity: 0.7; + + &:hover { + opacity: 1; + } + } + + .p-button { + width: 100%; + } +} diff --git a/src/assets/styles/overrides/table.scss b/src/assets/styles/overrides/table.scss index abb7e6a49..62f6417dc 100644 --- a/src/assets/styles/overrides/table.scss +++ b/src/assets/styles/overrides/table.scss @@ -105,6 +105,30 @@ p-table { } } +.link-project-table { + .p-datatable { + padding: 0 0.5rem 0.5rem 0.5rem; + } + + td { + background-color: var(--white); + height: auto; + border: none; + } + + thead { + tr { + outline: 1px solid var(--grey-2); + } + } +} + +.link-project-table.loading { + td { + border-bottom: 2px solid var(--white); + } +} + @media (max-width: var.$breakpoint-xl) { .addon-table, .my-projects-table {