diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html index 285d21a0e..0b19ebb0f 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html @@ -62,7 +62,7 @@

{{ 'common.labels.contributors' | translate }}: - +
@if (duplicate.description) { diff --git a/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.html b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.html index f4dab787e..52b284ecd 100644 --- a/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.html +++ b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.html @@ -30,7 +30,7 @@

{{ 'common.labels.contributors' | translate }}: - +
@if (duplicate.description) { diff --git a/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html b/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html index 55c97ab71..b0288a118 100644 --- a/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html +++ b/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html @@ -20,7 +20,7 @@ [showClear]="true" [loading]="fundersLoading()" [emptyFilterMessage]="filterMessage() | translate" - [emptyMessage]="filterMessage()" + [emptyMessage]="filterMessage() | translate" [autoOptionFocus]="false" (onChange)="onFunderSelected($event.value, $index)" (onFilter)="onFunderSearch($event.filter)" diff --git a/src/app/features/project/overview/components/component-card/component-card.component.html b/src/app/features/project/overview/components/component-card/component-card.component.html new file mode 100644 index 000000000..74e9de3f6 --- /dev/null +++ b/src/app/features/project/overview/components/component-card/component-card.component.html @@ -0,0 +1,50 @@ +
+
+

+ + + + {{ component().title }} + +

+ + @if (component().currentUserIsContributor) { +
+ + + + + + + {{ item.label | translate }} + + + +
+ } +
+ +
+

{{ 'common.labels.contributors' | translate }}:

+ + +
+ + @if (component().description) { + + } +
diff --git a/src/app/features/project/overview/components/component-card/component-card.component.scss b/src/app/features/project/overview/components/component-card/component-card.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/project/overview/components/component-card/component-card.component.spec.ts b/src/app/features/project/overview/components/component-card/component-card.component.spec.ts new file mode 100644 index 000000000..d0c1a3577 --- /dev/null +++ b/src/app/features/project/overview/components/component-card/component-card.component.spec.ts @@ -0,0 +1,77 @@ +import { MockComponents } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { IconComponent } from '@osf/shared/components/icon/icon.component'; + +import { ComponentCardComponent } from './component-card.component'; + +import { MOCK_NODE_WITH_ADMIN, MOCK_NODE_WITHOUT_ADMIN } from '@testing/mocks/node.mock'; + +describe('ComponentCardComponent', () => { + let component: ComponentCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ComponentCardComponent, ...MockComponents(IconComponent, ContributorsListComponent)], + }).compileComponents(); + + fixture = TestBed.createComponent(ComponentCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('component', MOCK_NODE_WITH_ADMIN); + fixture.componentRef.setInput('anonymous', false); + fixture.detectChanges(); + }); + + it('should emit navigate when handleNavigate is called', () => { + const emitSpy = jest.spyOn(component.navigate, 'emit'); + component.handleNavigate('test-id'); + expect(emitSpy).toHaveBeenCalledWith('test-id'); + }); + + it('should emit menuAction when handleMenuAction is called', () => { + const emitSpy = jest.spyOn(component.menuAction, 'emit'); + component.handleMenuAction('settings'); + expect(emitSpy).toHaveBeenCalledWith('settings'); + }); + + describe('componentActionItems', () => { + it('should return base items for any component', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITHOUT_ADMIN); + fixture.detectChanges(); + const items = component.componentActionItems(); + expect(items).toHaveLength(2); + expect(items[0].action).toBe('manageContributors'); + expect(items[1].action).toBe('settings'); + }); + + it('should include delete action when component has Admin permission', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITH_ADMIN); + fixture.detectChanges(); + const items = component.componentActionItems(); + expect(items).toHaveLength(3); + expect(items[0].action).toBe('manageContributors'); + expect(items[1].action).toBe('settings'); + expect(items[2].action).toBe('delete'); + }); + + it('should exclude delete action when component does not have Admin permission', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITHOUT_ADMIN); + fixture.detectChanges(); + const items = component.componentActionItems(); + expect(items).toHaveLength(2); + expect(items.every((item) => item.action !== 'delete')).toBe(true); + }); + + it('should exclude delete action when hideDeleteAction is true', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITH_ADMIN); + fixture.componentRef.setInput('hideDeleteAction', true); + fixture.detectChanges(); + const items = component.componentActionItems(); + expect(items).toHaveLength(2); + expect(items.every((item) => item.action !== 'delete')).toBe(true); + }); + }); +}); diff --git a/src/app/features/project/overview/components/component-card/component-card.component.ts b/src/app/features/project/overview/components/component-card/component-card.component.ts new file mode 100644 index 000000000..2bba7d860 --- /dev/null +++ b/src/app/features/project/overview/components/component-card/component-card.component.ts @@ -0,0 +1,60 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Menu } from 'primeng/menu'; + +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; + +@Component({ + selector: 'osf-component-card', + imports: [Button, Menu, TranslatePipe, TruncatedTextComponent, IconComponent, ContributorsListComponent], + templateUrl: './component-card.component.html', + styleUrl: './component-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComponentCardComponent { + component = input.required(); + anonymous = input.required(); + hideDeleteAction = input(false); + + navigate = output(); + menuAction = output(); + + readonly componentActionItems = computed(() => { + const component = this.component(); + + const baseItems = [ + { + label: 'project.overview.actions.manageContributors', + action: 'manageContributors', + }, + { + label: 'project.overview.actions.settings', + action: 'settings', + }, + ]; + + if (!this.hideDeleteAction() && component.currentUserPermissions.includes(UserPermissions.Admin)) { + baseItems.push({ + label: 'project.overview.actions.delete', + action: 'delete', + }); + } + + return baseItems; + }); + + handleNavigate(componentId: string): void { + this.navigate.emit(componentId); + } + + handleMenuAction(action: string): void { + this.menuAction.emit(action); + } +} diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html index 4d3afc46c..b02a69622 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html @@ -18,7 +18,7 @@

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

- + {{ linkedResource.title }}

@if (canEdit()) { @@ -39,7 +39,7 @@

{{ 'common.labels.contributors' | translate }}:

- +
@if (linkedResource.description) { { +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('LinkedProjectsComponent', () => { let component: LinkedResourcesComponent; let fixture: ComponentFixture; + let customDialogServiceMock: ReturnType; + + const mockLinkedResources = [ + { ...MOCK_NODE_WITH_ADMIN, id: 'resource-1', title: 'Linked Resource 1' }, + { ...MOCK_NODE_WITH_ADMIN, id: 'resource-2', title: 'Linked Resource 2' }, + { ...MOCK_NODE_WITH_ADMIN, id: 'resource-3', title: 'Linked Resource 3' }, + ]; beforeEach(async () => { + customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + await TestBed.configureTestingModule({ imports: [ LinkedResourcesComponent, - ...MockComponents(TruncatedTextComponent, IconComponent, ContributorsListComponent), + OSFTestingModule, + ...MockComponents(IconComponent, ContributorsListComponent), + ], + providers: [ + provideMockStore({ + signals: [ + { selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources }, + { selector: NodeLinksSelectors.getLinkedResourcesLoading, value: false }, + ], + }), + MockProvider(CustomDialogService, customDialogServiceMock), ], }).compileComponents(); fixture = TestBed.createComponent(LinkedResourcesComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('canEdit', true); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should open LinkResourceDialogComponent with correct config', () => { + component.openLinkProjectModal(); + + expect(customDialogServiceMock.open).toHaveBeenCalledWith(LinkResourceDialogComponent, { + header: 'project.overview.dialog.linkProject.header', + width: '850px', + }); + }); + + it('should find resource by id and open DeleteNodeLinkDialogComponent with correct config when resource exists', () => { + component.openDeleteResourceModal('resource-2'); + + expect(customDialogServiceMock.open).toHaveBeenCalledWith(DeleteNodeLinkDialogComponent, { + header: 'project.overview.dialog.deleteNodeLink.header', + width: '650px', + data: { currentLink: mockLinkedResources[1] }, + }); + }); + + it('should return early and not open dialog when resource is not found', () => { + customDialogServiceMock.open.mockClear(); + + component.openDeleteResourceModal('non-existent-id'); + + expect(customDialogServiceMock.open).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.html b/src/app/features/project/overview/components/overview-components/overview-components.component.html index dd86e3c15..16625cea3 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.html +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.html @@ -11,65 +11,34 @@

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

}

-
+
@if (isComponentsLoading()) { } @else { @if (components().length) { - @for (component of components(); track component.id) { -
-
-

- - - {{ component.title }} - -

- - @if (component.currentUserIsContributor) { - - } -
- -
-

{{ 'common.labels.contributors' | translate }}:

- - + @for (component of reorderedComponents(); track component.id) { +
+ -
- - @if (component.description) { - - } -
- } +
+ } +
@if (hasMoreComponents()) {
diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.scss b/src/app/features/project/overview/components/overview-components/overview-components.component.scss index e69de29bb..a7950ea0b 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.scss +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.scss @@ -0,0 +1,26 @@ +.components-drop-list { + max-height: 300px; + overflow-y: auto; +} + +.component-drag-item { + cursor: move; + + &.cdk-drag-preview { + background-color: var(--white); + border-radius: 0.6rem; + box-shadow: 0 2px 4px var(--grey-outline); + } + + &.cdk-drag-placeholder { + opacity: 0.3; + } + + &.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } +} + +.components-drop-list.cdk-drop-list-dragging .component-drag-item:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts index 6090ffc6f..36b82701a 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts @@ -1,31 +1,268 @@ -import { MockComponents } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; + +import { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; +import { AddComponentDialogComponent } from '../add-component-dialog/add-component-dialog.component'; import { OverviewComponentsComponent } from './overview-components.component'; -describe.skip('ProjectComponentsComponent', () => { +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('ProjectComponentsComponent', () => { let component: OverviewComponentsComponent; let fixture: ComponentFixture; + let store: jest.Mocked; + let routerMock: ReturnType; + let customDialogServiceMock: ReturnType; + let loaderServiceMock: LoaderServiceMock; + let toastService: jest.Mocked; + let createUrlTreeSpy: jest.Mock; + let serializeUrlSpy: jest.Mock; + let navigateSpy: jest.Mock; + + const mockComponents: NodeModel[] = [ + { ...MOCK_NODE_WITH_ADMIN, id: 'comp-1', title: 'Component 1' }, + { ...MOCK_NODE_WITH_ADMIN, id: 'comp-2', title: 'Component 2' }, + { ...MOCK_NODE_WITH_ADMIN, id: 'comp-3', title: 'Component 3' }, + ]; + + const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'project-123', rootParentId: 'root-123' }; beforeEach(async () => { + const mockUrlTree = {} as any; + createUrlTreeSpy = jest.fn().mockReturnValue(mockUrlTree); + serializeUrlSpy = jest.fn().mockReturnValue('/comp-1'); + navigateSpy = jest.fn().mockResolvedValue(true); + + routerMock = RouterMockBuilder.create().withCreateUrlTree(createUrlTreeSpy).build(); + routerMock.serializeUrl = serializeUrlSpy; + routerMock.navigate = navigateSpy; + + customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + loaderServiceMock = new LoaderServiceMock(); + toastService = { showSuccess: jest.fn() } as unknown as jest.Mocked; + await TestBed.configureTestingModule({ imports: [ OverviewComponentsComponent, - ...MockComponents(TruncatedTextComponent, IconComponent, ContributorsListComponent), + OSFTestingModule, + ...MockComponents(IconComponent, ContributorsListComponent), + ], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getComponents, value: mockComponents }, + { selector: ProjectOverviewSelectors.getComponentsLoading, value: false }, + { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, + { selector: ProjectOverviewSelectors.hasMoreComponents, value: true }, + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + ], + }), + MockProvider(Router, routerMock), + MockProvider(CustomDialogService, customDialogServiceMock), + { provide: LoaderService, useValue: loaderServiceMock }, + MockProvider(ToastService, toastService), ], }).compileComponents(); + store = TestBed.inject(Store) as jest.Mocked; + store.dispatch = jest.fn().mockReturnValue(of(true)); + fixture = TestBed.createComponent(OverviewComponentsComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('canEdit', true); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should sync reorderedComponents signal with components selector on init', () => { + expect(component.reorderedComponents()).toEqual(mockComponents); + expect(component.reorderedComponents().length).toBe(3); + }); + + it('should be true when canEdit is false and reorderedComponents.length <= 1', () => { + component.reorderedComponents.set([mockComponents[0]]); + fixture.componentRef.setInput('canEdit', false); + fixture.detectChanges(); + + expect(component.isDragDisabled()).toBe(true); + }); + + it('should be false when canEdit is true and isComponentsSubmitting is false and reorderedComponents.length > 1', () => { + fixture.componentRef.setInput('canEdit', true); + fixture.detectChanges(); + + expect(component.isDragDisabled()).toBe(false); + }); + + it('should navigate to contributors route when action is manageContributors', () => { + component.handleMenuAction('manageContributors', 'comp-1'); + + expect(navigateSpy).toHaveBeenCalledWith(['comp-1', 'contributors']); + }); + + it('should navigate to settings route when action is settings', () => { + component.handleMenuAction('settings', 'comp-1'); + + expect(navigateSpy).toHaveBeenCalledWith(['comp-1', 'settings']); + }); + + it('should call handleDeleteComponent when action is delete', () => { + store.dispatch = jest.fn().mockReturnValue(of(true)); + component.handleMenuAction('delete', 'comp-1'); + + expect(loaderServiceMock.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + constructor: GetResourceWithChildren, + }) + ); + }); + + it('should open AddComponentDialogComponent with correct config', () => { + component.handleAddComponent(); + + expect(customDialogServiceMock.open).toHaveBeenCalledWith(AddComponentDialogComponent, { + header: 'project.overview.dialog.addComponent.header', + width: '850px', + }); + }); + + it('should create URL tree with correct path and queryParamsHandling', () => { + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + component.handleComponentNavigate('comp-1'); + + expect(createUrlTreeSpy).toHaveBeenCalledWith(['/', 'comp-1'], { + queryParamsHandling: 'preserve', + }); + + windowOpenSpy.mockRestore(); + }); + + it('should serialize URL and open in same window', () => { + const mockUrlTree = {} as any; + createUrlTreeSpy.mockReturnValue(mockUrlTree); + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + component.handleComponentNavigate('comp-1'); + + expect(serializeUrlSpy).toHaveBeenCalledWith(mockUrlTree); + expect(windowOpenSpy).toHaveBeenCalledWith('/comp-1', '_self'); + + windowOpenSpy.mockRestore(); + }); + + it('should dispatch loadMoreComponents action with project id when project exists', () => { + component.loadMoreComponents(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(LoadMoreComponents)); + const dispatchedAction = (store.dispatch as jest.Mock).mock.calls[0][0]; + expect(dispatchedAction.projectId).toBe(mockProject.id); + }); + + it('should reorder components and dispatch reorderComponents action when project exists and canEdit is true', () => { + const event = { + previousIndex: 0, + currentIndex: 2, + container: { data: mockComponents }, + previousContainer: { data: mockComponents }, + } as any; + + store.dispatch = jest.fn().mockReturnValue(of(true)); + + component.onReorder(event); + + expect(component.reorderedComponents()[0].id).toBe('comp-2'); + expect(component.reorderedComponents()[2].id).toBe('comp-1'); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ReorderComponents)); + const dispatchedAction = (store.dispatch as jest.Mock).mock.calls[0][0]; + expect(dispatchedAction.projectId).toBe(mockProject.id); + expect(dispatchedAction.componentIds).toEqual(['comp-2', 'comp-3', 'comp-1']); + }); + + it('should show success toast after successful reorder', () => { + const event = { + previousIndex: 0, + currentIndex: 1, + container: { data: mockComponents }, + previousContainer: { data: mockComponents }, + } as any; + + store.dispatch = jest.fn().mockReturnValue(of(true)); + + component.onReorder(event); + + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.reorderComponents.success'); + }); + + it('should return early when canEdit is false', () => { + fixture.componentRef.setInput('canEdit', false); + fixture.detectChanges(); + + const event = { + previousIndex: 0, + currentIndex: 1, + container: { data: mockComponents }, + previousContainer: { data: mockComponents }, + } as any; + + store.dispatch.mockClear(); + + component.onReorder(event); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should show and hide loader on error', () => { + loaderServiceMock.hide.mockClear(); + + let subscribeError: ((error: any) => void) | undefined; + const mockObservable = { + subscribe: jest.fn((callbacks: any) => { + subscribeError = callbacks.error; + return { unsubscribe: jest.fn() }; + }), + }; + + store.dispatch = jest.fn().mockReturnValue(mockObservable as any); + + component.handleMenuAction('delete', 'comp-1'); + + expect(loaderServiceMock.show).toHaveBeenCalled(); + + if (subscribeError) { + subscribeError(new Error('Test error')); + } + + expect(loaderServiceMock.hide).toHaveBeenCalled(); + }); + + it('should use rootParentId if available, otherwise project id', () => { + store.dispatch = jest.fn().mockReturnValue(of(true)); + + component.handleMenuAction('delete', 'comp-1'); + + expect(store.dispatch).toHaveBeenCalled(); + const dispatchedAction = (store.dispatch as jest.Mock).mock.calls[0][0]; + expect(dispatchedAction.rootParentId).toBe(mockProject.rootParentId); }); }); diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.ts index f440c8476..0ab0bdfd4 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.ts +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.ts @@ -3,29 +3,27 @@ import { createDispatchMap, 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 { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'; import { Router } from '@angular/router'; -import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; -import { ComponentOverview } from '@shared/models/components/components.models'; -import { LoadMoreComponents, ProjectOverviewSelectors } from '../../store'; +import { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; import { AddComponentDialogComponent } from '../add-component-dialog/add-component-dialog.component'; +import { ComponentCardComponent } from '../component-card/component-card.component'; import { DeleteComponentDialogComponent } from '../delete-component-dialog/delete-component-dialog.component'; @Component({ selector: 'osf-project-components', - imports: [Button, Menu, Skeleton, TranslatePipe, TruncatedTextComponent, IconComponent, ContributorsListComponent], + imports: [Button, CdkDrag, CdkDropList, Skeleton, TranslatePipe, ComponentCardComponent], templateUrl: './overview-components.component.html', styleUrl: './overview-components.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -34,46 +32,35 @@ export class OverviewComponentsComponent { private router = inject(Router); private customDialogService = inject(CustomDialogService); private loaderService = inject(LoaderService); + private toastService = inject(ToastService); canEdit = input.required(); anonymous = input(false); components = select(ProjectOverviewSelectors.getComponents); isComponentsLoading = select(ProjectOverviewSelectors.getComponentsLoading); + isComponentsSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting); hasMoreComponents = select(ProjectOverviewSelectors.hasMoreComponents); project = select(ProjectOverviewSelectors.getProject); + reorderedComponents = signal([]); + actions = createDispatchMap({ getComponentsTree: GetResourceWithChildren, loadMoreComponents: LoadMoreComponents, + reorderComponents: ReorderComponents, }); - readonly UserPermissions = UserPermissions; - - readonly componentActionItems = (component: ComponentOverview) => { - const baseItems = [ - { - label: 'project.overview.actions.manageContributors', - action: 'manageContributors', - componentId: component.id, - }, - { - label: 'project.overview.actions.settings', - action: 'settings', - componentId: component.id, - }, - ]; - - if (component.currentUserPermissions.includes(UserPermissions.Admin)) { - baseItems.push({ - label: 'project.overview.actions.delete', - action: 'delete', - componentId: component.id, - }); - } + isDragDisabled = computed( + () => this.isComponentsSubmitting() || (!this.canEdit() && this.reorderedComponents().length <= 1) + ); - return baseItems; - }; + constructor() { + effect(() => { + const componentsData = this.components(); + this.reorderedComponents.set([...componentsData]); + }); + } handleMenuAction(action: string, componentId: string): void { switch (action) { @@ -96,7 +83,7 @@ export class OverviewComponentsComponent { }); } - navigateToComponent(componentId: string): void { + handleComponentNavigate(componentId: string): void { const url = this.router.serializeUrl( this.router.createUrlTree(['/', componentId], { queryParamsHandling: 'preserve' }) ); @@ -111,6 +98,22 @@ export class OverviewComponentsComponent { this.actions.loadMoreComponents(project.id); } + onReorder(event: CdkDragDrop): void { + const project = this.project(); + if (!project || !this.canEdit()) return; + + const components = [...this.reorderedComponents()]; + moveItemInArray(components, event.previousIndex, event.currentIndex); + this.reorderedComponents.set(components); + + const componentIds = components.map((component) => component.id); + this.actions.reorderComponents(project.id, componentIds).subscribe({ + next: () => { + this.toastService.showSuccess('project.overview.dialog.toast.reorderComponents.success'); + }, + }); + } + private handleDeleteComponent(componentId: string): void { const project = this.project(); if (!project) return; @@ -126,9 +129,7 @@ export class OverviewComponentsComponent { data: { componentId, resourceType: ResourceType.Project }, }); }, - error: () => { - this.loaderService.hide(); - }, + error: () => this.loaderService.hide(), }); } } diff --git a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.html b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.html index ac7c66a10..537a09491 100644 --- a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.html +++ b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.html @@ -7,54 +7,14 @@

{{ 'project.overview.parentProject' | translate }}

@if (isLoading()) { } @else { -
-
-

- - {{ project().title }} -

- - @if (project().currentUserIsContributor) { - - } -
- -
-

{{ 'common.labels.contributors' | translate }}:

- - -
- - @if (project().description) { - - } -
+ }
diff --git a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts index 849f3a136..a5ebbf43c 100644 --- a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts +++ b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts @@ -1,31 +1,95 @@ import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; -import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { ComponentCardComponent } from '../component-card/component-card.component'; import { OverviewParentProjectComponent } from './overview-parent-project.component'; -describe.skip('OverviewParentProjectComponent', () => { +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; + +describe('OverviewParentProjectComponent', () => { let component: OverviewParentProjectComponent; let fixture: ComponentFixture; + let createUrlTreeSpy: jest.Mock; + let serializeUrlSpy: jest.Mock; + let navigateSpy: jest.Mock; beforeEach(async () => { + const mockUrlTree = {} as any; + createUrlTreeSpy = jest.fn().mockReturnValue(mockUrlTree); + serializeUrlSpy = jest.fn().mockReturnValue('/test-id'); + navigateSpy = jest.fn().mockResolvedValue(true); + + const routerMock = RouterMockBuilder.create().withCreateUrlTree(createUrlTreeSpy).build(); + + routerMock.serializeUrl = serializeUrlSpy; + routerMock.navigate = navigateSpy; + await TestBed.configureTestingModule({ - imports: [ - OverviewParentProjectComponent, - ...MockComponents(TruncatedTextComponent, IconComponent, ContributorsListComponent), - ], + imports: [OverviewParentProjectComponent, ...MockComponents(ComponentCardComponent)], + providers: [{ provide: Router, useValue: routerMock }], }).compileComponents(); fixture = TestBed.createComponent(OverviewParentProjectComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('project', MOCK_NODE_WITH_ADMIN); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should create URL tree with correct path and queryParamsHandling', () => { + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + component.navigateToParent(); + + expect(createUrlTreeSpy).toHaveBeenCalledWith(['/', MOCK_NODE_WITH_ADMIN.id], { + queryParamsHandling: 'preserve', + }); + + windowOpenSpy.mockRestore(); + }); + + it('should serialize URL tree and open in same window', () => { + const mockUrlTree = {} as any; + createUrlTreeSpy.mockReturnValue(mockUrlTree); + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + component.navigateToParent(); + + expect(serializeUrlSpy).toHaveBeenCalledWith(mockUrlTree); + expect(windowOpenSpy).toHaveBeenCalledWith('/test-id', '_self'); + + windowOpenSpy.mockRestore(); + }); + + it('should navigate to contributors route when action is manageContributors', () => { + component.handleMenuAction('manageContributors'); + + expect(navigateSpy).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'contributors']); + }); + + it('should navigate to settings route when action is settings', () => { + component.handleMenuAction('settings'); + + expect(navigateSpy).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'settings']); + }); + + it('should return early if projectId is undefined', () => { + fixture.componentRef.setInput('project', { ...MOCK_NODE_WITH_ADMIN, id: undefined } as any); + fixture.detectChanges(); + + component.handleMenuAction('manageContributors'); + + expect(navigateSpy).not.toHaveBeenCalled(); + }); + + it('should not navigate for unknown actions', () => { + navigateSpy.mockClear(); + + component.handleMenuAction('unknownAction'); + + expect(navigateSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts index 3b0be9096..406ff4391 100644 --- a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts +++ b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts @@ -1,48 +1,28 @@ -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 { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { Router } from '@angular/router'; -import { UserSelectors } from '@core/store/user'; -import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; -import { ParentProjectModel } from '../../models/parent-overview.model'; +import { ComponentCardComponent } from '../component-card/component-card.component'; @Component({ selector: 'osf-overview-parent-project', - imports: [Skeleton, TranslatePipe, IconComponent, TruncatedTextComponent, Button, Menu, ContributorsListComponent], + imports: [Skeleton, TranslatePipe, ComponentCardComponent], templateUrl: './overview-parent-project.component.html', styleUrl: './overview-parent-project.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class OverviewParentProjectComponent { - project = input.required(); + project = input.required(); anonymous = input(false); isLoading = input(false); router = inject(Router); - currentUser = select(UserSelectors.getCurrentUser); - - menuItems = [ - { - label: 'project.overview.actions.manageContributors', - action: 'manageContributors', - }, - { - label: 'project.overview.actions.settings', - action: 'settings', - }, - ]; - navigateToParent(): void { const url = this.router.serializeUrl( this.router.createUrlTree(['/', this.project().id], { queryParamsHandling: 'preserve' }) 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 daa325757..c6fabdfe8 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,7 @@ -import { ContributorsMapper } from '@osf/shared/mappers/contributors'; import { BaseNodeMapper } from '@osf/shared/mappers/nodes'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; import { ProjectOverviewModel } from '../models'; -import { ParentProjectModel } from '../models/parent-overview.model'; export class ProjectOverviewMapper { static getProjectOverview(data: BaseNodeDataJsonApi): ProjectOverviewModel { @@ -26,13 +24,4 @@ export class ProjectOverviewMapper { licenseId: relationships.license?.data?.id, }; } - - static getParentOverview(data: BaseNodeDataJsonApi): ParentProjectModel { - const nodeAttributes = BaseNodeMapper.getNodeData(data); - - return { - ...nodeAttributes, - contributors: ContributorsMapper.getContributors(data.embeds?.bibliographic_contributors?.data), - }; - } } diff --git a/src/app/features/project/overview/models/parent-overview.model.ts b/src/app/features/project/overview/models/parent-overview.model.ts deleted file mode 100644 index e6a6f242a..000000000 --- a/src/app/features/project/overview/models/parent-overview.model.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ContributorModel } from '@osf/shared/models/contributors/contributor.model'; -import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; - -export interface ParentProjectModel extends NodeModel { - contributors: ContributorModel[]; -} 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 eaccc0b14..2c155b330 100644 --- a/src/app/features/project/overview/models/project-overview.models.ts +++ b/src/app/features/project/overview/models/project-overview.models.ts @@ -1,13 +1,13 @@ import { MetaJsonApi } from '@osf/shared/models/common/json-api.model'; import { ContributorModel } from '@osf/shared/models/contributors/contributor.model'; -import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model'; export interface ProjectOverviewWithMeta { project: ProjectOverviewModel; meta?: MetaJsonApi; } -export interface ProjectOverviewModel extends NodeModel { +export interface ProjectOverviewModel extends BaseNodeModel { forksCount: number; viewOnlyLinksCount: number; parentId?: string; 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 4eee20b32..f923aa0a1 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -6,26 +6,23 @@ import { inject, Injectable } from '@angular/core'; import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { ComponentsMapper } from '@osf/shared/mappers/components'; import { IdentifiersMapper } from '@osf/shared/mappers/identifiers.mapper'; import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; import { BaseNodeMapper } from '@osf/shared/mappers/nodes'; import { NodePreprintMapper } from '@osf/shared/mappers/nodes/node-preprint.mapper'; import { NodeStorageMapper } from '@osf/shared/mappers/nodes/node-storage.mapper'; -import { JsonApiResponse, ResponseJsonApi } from '@osf/shared/models/common/json-api.model'; -import { ComponentGetResponseJsonApi } from '@osf/shared/models/components/component-json-api.model'; -import { ComponentOverview } from '@osf/shared/models/components/components.models'; +import { JsonApiResponse } from '@osf/shared/models/common/json-api.model'; import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; import { LicenseResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; -import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { BaseNodeModel, NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; import { NodePreprintsResponseJsonApi } from '@osf/shared/models/nodes/node-preprint-json-api.model'; import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { NodeStorageResponseJsonApi } from '@osf/shared/models/nodes/node-storage-json-api.model'; -import { NodeResponseJsonApi } from '@osf/shared/models/nodes/nodes-json-api.model'; +import { NodeResponseJsonApi, NodesResponseJsonApi } from '@osf/shared/models/nodes/nodes-json-api.model'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; @@ -34,7 +31,6 @@ import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectOverviewMapper } from '../mappers'; import { PrivacyStatusModel, ProjectOverviewWithMeta } from '../models'; -import { ParentProjectModel } from '../models/parent-overview.model'; @Injectable({ providedIn: 'root', @@ -189,26 +185,21 @@ export class ProjectOverviewService { return this.jsonApiService.delete(`${this.apiUrl}/nodes/${componentId}/`); } - getComponents(projectId: string, page = 1, pageSize = 10): Observable> { + getComponents(projectId: string, page = 1, pageSize = 10): Observable> { const params: Record = { embed: 'bibliographic_contributors', 'fields[users]': 'family_name,full_name,given_name,middle_name', page: page, 'page[size]': pageSize, + sort: '_order', }; return this.jsonApiService - .get>(`${this.apiUrl}/nodes/${projectId}/children/`, params) - .pipe( - map((response) => ({ - data: response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)), - totalCount: response.meta?.total || 0, - pageSize: response.meta?.per_page || pageSize, - })) - ); + .get(`${this.apiUrl}/nodes/${projectId}/children/`, params) + .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(response))); } - getParentProject(projectId: string): Observable { + getParentProject(projectId: string): Observable { const params: Record = { 'embed[]': ['bibliographic_contributors'] }; const context = new HttpContext(); @@ -216,6 +207,29 @@ export class ProjectOverviewService { return this.jsonApiService .get(`${this.apiUrl}/nodes/${projectId}/`, params, context) - .pipe(map((response) => ProjectOverviewMapper.getParentOverview(response.data))); + .pipe(map((response) => BaseNodeMapper.getNodeWithEmbedContributors(response.data))); + } + + reorderComponents(projectId: string, componentIds: string[]): Observable { + const payload = { + data: componentIds.map((id, index) => ({ + type: 'nodes', + id, + attributes: { + _order: index, + }, + })), + }; + + const headers = { + 'Content-Type': 'application/vnd.api+json; ext=bulk', + }; + + return this.jsonApiService.patch( + `${this.apiUrl}/nodes/${projectId}/reorder_components/`, + payload, + undefined, + headers + ); } } 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 c5652189a..19c78cbef 100644 --- a/src/app/features/project/overview/store/project-overview.actions.ts +++ b/src/app/features/project/overview/store/project-overview.actions.ts @@ -118,3 +118,12 @@ export class GetParentProject { constructor(public projectId: string) {} } + +export class ReorderComponents { + static readonly type = '[Project Overview] Reorder Components'; + + constructor( + public projectId: string, + public componentIds: 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 0caeeb24b..8675ce272 100644 --- a/src/app/features/project/overview/store/project-overview.model.ts +++ b/src/app/features/project/overview/store/project-overview.model.ts @@ -1,5 +1,4 @@ -import { ComponentOverview } from '@osf/shared/models/components/components.models'; -import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { BaseNodeModel, NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; @@ -9,15 +8,14 @@ import { Institution } from '@shared/models/institutions/institutions.models'; import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectOverviewModel } from '../models'; -import { ParentProjectModel } from '../models/parent-overview.model'; export interface ProjectOverviewStateModel { project: AsyncStateModel; - components: AsyncStateWithTotalCount & { + components: AsyncStateWithTotalCount & { currentPage: number; }; duplicatedProject: BaseNodeModel | null; - parentProject: AsyncStateModel; + parentProject: AsyncStateModel; institutions: AsyncStateModel; identifiers: AsyncStateModel; license: AsyncStateModel; 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 dc1def2bd..08090fb32 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -25,6 +25,7 @@ import { GetProjectPreprints, GetProjectStorage, LoadMoreComponents, + ReorderComponents, SetProjectCustomCitation, UpdateProjectPublicStatus, } from './project-overview.actions'; @@ -433,4 +434,32 @@ export class ProjectOverviewState { catchError((error) => handleSectionError(ctx, 'parentProject', error)) ); } + + @Action(ReorderComponents) + reorderComponents(ctx: StateContext, action: ReorderComponents) { + const state = ctx.getState(); + ctx.patchState({ + components: { + ...state.components, + isSubmitting: true, + }, + }); + + return this.projectOverviewService.reorderComponents(action.projectId, action.componentIds).pipe( + tap(() => { + const reorderedComponents = action.componentIds + .map((id) => state.components.data.find((c) => c.id === id)) + .filter((c) => c !== undefined); + + ctx.patchState({ + components: { + ...state.components, + data: reorderedComponents, + isSubmitting: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'components', error)) + ); + } } diff --git a/src/app/shared/components/generic-filter/generic-filter.component.ts b/src/app/shared/components/generic-filter/generic-filter.component.ts index 482e2227b..c225bc1f8 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.ts +++ b/src/app/shared/components/generic-filter/generic-filter.component.ts @@ -70,9 +70,8 @@ export class GenericFilterComponent { this.updateStableArray(newOptions); return this.stableOptionsArray; }); - selectedOptionValues = computed(() => { - return this.selectedOptions().map((option) => option.value); - }); + + selectedOptionValues = computed(() => this.selectedOptions().map((option) => option.value)); constructor() { effect(() => { diff --git a/src/app/shared/components/resource-card/resource-card.component.html b/src/app/shared/components/resource-card/resource-card.component.html index 279ab27ff..3a73344dd 100644 --- a/src/app/shared/components/resource-card/resource-card.component.html +++ b/src/app/shared/components/resource-card/resource-card.component.html @@ -32,7 +32,12 @@

@if (affiliatedEntities().length > 0) {
@for (affiliatedEntity of affiliatedEntities().slice(0, limit); track $index) { - + {{ affiliatedEntity.name }}{{ $last ? '' : ', ' }} } diff --git a/src/app/shared/mappers/components/components.mapper.ts b/src/app/shared/mappers/components/components.mapper.ts deleted file mode 100644 index 14cb0f5a3..000000000 --- a/src/app/shared/mappers/components/components.mapper.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ComponentGetResponseJsonApi } from '@osf/shared/models/components/component-json-api.model'; -import { ComponentOverview } from '@osf/shared/models/components/components.models'; - -import { ContributorsMapper } from '../contributors'; - -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, - currentUserIsContributor: response.attributes.current_user_is_contributor, - contributors: ContributorsMapper.getContributors(response?.embeds?.bibliographic_contributors?.data), - currentUserPermissions: response.attributes?.current_user_permissions || [], - parentId: response.relationships.parent?.data?.id, - }; - } -} diff --git a/src/app/shared/mappers/nodes/base-node.mapper.ts b/src/app/shared/mappers/nodes/base-node.mapper.ts index 3fc17f94b..e839fcf0f 100644 --- a/src/app/shared/mappers/nodes/base-node.mapper.ts +++ b/src/app/shared/mappers/nodes/base-node.mapper.ts @@ -11,13 +11,9 @@ export class BaseNodeMapper { return data.map((item) => this.getNodeData(item)); } - static getNodesWithEmbedsData(data: BaseNodeDataJsonApi[]): NodeModel[] { - return data.map((item) => this.getNodeWithEmbedsData(item)); - } - static getNodesWithEmbedsAndTotalData(response: ResponseJsonApi): PaginatedData { return { - data: BaseNodeMapper.getNodesWithEmbedsData(response.data), + data: BaseNodeMapper.getNodesWithEmbedContributors(response.data), totalCount: response.meta.total, pageSize: response.meta.per_page, }; @@ -62,8 +58,13 @@ export class BaseNodeMapper { }; } - static getNodeWithEmbedsData(data: BaseNodeDataJsonApi): NodeModel { + static getNodesWithEmbedContributors(data: BaseNodeDataJsonApi[]): NodeModel[] { + return data.map((item) => this.getNodeWithEmbedContributors(item)); + } + + static getNodeWithEmbedContributors(data: BaseNodeDataJsonApi): NodeModel { const baseNode = BaseNodeMapper.getNodeData(data); + return { ...baseNode, bibliographicContributors: ContributorsMapper.getContributors(data.embeds?.bibliographic_contributors?.data), diff --git a/src/app/shared/models/components/component-json-api.model.ts b/src/app/shared/models/components/component-json-api.model.ts deleted file mode 100644 index 6930d78e8..000000000 --- a/src/app/shared/models/components/component-json-api.model.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ContributorDataJsonApi } from '../contributors/contributor-response-json-api.model'; -import { BaseNodeDataJsonApi } from '../nodes/base-node-data-json-api.model'; - -export interface ComponentGetResponseJsonApi extends BaseNodeDataJsonApi { - embeds: { - bibliographic_contributors: { - data: ContributorDataJsonApi[]; - }; - }; -} diff --git a/src/app/shared/models/components/components.models.ts b/src/app/shared/models/components/components.models.ts deleted file mode 100644 index 517a7098c..000000000 --- a/src/app/shared/models/components/components.models.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; - -import { ContributorModel } from '../contributors/contributor.model'; - -export interface ComponentOverview { - id: string; - type: string; - title: string; - description: string; - public: boolean; - contributors: ContributorModel[]; - currentUserIsContributor: boolean; - currentUserPermissions: UserPermissions[]; - parentId?: string; -} diff --git a/src/app/shared/models/nodes/base-node.model.ts b/src/app/shared/models/nodes/base-node.model.ts index eabe83d65..569eaa418 100644 --- a/src/app/shared/models/nodes/base-node.model.ts +++ b/src/app/shared/models/nodes/base-node.model.ts @@ -27,5 +27,5 @@ export interface BaseNodeModel { } export interface NodeModel extends BaseNodeModel { - bibliographicContributors?: ContributorModel[]; + bibliographicContributors: ContributorModel[]; } diff --git a/src/app/shared/services/duplicates.service.ts b/src/app/shared/services/duplicates.service.ts index b4c779424..5d25f3166 100644 --- a/src/app/shared/services/duplicates.service.ts +++ b/src/app/shared/services/duplicates.service.ts @@ -6,9 +6,8 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { BaseNodeMapper } from '../mappers/nodes'; -import { ResponseJsonApi } from '../models/common/json-api.model'; import { NodeModel } from '../models/nodes/base-node.model'; -import { BaseNodeDataJsonApi } from '../models/nodes/base-node-data-json-api.model'; +import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { PaginatedData } from '../models/paginated-data.model'; import { JsonApiService } from './json-api.service'; @@ -44,7 +43,7 @@ export class DuplicatesService { } return this.jsonApiService - .get>(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params) + .get(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params) .pipe(map((res) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(res))); } } diff --git a/src/app/shared/services/json-api.service.ts b/src/app/shared/services/json-api.service.ts index cb5735df8..28fe82f87 100644 --- a/src/app/shared/services/json-api.service.ts +++ b/src/app/shared/services/json-api.service.ts @@ -48,7 +48,7 @@ export class JsonApiService { ): Observable { return this.http .patch>(url, body, { params: this.buildHttpParams(params), headers, context }) - .pipe(map((response) => response.data)); + .pipe(map((response) => response?.data)); } put(url: string, body: unknown, params?: Record): Observable { diff --git a/src/app/shared/services/linked-projects.service.ts b/src/app/shared/services/linked-projects.service.ts index 0c1ae59c1..006671eed 100644 --- a/src/app/shared/services/linked-projects.service.ts +++ b/src/app/shared/services/linked-projects.service.ts @@ -6,9 +6,8 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { BaseNodeMapper } from '../mappers/nodes'; -import { ResponseJsonApi } from '../models/common/json-api.model'; import { NodeModel } from '../models/nodes/base-node.model'; -import { BaseNodeDataJsonApi } from '../models/nodes/base-node-data-json-api.model'; +import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { PaginatedData } from '../models/paginated-data.model'; import { JsonApiService } from './json-api.service'; @@ -44,9 +43,7 @@ export class LinkedProjectsService { } return this.jsonApiService - .get< - ResponseJsonApi - >(`${this.apiUrl}/${resourceType}/${resourceId}/linked_by_nodes/`, params) + .get(`${this.apiUrl}/${resourceType}/${resourceId}/linked_by_nodes/`, params) .pipe(map((res) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(res))); } } diff --git a/src/app/shared/services/node-links.service.ts b/src/app/shared/services/node-links.service.ts index e2a9c7056..3ced22bb2 100644 --- a/src/app/shared/services/node-links.service.ts +++ b/src/app/shared/services/node-links.service.ts @@ -5,12 +5,12 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { ComponentsMapper } from '../mappers/components'; +import { BaseNodeMapper } from '../mappers/nodes'; import { JsonApiResponse } from '../models/common/json-api.model'; -import { ComponentGetResponseJsonApi } from '../models/components/component-json-api.model'; -import { ComponentOverview } from '../models/components/components.models'; import { MyResourcesItem } from '../models/my-resources/my-resources.models'; import { NodeLinkJsonApi } from '../models/node-links/node-link-json-api.model'; +import { NodeModel } from '../models/nodes/base-node.model'; +import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { JsonApiService } from './json-api.service'; @@ -44,7 +44,7 @@ export class NodeLinksService { ); } - deleteNodeLink(projectId: string, resource: ComponentOverview): Observable { + deleteNodeLink(projectId: string, resource: NodeModel): Observable { const payload = { data: [ { @@ -60,29 +60,25 @@ export class NodeLinksService { ); } - fetchLinkedProjects(projectId: string): Observable { + 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 - >(`${this.apiUrl}/nodes/${projectId}/linked_nodes/`, params) - .pipe(map((response) => response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)))); + .get(`${this.apiUrl}/nodes/${projectId}/linked_nodes/`, params) + .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedContributors(response.data))); } - fetchLinkedRegistrations(projectId: string): Observable { + 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 - >(`${this.apiUrl}/nodes/${projectId}/linked_registrations/`, params) - .pipe(map((response) => response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)))); + .get(`${this.apiUrl}/nodes/${projectId}/linked_registrations/`, params) + .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedContributors(response.data))); } } diff --git a/src/app/shared/services/resource.service.ts b/src/app/shared/services/resource.service.ts index e0bec6a80..3b1d51fb2 100644 --- a/src/app/shared/services/resource.service.ts +++ b/src/app/shared/services/resource.service.ts @@ -6,12 +6,13 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { CurrentResourceType, ResourceType } from '../enums/resource-type.enum'; import { BaseNodeMapper } from '../mappers/nodes'; -import { ResponseDataJsonApi, ResponseJsonApi } from '../models/common/json-api.model'; +import { ResponseDataJsonApi } from '../models/common/json-api.model'; import { CurrentResource } from '../models/current-resource.model'; import { GuidedResponseJsonApi } from '../models/guid-response-json-api.model'; import { BaseNodeModel } from '../models/nodes/base-node.model'; import { BaseNodeDataJsonApi } from '../models/nodes/base-node-data-json-api.model'; import { NodeShortInfoModel } from '../models/nodes/node-with-children.model'; +import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { JsonApiService } from './json-api.service'; import { LoaderService } from './loader.service'; @@ -80,9 +81,7 @@ export class ResourceGuidService { const resourcePath = this.urlMap.get(resourceType); return this.jsonApiService - .get< - ResponseJsonApi - >(`${this.apiUrl}/${resourcePath}/?filter[root]=${rootParentId}&page[size]=100`) + .get(`${this.apiUrl}/${resourcePath}/?filter[root]=${rootParentId}&page[size]=100`) .pipe(map((response) => BaseNodeMapper.getNodesWithChildren(response.data, resourceId))); } } diff --git a/src/app/shared/stores/node-links/node-links.actions.ts b/src/app/shared/stores/node-links/node-links.actions.ts index 1c4fdc53a..ed3d10145 100644 --- a/src/app/shared/stores/node-links/node-links.actions.ts +++ b/src/app/shared/stores/node-links/node-links.actions.ts @@ -1,5 +1,5 @@ -import { ComponentOverview } from '@osf/shared/models/components/components.models'; import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; export class CreateNodeLink { static readonly type = '[Node Links] Create Node Link'; @@ -21,7 +21,7 @@ export class DeleteNodeLink { constructor( public projectId: string, - public linkedResource: ComponentOverview + public linkedResource: NodeModel ) {} } diff --git a/src/app/shared/stores/node-links/node-links.model.ts b/src/app/shared/stores/node-links/node-links.model.ts index eec2c3bc7..5e892a858 100644 --- a/src/app/shared/stores/node-links/node-links.model.ts +++ b/src/app/shared/stores/node-links/node-links.model.ts @@ -1,8 +1,8 @@ -import { ComponentOverview } from '@osf/shared/models/components/components.models'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; export interface NodeLinksStateModel { - linkedResources: AsyncStateModel; + linkedResources: AsyncStateModel; } export const NODE_LINKS_DEFAULTS: NodeLinksStateModel = { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 9bd06483a..740df40fa 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -807,6 +807,9 @@ "bookmark": { "add": "Successfully added to bookmarks", "remove": "Successfully removed from bookmarks" + }, + "reorderComponents": { + "success": "Components have been reordered successfully." } }, "linkProject": { diff --git a/src/testing/mocks/node.mock.ts b/src/testing/mocks/node.mock.ts new file mode 100644 index 000000000..fbcaff986 --- /dev/null +++ b/src/testing/mocks/node.mock.ts @@ -0,0 +1,52 @@ +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; + +export const MOCK_NODE_WITH_ADMIN: NodeModel = { + id: 'test-id-1', + type: 'nodes', + title: 'Test Component', + description: 'Test Description', + category: 'project', + dateCreated: '2024-01-01T00:00:00.000Z', + dateModified: '2024-01-02T00:00:00.000Z', + isRegistration: false, + isPreprint: false, + isFork: false, + isCollection: false, + isPublic: true, + tags: [], + accessRequestsEnabled: true, + nodeLicense: { + copyrightHolders: null, + year: null, + }, + currentUserPermissions: [UserPermissions.Admin], + currentUserIsContributor: true, + wikiEnabled: true, + bibliographicContributors: [], +}; + +export const MOCK_NODE_WITHOUT_ADMIN: NodeModel = { + id: 'test-id-2', + type: 'nodes', + title: 'Test Component 2', + description: 'Test Description 2', + category: 'project', + dateCreated: '2024-01-01T00:00:00.000Z', + dateModified: '2024-01-02T00:00:00.000Z', + isRegistration: false, + isPreprint: false, + isFork: false, + isCollection: false, + isPublic: false, + tags: [], + accessRequestsEnabled: true, + nodeLicense: { + copyrightHolders: null, + year: null, + }, + currentUserPermissions: [UserPermissions.Read, UserPermissions.Write], + currentUserIsContributor: true, + wikiEnabled: true, + bibliographicContributors: [], +};