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 {
-
-
-
-
- @if (project().currentUserIsContributor) {
-
- }
-
-
-
-
{{ 'common.labels.contributors' | translate }}:
-
-
-
-
- @if (project().description) {
-
- }
-
+
}