From 72c169e432e16d6842dfeca0bf79d95655064e34 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 13 Oct 2026 16:17:23 +0300 Subject: [PATCH 1/5] fix(project): updated delete project/component logic --- .../duplicate-card.component.ts | 13 ++- .../view-duplicates.component.ts | 68 +++++------- .../component-card.component.ts | 11 +- .../delete-component-dialog.component.html | 23 ++-- .../delete-component-dialog.component.spec.ts | 100 ++---------------- .../delete-component-dialog.component.ts | 98 +++-------------- .../overview-components.component.ts | 78 ++++++-------- .../overview-parent-project.component.ts | 9 +- .../delete-project-dialog.component.html | 21 ++-- .../delete-project-dialog.component.spec.ts | 82 ++++++++++---- .../delete-project-dialog.component.ts | 58 ++-------- .../models/delete-resource-options.model.ts | 18 ++++ .../services/delete-resource.service.ts | 90 ++++++++++++++++ .../settings/services/settings.service.ts | 13 +-- .../project/settings/settings.component.ts | 38 +++---- .../enums/node-menu-action.enum.ts} | 2 +- .../current-resource.selectors.ts | 7 ++ 17 files changed, 326 insertions(+), 403 deletions(-) create mode 100644 src/app/features/project/settings/models/delete-resource-options.model.ts create mode 100644 src/app/features/project/settings/services/delete-resource.service.ts rename src/app/{features/analytics/enums/duplicate-menu-action.enum.ts => shared/enums/node-menu-action.enum.ts} (73%) diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts index d5d854530..c1e3c5aae 100644 --- a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts @@ -10,11 +10,10 @@ import { RouterLink } 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 { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; -import { DuplicateMenuAction } from '../../enums/duplicate-menu-action.enum'; - @Component({ selector: 'osf-duplicate-card', imports: [ @@ -34,7 +33,7 @@ import { DuplicateMenuAction } from '../../enums/duplicate-menu-action.enum'; export class DuplicateCardComponent { readonly duplicate = input.required(); - readonly menuAction = output(); + readonly menuAction = output(); readonly dateFormat = 'MMM d, y, h:mm a'; @@ -46,19 +45,19 @@ export class DuplicateCardComponent { readonly menuItems = [ { label: 'project.overview.actions.manageContributors', - action: DuplicateMenuAction.ManageContributors, + action: NodeMenuAction.ManageContributors, }, { label: 'project.overview.actions.settings', - action: DuplicateMenuAction.Settings, + action: NodeMenuAction.Settings, }, { label: 'common.labels.delete', - action: DuplicateMenuAction.Delete, + action: NodeMenuAction.Delete, }, ]; - handleMenuAction(action: DuplicateMenuAction): void { + handleMenuAction(action: NodeMenuAction): void { this.menuAction.emit(action); } } diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts index dd6c79b48..9705378ad 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { PaginatorState } from 'primeng/paginator'; -import { filter, map, of } from 'rxjs'; +import { map, of } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; import { @@ -18,16 +18,17 @@ import { Signal, signal, } from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components/delete-component-dialog/delete-component-dialog.component'; import { ForkProject } from '@osf/features/project/overview/store'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; import { ForkRegistry } from '@osf/features/registry/store/registry'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; @@ -35,7 +36,6 @@ import { LoaderService } from '@osf/shared/services/loader.service'; import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@osf/shared/stores/duplicates'; -import { DuplicateMenuAction } from '../../enums/duplicate-menu-action.enum'; import { DuplicateCardComponent } from '../duplicate-card/duplicate-card.component'; @Component({ @@ -59,6 +59,7 @@ export class ViewDuplicatesComponent { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly deleteResourceService = inject(DeleteResourceService); readonly duplicates = select(DuplicatesSelectors.getDuplicates); readonly isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); @@ -75,9 +76,9 @@ export class ViewDuplicatesComponent { }); readonly pageSize = 10; - - currentPage = signal(1); + readonly currentPage = signal(1); readonly firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); + readonly duplicateButtonLabel = computed(() => this.resourceType() === ResourceType.Registration ? 'project.overview.actions.duplicateRegistration' @@ -109,15 +110,23 @@ export class ViewDuplicatesComponent { this.setupCleanup(); } - handleMenuAction(action: DuplicateMenuAction, resourceId: string): void { + setupCleanup(): void { + this.destroyRef.onDestroy(() => { + if (this.isBrowser) { + this.actions.clearDuplicates(); + } + }); + } + + handleMenuAction(action: NodeMenuAction, resourceId: string): void { switch (action) { - case DuplicateMenuAction.ManageContributors: + case NodeMenuAction.ManageContributors: this.router.navigate([resourceId, 'contributors']); break; - case DuplicateMenuAction.Settings: + case NodeMenuAction.Settings: this.router.navigate([resourceId, 'settings']); break; - case DuplicateMenuAction.Delete: + case NodeMenuAction.Delete: this.handleDeleteFork(resourceId); break; } @@ -146,40 +155,13 @@ export class ViewDuplicatesComponent { } } - setupCleanup(): void { - this.destroyRef.onDestroy(() => { - if (this.isBrowser) { - this.actions.clearDuplicates(); - } - }); - } - private handleDeleteFork(id: string): void { - this.loaderService.show(); - - this.actions.getComponentsTree(id, id, ResourceType.Project).subscribe({ - next: () => { - this.loaderService.hide(); - this.customDialogService - .open(DeleteComponentDialogComponent, { - header: 'project.overview.dialog.deleteComponent.header', - width: '650px', - data: { isForksContext: true }, - }) - .onClose.pipe( - filter((result) => !!result?.success), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(() => { - const resourceId = this.resourceId(); - const apiResourceType = this.apiResourceType(); - if (resourceId && apiResourceType) { - this.actions.getDuplicates(resourceId, apiResourceType, this.currentPage(), this.pageSize); - } - }); - }, - error: () => { - this.loaderService.hide(); + this.deleteResourceService.deleteProject({ + rootParentId: id, + resourceId: id, + destroyRef: this.destroyRef, + onSuccess: () => { + this.actions.getDuplicates(this.resourceId(), this.apiResourceType(), this.currentPage(), this.pageSize); }, }); } 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 index a88609484..1cfdfa12d 100644 --- 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 @@ -8,6 +8,7 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an 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 { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; @@ -24,7 +25,7 @@ export class ComponentCardComponent { hideDeleteAction = input(false); navigate = output(); - menuAction = output(); + menuAction = output(); readonly componentActionItems = computed(() => { const component = this.component(); @@ -32,18 +33,18 @@ export class ComponentCardComponent { const baseItems = [ { label: 'project.overview.actions.manageContributors', - action: 'manageContributors', + action: NodeMenuAction.ManageContributors, }, { label: 'project.overview.actions.settings', - action: 'settings', + action: NodeMenuAction.Settings, }, ]; if (!this.hideDeleteAction() && component.currentUserPermissions.includes(UserPermissions.Admin)) { baseItems.push({ label: 'common.labels.delete', - action: 'delete', + action: NodeMenuAction.Delete, }); } @@ -54,7 +55,7 @@ export class ComponentCardComponent { this.navigate.emit(componentId); } - handleMenuAction(action: string): void { + handleMenuAction(action: NodeMenuAction): void { this.menuAction.emit(action); } } diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html index 28ab10be0..b982147a5 100644 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html +++ b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html @@ -1,15 +1,12 @@ @if (hasAdminAccessForAllComponents()) { @if (hasSubComponents()) {

{{ 'project.overview.dialog.deleteComponent.listMessage' | translate }}

- @if (isLoading()) { - - } @else { -
    - @for (component of components(); track component.id) { -
  • {{ component.title }}
  • - } -
- } + +
    + @for (component of components(); track component.id) { +
  • {{ component.title }}
  • + } +

{{ 'project.overview.dialog.deleteComponent.warningMessage' | translate }}

} @else { @@ -18,10 +15,10 @@

{{ 'project.overview.dialog.deleteComponent.confirmation' | translate }} - {{ selectedScientist() }} + {{ selectedScientist }}

- + } @else {

} @@ -32,14 +29,12 @@ [label]="'common.buttons.cancel' | translate" severity="info" (onClick)="dialogRef.close()" - [disabled]="isSubmitting()" /> diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts index aabe577e7..a53b8a18d 100644 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts @@ -1,22 +1,11 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { Mock } from 'vitest'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DeleteProject, SettingsSelectors } from '@osf/features/project/settings/store'; -import { RegistrySelectors } from '@osf/features/registry/store/registry'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; -import { ToastService } from '@osf/shared/services/toast.service'; import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; -import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; import { @@ -25,29 +14,13 @@ import { provideMockStore, SignalOverride, } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; - -import { ProjectOverviewModel } from '../../models'; -import { GetComponents, ProjectOverviewSelectors } from '../../store'; import { DeleteComponentDialogComponent } from './delete-component-dialog.component'; -interface SetupOverrides extends BaseSetupOverrides { - resourceType?: ResourceType; - isForksContext?: boolean; -} - describe('DeleteComponentDialogComponent', () => { let component: DeleteComponentDialogComponent; let fixture: ComponentFixture; - let store: Store; let dialogRef: DynamicDialogRef; - let toastService: ToastServiceMockType; - - const mockProject: ProjectOverviewModel = { - ...MOCK_PROJECT_OVERVIEW, - id: 'project-1', - }; const adminComponents: NodeShortInfoModel[] = [ { @@ -65,34 +38,18 @@ describe('DeleteComponentDialogComponent', () => { ]; const defaultSignals: SignalOverride[] = [ - { selector: ProjectOverviewSelectors.getProject, value: mockProject }, - { selector: RegistrySelectors.getRegistry, value: null }, - { selector: SettingsSelectors.isSettingsSubmitting, value: false }, - { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, { selector: CurrentResourceSelectors.getResourceWithChildren, value: adminComponents }, + { selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: true }, ]; - function setup(overrides: SetupOverrides = {}) { + function setup(overrides: BaseSetupOverrides = {}) { const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); - toastService = ToastServiceMock.simple(); TestBed.configureTestingModule({ imports: [DeleteComponentDialogComponent], - providers: [ - provideOSFCore(), - provideDynamicDialogRefMock(), - MockProvider(DynamicDialogConfig, { - data: { - resourceType: overrides.resourceType ?? ResourceType.Project, - isForksContext: overrides.isForksContext ?? false, - }, - }), - MockProvider(ToastService, toastService), - provideMockStore({ signals }), - ], + providers: [provideOSFCore(), provideDynamicDialogRefMock(), provideMockStore({ signals })], }); - store = TestBed.inject(Store); dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(DeleteComponentDialogComponent); component = fixture.componentInstance; @@ -105,15 +62,9 @@ describe('DeleteComponentDialogComponent', () => { expect(component).toBeTruthy(); }); - it('should compute current resource from project when resource type is project', () => { - setup({ resourceType: ResourceType.Project }); - - expect(component.currentResource()?.id).toBe('project-1'); - }); - - it('should return false for hasAdminAccessForAllComponents when components are empty', () => { + it('should return false for hasAdminAccessForAllComponents when access is denied', () => { setup({ - selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }], + selectorOverrides: [{ selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: false }], }); expect(component.hasAdminAccessForAllComponents()).toBe(false); @@ -139,48 +90,19 @@ describe('DeleteComponentDialogComponent', () => { expect(component.hasSubComponents()).toBe(false); }); - it('should update userInput on input change and validate exact match', () => { - vi.spyOn(Math, 'random').mockReturnValue(0); + it('should return true for isInputValid when confirmation matches selected scientist', () => { setup(); - component.onInputChange(component.selectedScientist()); + component.userInput.set(component.selectedScientist); - expect(component.userInput()).toBe(component.selectedScientist()); expect(component.isInputValid()).toBe(true); - vi.restoreAllMocks(); - }); - - it('should not dispatch delete action when there are no components', () => { - setup({ - selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }], - }); - (store.dispatch as Mock).mockClear(); - - component.handleDeleteComponent(); - - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(DeleteProject)); }); - it('should delete components, refresh list, close dialog and show success when not forks context', () => { - setup({ isForksContext: false }); - (store.dispatch as Mock).mockClear(); - - component.handleDeleteComponent(); - - expect(store.dispatch).toHaveBeenCalledWith(new DeleteProject(adminComponents)); - expect(store.dispatch).toHaveBeenCalledWith(new GetComponents('project-1')); - expect(dialogRef.close).toHaveBeenCalledWith({ success: true }); - expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.deleteComponent.success'); - }); - - it('should skip components refresh after delete in forks context', () => { - setup({ isForksContext: true }); - (store.dispatch as Mock).mockClear(); + it('should close dialog with true on delete', () => { + setup(); component.handleDeleteComponent(); - expect(store.dispatch).toHaveBeenCalledWith(new DeleteProject(adminComponents)); - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetComponents)); - expect(dialogRef.close).toHaveBeenCalledWith({ success: true }); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); }); diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts index 3e50feae8..4479da480 100644 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts +++ b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts @@ -1,112 +1,38 @@ -import { createDispatchMap, select } from '@ngxs/store'; +import { select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { InputText } from 'primeng/inputtext'; -import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { DeleteProject, SettingsSelectors } from '@osf/features/project/settings/store'; -import { RegistrySelectors } from '@osf/features/registry/store/registry'; import { ScientistsNames } from '@osf/shared/constants/scientists.const'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { ToastService } from '@osf/shared/services/toast.service'; import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; -import { GetComponents, ProjectOverviewSelectors } from '../../store'; - @Component({ selector: 'osf-delete-component-dialog', - imports: [TranslatePipe, Button, InputText, FormsModule, Skeleton], + imports: [TranslatePipe, Button, InputText, FormsModule], templateUrl: './delete-component-dialog.component.html', styleUrl: './delete-component-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeleteComponentDialogComponent { - private dialogConfig = inject(DynamicDialogConfig); - private toastService = inject(ToastService); - - dialogRef = inject(DynamicDialogRef); - destroyRef = inject(DestroyRef); - - scientistNames = ScientistsNames; - - project = select(ProjectOverviewSelectors.getProject); - registration = select(RegistrySelectors.getRegistry); - isSubmitting = select(SettingsSelectors.isSettingsSubmitting); - isLoading = select(CurrentResourceSelectors.isResourceWithChildrenLoading); - components = select(CurrentResourceSelectors.getResourceWithChildren); - userInput = signal(''); - - selectedScientist = computed(() => { - const names = Object.values(this.scientistNames); - return names[Math.floor(Math.random() * names.length)]; - }); - - readonly currentResource = computed(() => { - const resourceType = this.dialogConfig.data.resourceType; - - if (resourceType) { - if (resourceType === ResourceType.Project) return this.project(); - - if (resourceType === ResourceType.Registration) return this.registration(); - } - - return null; - }); + readonly dialogRef = inject(DynamicDialogRef); - hasAdminAccessForAllComponents = computed(() => { - const components = this.components(); - if (!components || !components.length) return false; + readonly components = select(CurrentResourceSelectors.getResourceWithChildren); + readonly hasAdminAccessForAllComponents = select(CurrentResourceSelectors.allResourceChildrenHaveAdminAccess); - return components.every((component) => component.permissions?.includes(UserPermissions.Admin)); - }); + readonly userInput = signal(''); - hasSubComponents = computed(() => { - const components = this.components(); - return components && components.length > 1; - }); + readonly selectedScientist = ScientistsNames[Math.floor(Math.random() * ScientistsNames.length)]; - actions = createDispatchMap({ - getComponents: GetComponents, - deleteComponent: DeleteProject, - }); - - isInputValid(): boolean { - return this.userInput() === this.selectedScientist(); - } - - onInputChange(value: string): void { - this.userInput.set(value); - } + readonly isInputValid = computed(() => this.userInput() === this.selectedScientist); + readonly hasSubComponents = computed(() => this.components()?.length > 1); handleDeleteComponent(): void { - const components = this.components(); - - if (!components?.length) return; - - this.actions - .deleteComponent(components) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.dialogRef.close({ success: true }); - - const isForksContext = this.dialogConfig.data.isForksContext; - const resource = this.currentResource(); - - if (!isForksContext && resource) { - this.actions.getComponents(resource.id); - } - - this.toastService.showSuccess('project.overview.dialog.toast.deleteComponent.success'); - }, - }); + this.dialogRef.close(true); } } 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 e7b7bafd4..07c6c1377 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 @@ -7,21 +7,19 @@ import { Skeleton } from 'primeng/skeleton'; import { Tooltip } from 'primeng/tooltip'; import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core'; import { Router } from '@angular/router'; import { UserSelectors } from '@osf/core/store/user'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.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 { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; +import { GetComponents, 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', @@ -31,34 +29,35 @@ import { DeleteComponentDialogComponent } from '../delete-component-dialog/delet changeDetection: ChangeDetectionStrategy.OnPush, }) 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); - preventComponentCreation = select(UserSelectors.isProjectCreationDisabled); - - reorderedComponents = signal([]); - - actions = createDispatchMap({ - getComponentsTree: GetResourceWithChildren, + private readonly router = inject(Router); + private readonly customDialogService = inject(CustomDialogService); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly deleteResourceService = inject(DeleteResourceService); + + readonly canEdit = input.required(); + readonly anonymous = input(false); + + readonly components = select(ProjectOverviewSelectors.getComponents); + readonly isComponentsLoading = select(ProjectOverviewSelectors.getComponentsLoading); + readonly isComponentsSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting); + readonly hasMoreComponents = select(ProjectOverviewSelectors.hasMoreComponents); + readonly project = select(ProjectOverviewSelectors.getProject); + readonly preventComponentCreation = select(UserSelectors.isProjectCreationDisabled); + + private readonly actions = createDispatchMap({ + getComponents: GetComponents, loadMoreComponents: LoadMoreComponents, reorderComponents: ReorderComponents, }); - isDragDisabled = computed( + readonly reorderedComponents = signal([]); + + readonly isDragDisabled = computed( () => this.isComponentsSubmitting() || (!this.canEdit() && this.reorderedComponents().length <= 1) ); - createComponentTooltip = computed(() => + readonly createComponentTooltip = computed(() => this.preventComponentCreation() ? 'project.overview.components.addComponentDisabled' : '' ); @@ -69,15 +68,15 @@ export class OverviewComponentsComponent { }); } - handleMenuAction(action: string, componentId: string): void { + handleMenuAction(action: NodeMenuAction, componentId: string): void { switch (action) { - case 'manageContributors': + case NodeMenuAction.ManageContributors: this.router.navigate([componentId, 'contributors']); break; - case 'settings': + case NodeMenuAction.Settings: this.router.navigate([componentId, 'settings']); break; - case 'delete': + case NodeMenuAction.Delete: this.handleDeleteComponent(componentId); break; } @@ -127,18 +126,11 @@ export class OverviewComponentsComponent { const project = this.project(); if (!project) return; - this.loaderService.show(); - - this.actions.getComponentsTree(project.rootParentId || project.id, componentId, ResourceType.Project).subscribe({ - next: () => { - this.loaderService.hide(); - this.customDialogService.open(DeleteComponentDialogComponent, { - header: 'project.overview.dialog.deleteComponent.header', - width: '650px', - data: { componentId, resourceType: ResourceType.Project }, - }); - }, - error: () => this.loaderService.hide(), + this.deleteResourceService.deleteComponent({ + rootParentId: project.rootParentId || project.id, + resourceId: componentId, + destroyRef: this.destroyRef, + onSuccess: () => this.actions.getComponents(project.id), }); } } 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 406ff4391..c85cc8a29 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 @@ -5,6 +5,7 @@ import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { Router } from '@angular/router'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { ComponentCardComponent } from '../component-card/component-card.component'; @@ -31,18 +32,18 @@ export class OverviewParentProjectComponent { window.open(url, '_self'); } - handleMenuAction(action: string): void { - const projectId = this.project()?.id; + handleMenuAction(action: NodeMenuAction): void { + const projectId = this.project().id; if (!projectId) { return; } switch (action) { - case 'manageContributors': + case NodeMenuAction.ManageContributors: this.router.navigate([projectId, 'contributors']); break; - case 'settings': + case NodeMenuAction.Settings: this.router.navigate([projectId, 'settings']); break; } diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html index 499c065ef..ecf5542ec 100644 --- a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html +++ b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html @@ -1,20 +1,17 @@ @if (hasAdminAccessForAllComponents()) {

{{ 'project.deleteProject.dialog.listMessage' | translate }}

- @if (isLoading()) { - - } @else { -
    - @for (project of projects(); track project.id) { -
  • {{ project.title }}
  • - } -
- } + +
    + @for (project of projects(); track project.id) { +
  • {{ project.title }}
  • + } +

{{ 'project.deleteProject.dialog.warningMessage' | translate }}

{{ 'project.deleteProject.dialog.confirmation' | translate }} - {{ selectedScientist() }} + {{ selectedScientist }}

@@ -28,7 +25,6 @@ [label]="'common.buttons.cancel' | translate" severity="info" (onClick)="dialogRef.close()" - [disabled]="isSubmitting()" /> diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts index ea0aeefaa..22f3d96d5 100644 --- a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts +++ b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts @@ -1,44 +1,88 @@ -import { MockProvider } from 'ng-mocks'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ToastService } from '@osf/shared/services/toast.service'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; - -import { SettingsSelectors } from '../../store'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; import { DeleteProjectDialogComponent } from './delete-project-dialog.component'; describe('DeleteProjectDialogComponent', () => { let component: DeleteProjectDialogComponent; let fixture: ComponentFixture; + let dialogRef: DynamicDialogRef; + + const projects: NodeShortInfoModel[] = [ + { + id: 'p1', + title: 'Project 1', + isPublic: true, + permissions: [UserPermissions.Admin], + }, + { + id: 'p2', + title: 'Project 2', + isPublic: true, + permissions: [UserPermissions.Admin], + }, + ]; + + const defaultSignals: SignalOverride[] = [ + { selector: CurrentResourceSelectors.getResourceWithChildren, value: projects }, + { selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: true }, + ]; + + function setup(overrides: BaseSetupOverrides = {}) { + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); - beforeEach(() => { TestBed.configureTestingModule({ imports: [DeleteProjectDialogComponent], - providers: [ - provideOSFCore(), - MockProvider(ToastService), - provideDynamicDialogRefMock(), - provideMockStore({ - signals: [ - { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, - { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }, - { selector: SettingsSelectors.isSettingsSubmitting, value: false }, - ], - }), - ], + providers: [provideOSFCore(), provideDynamicDialogRefMock(), provideMockStore({ signals })], }); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(DeleteProjectDialogComponent); component = fixture.componentInstance; - }); + fixture.detectChanges(); + } it('should create', () => { + setup(); + expect(component).toBeTruthy(); }); + + it('should return false for hasAdminAccessForAllComponents when access is denied', () => { + setup({ + selectorOverrides: [{ selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: false }], + }); + + expect(component.hasAdminAccessForAllComponents()).toBe(false); + }); + + it('should return true for isInputValid when confirmation matches selected scientist', () => { + setup(); + + component.userInput.set(component.selectedScientist); + + expect(component.isInputValid()).toBe(true); + }); + + it('should close dialog with true on delete', () => { + setup(); + + component.handleDeleteProject(); + + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); }); diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts index ec54ab464..2102f8d41 100644 --- a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts +++ b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts @@ -1,75 +1,37 @@ -import { createDispatchMap, select } from '@ngxs/store'; +import { select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { InputText } from 'primeng/inputtext'; -import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; import { ScientistsNames } from '@osf/shared/constants/scientists.const'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { ToastService } from '@osf/shared/services/toast.service'; import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; -import { DeleteProject, SettingsSelectors } from '../../store'; - @Component({ selector: 'osf-delete-project-dialog', - imports: [TranslatePipe, Button, InputText, FormsModule, Skeleton], + imports: [TranslatePipe, Button, InputText, FormsModule], templateUrl: './delete-project-dialog.component.html', styleUrl: './delete-project-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeleteProjectDialogComponent { - private toastService = inject(ToastService); - private router = inject(Router); - - dialogRef = inject(DynamicDialogRef); - destroyRef = inject(DestroyRef); - - scientistNames = ScientistsNames; - userInput = signal(''); - - isLoading = select(CurrentResourceSelectors.isResourceWithChildrenLoading); - isSubmitting = select(SettingsSelectors.isSettingsSubmitting); - projects = select(CurrentResourceSelectors.getResourceWithChildren); + readonly dialogRef = inject(DynamicDialogRef); - hasAdminAccessForAllComponents = computed(() => { - const projects = this.projects(); - if (!projects || !projects.length) return false; + readonly projects = select(CurrentResourceSelectors.getResourceWithChildren); + readonly hasAdminAccessForAllComponents = select(CurrentResourceSelectors.allResourceChildrenHaveAdminAccess); - return projects.every((project) => project.permissions?.includes(UserPermissions.Admin)); - }); + readonly userInput = signal(''); - selectedScientist = computed(() => { - const names = Object.values(this.scientistNames); - return names[Math.floor(Math.random() * names.length)]; - }); + readonly selectedScientist = ScientistsNames[Math.floor(Math.random() * ScientistsNames.length)]; - actions = createDispatchMap({ deleteProject: DeleteProject }); - - isInputValid = computed(() => this.userInput() === this.selectedScientist()); + readonly isInputValid = computed(() => this.userInput() === this.selectedScientist); handleDeleteProject(): void { - const projects = this.projects(); - - if (!projects?.length) return; - - this.actions - .deleteProject(projects) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.dialogRef.close({ success: true }); - this.toastService.showSuccess('project.deleteProject.success'); - this.router.navigate(['/']); - }, - }); + this.dialogRef.close(true); } } diff --git a/src/app/features/project/settings/models/delete-resource-options.model.ts b/src/app/features/project/settings/models/delete-resource-options.model.ts new file mode 100644 index 000000000..770610401 --- /dev/null +++ b/src/app/features/project/settings/models/delete-resource-options.model.ts @@ -0,0 +1,18 @@ +import { DestroyRef, Type } from '@angular/core'; + +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; + +export interface DeleteResourceBaseOptions { + rootParentId: string; + resourceId: string; + resourceType?: ResourceType; + destroyRef: DestroyRef; + onSuccess: () => void; +} + +export interface DeleteResourceRunConfig extends DeleteResourceBaseOptions { + dialogComponent: Type; + dialogHeader: string; + dialogWidth: string; + toastKey: string; +} diff --git a/src/app/features/project/settings/services/delete-resource.service.ts b/src/app/features/project/settings/services/delete-resource.service.ts new file mode 100644 index 000000000..f328f5648 --- /dev/null +++ b/src/app/features/project/settings/services/delete-resource.service.ts @@ -0,0 +1,90 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { catchError, EMPTY, filter, finalize, switchMap, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components/delete-component-dialog/delete-component-dialog.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +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 { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; + +import { DeleteProjectDialogComponent } from '../components/delete-project-dialog/delete-project-dialog.component'; +import { DeleteResourceBaseOptions, DeleteResourceRunConfig } from '../models/delete-resource-options.model'; +import { DeleteProject } from '../store/settings.actions'; + +@Injectable({ providedIn: 'root' }) +export class DeleteResourceService { + private readonly customDialogService = inject(CustomDialogService); + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + + private readonly resourcesToDelete = select(CurrentResourceSelectors.getResourceWithChildren); + + private readonly actions = createDispatchMap({ + getResourceWithChildren: GetResourceWithChildren, + deleteProject: DeleteProject, + }); + + deleteComponent(options: DeleteResourceBaseOptions): void { + this.run({ + ...options, + dialogComponent: DeleteComponentDialogComponent, + dialogHeader: 'project.overview.dialog.deleteComponent.header', + dialogWidth: '650px', + toastKey: 'project.overview.dialog.toast.deleteComponent.success', + }); + } + + deleteProject(options: DeleteResourceBaseOptions): void { + this.run({ + ...options, + dialogComponent: DeleteProjectDialogComponent, + dialogHeader: 'project.deleteProject.dialog.deleteProject', + dialogWidth: '500px', + toastKey: 'project.deleteProject.success', + }); + } + + private run(config: DeleteResourceRunConfig): void { + const { + rootParentId, + resourceId, + resourceType = ResourceType.Project, + destroyRef, + onSuccess, + dialogComponent, + dialogHeader, + dialogWidth, + toastKey, + } = config; + + this.actions + .getResourceWithChildren(rootParentId, resourceId, resourceType) + .pipe( + tap({ subscribe: () => this.loaderService.show() }), + finalize(() => this.loaderService.hide()), + switchMap(() => + this.customDialogService + .open(dialogComponent, { + header: dialogHeader, + width: dialogWidth, + }) + .onClose.pipe(filter((confirmed: boolean) => !!confirmed)) + ), + tap(() => this.loaderService.show()), + switchMap(() => + this.actions.deleteProject(this.resourcesToDelete()).pipe(finalize(() => this.loaderService.hide())) + ), + catchError(() => EMPTY), + takeUntilDestroyed(destroyRef) + ) + .subscribe(() => { + this.toastService.showSuccess(toastKey); + onSuccess(); + }); + } +} diff --git a/src/app/features/project/settings/services/settings.service.ts b/src/app/features/project/settings/services/settings.service.ts index fcdb82acb..6337092e2 100644 --- a/src/app/features/project/settings/services/settings.service.ts +++ b/src/app/features/project/settings/services/settings.service.ts @@ -3,6 +3,7 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum'; import { NotificationSubscriptionMapper } from '@osf/shared/mappers/notification-subscription.mapper'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; @@ -83,16 +84,8 @@ export class SettingsService { } deleteProject(projects: NodeShortInfoModel[]): Observable { - const payload = { - data: projects.map((project) => ({ - type: 'nodes', - id: project.id, - })), - }; - - const headers = { - 'Content-Type': 'application/vnd.api+json; ext=bulk', - }; + const payload = { data: projects.map((project) => ({ id: project.id, type: CurrentResourceType.Projects })) }; + const headers = { 'Content-Type': 'application/vnd.api+json; ext=bulk' }; return this.jsonApiService.delete(`${this.apiUrl}/nodes/`, payload, headers); } diff --git a/src/app/features/project/settings/settings.component.ts b/src/app/features/project/settings/settings.component.ts index d697fe844..37f804c42 100644 --- a/src/app/features/project/settings/settings.component.ts +++ b/src/app/features/project/settings/settings.component.ts @@ -4,10 +4,10 @@ import { TranslatePipe } from '@ngx-translate/core'; import { map, of } from 'rxjs'; -import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; @@ -19,14 +19,13 @@ import { Institution } from '@osf/shared/models/institutions/institutions.model' import { UpdateNodeRequestModel } from '@osf/shared/models/nodes/nodes-json-api.model'; import { ViewOnlyLinkModel } from '@osf/shared/models/view-only-links/view-only-link.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -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 { GetResource, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; +import { GetResource } from '@osf/shared/stores/current-resource'; import { DeleteViewOnlyLink, FetchViewOnlyLinks, ViewOnlyLinkSelectors } from '@osf/shared/stores/view-only-links'; +import { DeleteResourceService } from './services/delete-resource.service'; import { - DeleteProjectDialogComponent, ProjectSettingNotificationsComponent, SettingsAccessRequestsCardComponent, SettingsProjectAffiliationComponent, @@ -38,7 +37,6 @@ import { import { ProjectDetailsModel, ProjectSettingsAttributesJsonApi, ProjectSettingsDataJsonApi } from './models'; import { DeleteInstitution, - DeleteProject, GetProjectDetails, GetProjectNotificationSubscriptions, GetProjectSettings, @@ -70,8 +68,10 @@ import { }) export class SettingsComponent implements OnInit { private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); private readonly customConfirmationService = inject(CustomConfirmationService); - private readonly customDialogService = inject(CustomDialogService); + private readonly deleteResourceService = inject(DeleteResourceService); private readonly toastService = inject(ToastService); private readonly loaderService = inject(LoaderService); @@ -97,10 +97,8 @@ export class SettingsComponent implements OnInit { updateProjectSettings: UpdateProjectSettings, updateNotificationSubscription: UpdateProjectNotificationSubscription, deleteViewOnlyLink: DeleteViewOnlyLink, - deleteProject: DeleteProject, deleteInstitution: DeleteInstitution, refreshCurrentResource: GetResource, - getComponentsTree: GetResourceWithChildren, }); accessRequest = signal(false); @@ -184,19 +182,17 @@ export class SettingsComponent implements OnInit { } deleteProject(): void { - this.loaderService.show(); + const projectId = this.projectId(); + const projectDetails = this.projectDetails(); - this.actions - .getComponentsTree(this.projectDetails()?.rootId || this.projectId(), this.projectId(), ResourceType.Project) - .subscribe({ - next: () => { - this.loaderService.hide(); - this.customDialogService.open(DeleteProjectDialogComponent, { - header: 'project.deleteProject.dialog.deleteProject', - width: '500px', - }); - }, - }); + if (!projectId) return; + + this.deleteResourceService.deleteProject({ + rootParentId: projectDetails?.rootId || projectId, + resourceId: projectId, + destroyRef: this.destroyRef, + onSuccess: () => this.router.navigate(['/']), + }); } removeAffiliation(affiliation: Institution): void { diff --git a/src/app/features/analytics/enums/duplicate-menu-action.enum.ts b/src/app/shared/enums/node-menu-action.enum.ts similarity index 73% rename from src/app/features/analytics/enums/duplicate-menu-action.enum.ts rename to src/app/shared/enums/node-menu-action.enum.ts index 5d78213f4..3b66369ba 100644 --- a/src/app/features/analytics/enums/duplicate-menu-action.enum.ts +++ b/src/app/shared/enums/node-menu-action.enum.ts @@ -1,4 +1,4 @@ -export enum DuplicateMenuAction { +export enum NodeMenuAction { ManageContributors = 'manageContributors', Settings = 'settings', Delete = 'delete', diff --git a/src/app/shared/stores/current-resource/current-resource.selectors.ts b/src/app/shared/stores/current-resource/current-resource.selectors.ts index c4d7d3b17..c428ef8c5 100644 --- a/src/app/shared/stores/current-resource/current-resource.selectors.ts +++ b/src/app/shared/stores/current-resource/current-resource.selectors.ts @@ -24,6 +24,13 @@ export class CurrentResourceSelectors { return state.resourceChildren.data; } + @Selector([CurrentResourceSelectors.getResourceWithChildren]) + static allResourceChildrenHaveAdminAccess(children: NodeShortInfoModel[]): boolean { + if (!children?.length) return false; + + return children.every((child) => child.permissions?.includes(UserPermissions.Admin)); + } + @Selector([CurrentResourceState]) static hasWriteAccess(state: CurrentResourceStateModel): boolean { return state.currentResource.data?.permissions?.includes(UserPermissions.Write) || false; From 6fa376e05a1ffc21062670f25b338995aafcb3a0 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 13 Oct 2026 16:57:30 +0300 Subject: [PATCH 2/5] fix(delete-dialogs): created one shared delete dialog --- .../view-duplicates.component.ts | 6 - .../delete-component-dialog.component.html | 40 ------- .../delete-component-dialog.component.scss | 0 .../delete-component-dialog.component.spec.ts | 108 ------------------ .../delete-component-dialog.component.ts | 38 ------ .../services/project-overview.service.ts | 4 - .../store/project-overview.actions.ts | 6 - .../overview/store/project-overview.state.ts | 25 ---- .../delete-project-dialog.component.html | 37 ------ .../delete-project-dialog.component.scss | 0 .../delete-project-dialog.component.spec.ts | 88 -------------- .../delete-resource-dialog.component.html | 40 +++++++ .../delete-resource-dialog.component.ts} | 18 +-- .../project/settings/components/index.ts | 1 - .../models/delete-resource-options.model.ts | 14 ++- .../services/delete-resource.service.ts | 46 ++++---- 16 files changed, 82 insertions(+), 389 deletions(-) delete mode 100644 src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html delete mode 100644 src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.scss delete mode 100644 src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts delete mode 100644 src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts delete mode 100644 src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html delete mode 100644 src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.scss delete mode 100644 src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts create mode 100644 src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.html rename src/app/features/project/settings/components/{delete-project-dialog/delete-project-dialog.component.ts => delete-resource-dialog/delete-resource-dialog.component.ts} (58%) diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts index 9705378ad..9e75ef452 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts @@ -30,10 +30,7 @@ import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@osf/shared/stores/duplicates'; import { DuplicateCardComponent } from '../duplicate-card/duplicate-card.component'; @@ -52,9 +49,7 @@ import { DuplicateCardComponent } from '../duplicate-card/duplicate-card.compone changeDetection: ChangeDetectionStrategy.OnPush, }) export class ViewDuplicatesComponent { - private readonly customDialogService = inject(CustomDialogService); private readonly forkDialogService = inject(ForkDialogService); - private readonly loaderService = inject(LoaderService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); @@ -70,7 +65,6 @@ export class ViewDuplicatesComponent { readonly actions = createDispatchMap({ getDuplicates: GetAllDuplicates, clearDuplicates: ClearDuplicates, - getComponentsTree: GetResourceWithChildren, forkProject: ForkProject, forkRegistry: ForkRegistry, }); diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html deleted file mode 100644 index b982147a5..000000000 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html +++ /dev/null @@ -1,40 +0,0 @@ -@if (hasAdminAccessForAllComponents()) { - @if (hasSubComponents()) { -

{{ 'project.overview.dialog.deleteComponent.listMessage' | translate }}

- -
    - @for (component of components(); track component.id) { -
  • {{ component.title }}
  • - } -
- -

{{ 'project.overview.dialog.deleteComponent.warningMessage' | translate }}

- } @else { -

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

- } - -

- {{ 'project.overview.dialog.deleteComponent.confirmation' | translate }} - {{ selectedScientist }} -

- - -} @else { -

-} - -
- - -
diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.scss b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts deleted file mode 100644 index a53b8a18d..000000000 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; - -import { provideOSFCore } from '@testing/osf.testing.provider'; -import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { - BaseSetupOverrides, - mergeSignalOverrides, - provideMockStore, - SignalOverride, -} from '@testing/providers/store-provider.mock'; - -import { DeleteComponentDialogComponent } from './delete-component-dialog.component'; - -describe('DeleteComponentDialogComponent', () => { - let component: DeleteComponentDialogComponent; - let fixture: ComponentFixture; - let dialogRef: DynamicDialogRef; - - const adminComponents: NodeShortInfoModel[] = [ - { - id: 'c1', - title: 'Component 1', - isPublic: true, - permissions: [UserPermissions.Admin], - }, - { - id: 'c2', - title: 'Component 2', - isPublic: true, - permissions: [UserPermissions.Admin, UserPermissions.Write], - }, - ]; - - const defaultSignals: SignalOverride[] = [ - { selector: CurrentResourceSelectors.getResourceWithChildren, value: adminComponents }, - { selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: true }, - ]; - - function setup(overrides: BaseSetupOverrides = {}) { - const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); - - TestBed.configureTestingModule({ - imports: [DeleteComponentDialogComponent], - providers: [provideOSFCore(), provideDynamicDialogRefMock(), provideMockStore({ signals })], - }); - - dialogRef = TestBed.inject(DynamicDialogRef); - fixture = TestBed.createComponent(DeleteComponentDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - } - - it('should create', () => { - setup(); - - expect(component).toBeTruthy(); - }); - - it('should return false for hasAdminAccessForAllComponents when access is denied', () => { - setup({ - selectorOverrides: [{ selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: false }], - }); - - expect(component.hasAdminAccessForAllComponents()).toBe(false); - }); - - it('should return true for hasAdminAccessForAllComponents when all components have admin access', () => { - setup(); - - expect(component.hasAdminAccessForAllComponents()).toBe(true); - }); - - it('should return true for hasSubComponents when there are multiple components', () => { - setup(); - - expect(component.hasSubComponents()).toBe(true); - }); - - it('should return false for hasSubComponents when there is one component', () => { - setup({ - selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [adminComponents[0]] }], - }); - - expect(component.hasSubComponents()).toBe(false); - }); - - it('should return true for isInputValid when confirmation matches selected scientist', () => { - setup(); - - component.userInput.set(component.selectedScientist); - - expect(component.isInputValid()).toBe(true); - }); - - it('should close dialog with true on delete', () => { - setup(); - - component.handleDeleteComponent(); - - expect(dialogRef.close).toHaveBeenCalledWith(true); - }); -}); diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts deleted file mode 100644 index 4479da480..000000000 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; -import { InputText } from 'primeng/inputtext'; - -import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { ScientistsNames } from '@osf/shared/constants/scientists.const'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; - -@Component({ - selector: 'osf-delete-component-dialog', - imports: [TranslatePipe, Button, InputText, FormsModule], - templateUrl: './delete-component-dialog.component.html', - styleUrl: './delete-component-dialog.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DeleteComponentDialogComponent { - readonly dialogRef = inject(DynamicDialogRef); - - readonly components = select(CurrentResourceSelectors.getResourceWithChildren); - readonly hasAdminAccessForAllComponents = select(CurrentResourceSelectors.allResourceChildrenHaveAdminAccess); - - readonly userInput = signal(''); - - readonly selectedScientist = ScientistsNames[Math.floor(Math.random() * ScientistsNames.length)]; - - readonly isInputValid = computed(() => this.userInput() === this.selectedScientist); - readonly hasSubComponents = computed(() => this.components()?.length > 1); - - handleDeleteComponent(): void { - this.dialogRef.close(true); - } -} 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 f58bd0bff..ae2e609c7 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -172,10 +172,6 @@ export class ProjectOverviewService { ); } - deleteComponent(componentId: string): Observable { - return this.jsonApiService.delete(`${this.apiUrl}/nodes/${componentId}/`); - } - getComponents(projectId: string, page = 1, pageSize = 10): Observable> { const params: Record = { embed: 'bibliographic_contributors', 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 58c80445c..16c79931a 100644 --- a/src/app/features/project/overview/store/project-overview.actions.ts +++ b/src/app/features/project/overview/store/project-overview.actions.ts @@ -87,12 +87,6 @@ export class CreateComponent { ) {} } -export class DeleteComponent { - static readonly type = '[Project Overview] Delete Component'; - - constructor(public componentId: string) {} -} - export class GetComponents { static readonly type = '[Project Overview] Get Components'; 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 d74ee787c..8cd50508f 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -14,7 +14,6 @@ import { ClearDuplicatedProject, ClearProjectOverview, CreateComponent, - DeleteComponent, DuplicateProject, ForkProject, GetComponents, @@ -356,30 +355,6 @@ export class ProjectOverviewState { ); } - @Action(DeleteComponent) - deleteComponent(ctx: StateContext, action: DeleteComponent) { - const state = ctx.getState(); - ctx.patchState({ - components: { - ...state.components, - isSubmitting: true, - }, - }); - - return this.projectOverviewService.deleteComponent(action.componentId).pipe( - tap(() => { - ctx.patchState({ - components: { - ...state.components, - isSubmitting: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'components', error)) - ); - } - @Action(GetComponents) getProjectComponents(ctx: StateContext, action: GetComponents) { const state = ctx.getState(); diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html deleted file mode 100644 index ecf5542ec..000000000 --- a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html +++ /dev/null @@ -1,37 +0,0 @@ -@if (hasAdminAccessForAllComponents()) { -

{{ 'project.deleteProject.dialog.listMessage' | translate }}

- -
    - @for (project of projects(); track project.id) { -
  • {{ project.title }}
  • - } -
- -

{{ 'project.deleteProject.dialog.warningMessage' | translate }}

- -

- {{ 'project.deleteProject.dialog.confirmation' | translate }} - {{ selectedScientist }} -

- - -} @else { -

-} -
- - -
diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.scss b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts deleted file mode 100644 index 22f3d96d5..000000000 --- a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; - -import { provideOSFCore } from '@testing/osf.testing.provider'; -import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { - BaseSetupOverrides, - mergeSignalOverrides, - provideMockStore, - SignalOverride, -} from '@testing/providers/store-provider.mock'; - -import { DeleteProjectDialogComponent } from './delete-project-dialog.component'; - -describe('DeleteProjectDialogComponent', () => { - let component: DeleteProjectDialogComponent; - let fixture: ComponentFixture; - let dialogRef: DynamicDialogRef; - - const projects: NodeShortInfoModel[] = [ - { - id: 'p1', - title: 'Project 1', - isPublic: true, - permissions: [UserPermissions.Admin], - }, - { - id: 'p2', - title: 'Project 2', - isPublic: true, - permissions: [UserPermissions.Admin], - }, - ]; - - const defaultSignals: SignalOverride[] = [ - { selector: CurrentResourceSelectors.getResourceWithChildren, value: projects }, - { selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: true }, - ]; - - function setup(overrides: BaseSetupOverrides = {}) { - const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); - - TestBed.configureTestingModule({ - imports: [DeleteProjectDialogComponent], - providers: [provideOSFCore(), provideDynamicDialogRefMock(), provideMockStore({ signals })], - }); - - dialogRef = TestBed.inject(DynamicDialogRef); - fixture = TestBed.createComponent(DeleteProjectDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - } - - it('should create', () => { - setup(); - - expect(component).toBeTruthy(); - }); - - it('should return false for hasAdminAccessForAllComponents when access is denied', () => { - setup({ - selectorOverrides: [{ selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: false }], - }); - - expect(component.hasAdminAccessForAllComponents()).toBe(false); - }); - - it('should return true for isInputValid when confirmation matches selected scientist', () => { - setup(); - - component.userInput.set(component.selectedScientist); - - expect(component.isInputValid()).toBe(true); - }); - - it('should close dialog with true on delete', () => { - setup(); - - component.handleDeleteProject(); - - expect(dialogRef.close).toHaveBeenCalledWith(true); - }); -}); diff --git a/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.html b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.html new file mode 100644 index 000000000..5874d5332 --- /dev/null +++ b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.html @@ -0,0 +1,40 @@ +@if (hasAdminAccessForAllComponents()) { + @if (showList()) { +

{{ dialogData.listMessageKey | translate }}

+ +
    + @for (resource of resources(); track resource.id) { +
  • {{ resource.title }}
  • + } +
+ +

{{ dialogData.warningMessageKey | translate }}

+ } @else { +

{{ dialogData.singleMessageKey | translate }}

+ } + +

+ {{ dialogData.confirmationKey | translate }} + {{ selectedScientist }} +

+ + +} @else { +

+} + +
+ + +
diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.ts similarity index 58% rename from src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts rename to src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.ts index 2102f8d41..067bc6462 100644 --- a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts +++ b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.ts @@ -3,7 +3,7 @@ import { select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { InputText } from 'primeng/inputtext'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; @@ -12,17 +12,19 @@ import { FormsModule } from '@angular/forms'; import { ScientistsNames } from '@osf/shared/constants/scientists.const'; import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; +import { DeleteResourceDialogData } from '../../models/delete-resource-options.model'; + @Component({ - selector: 'osf-delete-project-dialog', + selector: 'osf-delete-resource-dialog', imports: [TranslatePipe, Button, InputText, FormsModule], - templateUrl: './delete-project-dialog.component.html', - styleUrl: './delete-project-dialog.component.scss', + templateUrl: './delete-resource-dialog.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DeleteProjectDialogComponent { +export class DeleteResourceDialogComponent { readonly dialogRef = inject(DynamicDialogRef); + readonly dialogData: DeleteResourceDialogData = inject(DynamicDialogConfig).data; - readonly projects = select(CurrentResourceSelectors.getResourceWithChildren); + readonly resources = select(CurrentResourceSelectors.getResourceWithChildren); readonly hasAdminAccessForAllComponents = select(CurrentResourceSelectors.allResourceChildrenHaveAdminAccess); readonly userInput = signal(''); @@ -30,8 +32,10 @@ export class DeleteProjectDialogComponent { readonly selectedScientist = ScientistsNames[Math.floor(Math.random() * ScientistsNames.length)]; readonly isInputValid = computed(() => this.userInput() === this.selectedScientist); + readonly hasMultipleResources = computed(() => (this.resources()?.length ?? 0) > 1); + readonly showList = computed(() => !this.dialogData.singleMessageKey || this.hasMultipleResources()); - handleDeleteProject(): void { + handleDelete(): void { this.dialogRef.close(true); } } diff --git a/src/app/features/project/settings/components/index.ts b/src/app/features/project/settings/components/index.ts index 37ac121d2..48f7a5372 100644 --- a/src/app/features/project/settings/components/index.ts +++ b/src/app/features/project/settings/components/index.ts @@ -1,4 +1,3 @@ -export { DeleteProjectDialogComponent } from './delete-project-dialog/delete-project-dialog.component'; export { ProjectDetailSettingAccordionComponent } from './project-detail-setting-accordion/project-detail-setting-accordion.component'; export { ProjectSettingNotificationsComponent } from './project-setting-notifications/project-setting-notifications.component'; export { SettingsAccessRequestsCardComponent } from './settings-access-requests-card/settings-access-requests-card.component'; diff --git a/src/app/features/project/settings/models/delete-resource-options.model.ts b/src/app/features/project/settings/models/delete-resource-options.model.ts index 770610401..673d8123d 100644 --- a/src/app/features/project/settings/models/delete-resource-options.model.ts +++ b/src/app/features/project/settings/models/delete-resource-options.model.ts @@ -1,18 +1,22 @@ -import { DestroyRef, Type } from '@angular/core'; +import { DestroyRef } from '@angular/core'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +export interface DeleteResourceDialogData { + listMessageKey: string; + warningMessageKey: string; + confirmationKey: string; + noPermissionsMessageKey: string; + singleMessageKey?: string; +} export interface DeleteResourceBaseOptions { rootParentId: string; resourceId: string; - resourceType?: ResourceType; destroyRef: DestroyRef; onSuccess: () => void; } export interface DeleteResourceRunConfig extends DeleteResourceBaseOptions { - dialogComponent: Type; dialogHeader: string; - dialogWidth: string; toastKey: string; + dialogData: DeleteResourceDialogData; } diff --git a/src/app/features/project/settings/services/delete-resource.service.ts b/src/app/features/project/settings/services/delete-resource.service.ts index f328f5648..746f75bf7 100644 --- a/src/app/features/project/settings/services/delete-resource.service.ts +++ b/src/app/features/project/settings/services/delete-resource.service.ts @@ -1,18 +1,17 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { catchError, EMPTY, filter, finalize, switchMap, tap } from 'rxjs'; +import { catchError, EMPTY, filter, finalize, switchMap, take, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components/delete-component-dialog/delete-component-dialog.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; 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 { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; -import { DeleteProjectDialogComponent } from '../components/delete-project-dialog/delete-project-dialog.component'; +import { DeleteResourceDialogComponent } from '../components/delete-resource-dialog/delete-resource-dialog.component'; import { DeleteResourceBaseOptions, DeleteResourceRunConfig } from '../models/delete-resource-options.model'; import { DeleteProject } from '../store/settings.actions'; @@ -32,48 +31,47 @@ export class DeleteResourceService { deleteComponent(options: DeleteResourceBaseOptions): void { this.run({ ...options, - dialogComponent: DeleteComponentDialogComponent, dialogHeader: 'project.overview.dialog.deleteComponent.header', - dialogWidth: '650px', toastKey: 'project.overview.dialog.toast.deleteComponent.success', + dialogData: { + listMessageKey: 'project.overview.dialog.deleteComponent.listMessage', + warningMessageKey: 'project.overview.dialog.deleteComponent.warningMessage', + confirmationKey: 'project.overview.dialog.deleteComponent.confirmation', + noPermissionsMessageKey: 'project.overview.dialog.deleteComponent.noPermissionsMessage', + singleMessageKey: 'project.overview.dialog.deleteComponent.message', + }, }); } deleteProject(options: DeleteResourceBaseOptions): void { this.run({ ...options, - dialogComponent: DeleteProjectDialogComponent, dialogHeader: 'project.deleteProject.dialog.deleteProject', - dialogWidth: '500px', toastKey: 'project.deleteProject.success', + dialogData: { + listMessageKey: 'project.deleteProject.dialog.listMessage', + warningMessageKey: 'project.deleteProject.dialog.warningMessage', + confirmationKey: 'project.deleteProject.dialog.confirmation', + noPermissionsMessageKey: 'project.deleteProject.dialog.noPermissionsMessage', + }, }); } private run(config: DeleteResourceRunConfig): void { - const { - rootParentId, - resourceId, - resourceType = ResourceType.Project, - destroyRef, - onSuccess, - dialogComponent, - dialogHeader, - dialogWidth, - toastKey, - } = config; + const { rootParentId, resourceId, destroyRef, onSuccess, dialogHeader, toastKey, dialogData } = config; this.actions - .getResourceWithChildren(rootParentId, resourceId, resourceType) + .getResourceWithChildren(rootParentId, resourceId, ResourceType.Project) .pipe( tap({ subscribe: () => this.loaderService.show() }), finalize(() => this.loaderService.hide()), switchMap(() => this.customDialogService - .open(dialogComponent, { - header: dialogHeader, - width: dialogWidth, - }) - .onClose.pipe(filter((confirmed: boolean) => !!confirmed)) + .open(DeleteResourceDialogComponent, { header: dialogHeader, width: '500px', data: dialogData }) + .onClose.pipe( + take(1), + filter((confirmed: boolean) => !!confirmed) + ) ), tap(() => this.loaderService.show()), switchMap(() => From 9ede539ef0f928742e4a59fb57138118e251da0e Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 14 Oct 2026 10:41:30 +0300 Subject: [PATCH 3/5] test(project): updated tests --- .../duplicate-card.component.spec.ts | 17 +- .../view-duplicates.component.spec.ts | 106 ++---- .../component-card.component.spec.ts | 86 ++--- .../overview-components.component.spec.ts | 112 ++++--- .../overview-parent-project.component.spec.ts | 73 ++--- .../delete-resource-dialog.component.spec.ts | 137 ++++++++ ...ttings-project-form-card.component.spec.ts | 20 +- .../settings/settings.component.spec.ts | 301 +++++++++++++----- .../project/settings/settings.component.ts | 7 - src/app/shared/mappers/wiki/wiki.mapper.ts | 2 +- .../shared/models/wiki/wiki-json-api.model.ts | 1 - src/app/shared/services/wiki.service.ts | 5 +- .../delete-resource-provider.mock.ts | 20 ++ 13 files changed, 571 insertions(+), 316 deletions(-) create mode 100644 src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.spec.ts create mode 100644 src/testing/providers/delete-resource-provider.mock.ts diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts index cca195ff4..e1de937cb 100644 --- a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts @@ -6,14 +6,13 @@ import { provideRouter } 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 { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { MOCK_NODE_WITH_ADMIN, MOCK_NODE_WITHOUT_ADMIN } from '@testing/mocks/node.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { DuplicateMenuAction } from '../../enums/duplicate-menu-action.enum'; - import { DuplicateCardComponent } from './duplicate-card.component'; describe('DuplicateCardComponent', () => { @@ -50,15 +49,15 @@ describe('DuplicateCardComponent', () => { expect(component.menuItems).toEqual([ { label: 'project.overview.actions.manageContributors', - action: DuplicateMenuAction.ManageContributors, + action: NodeMenuAction.ManageContributors, }, { label: 'project.overview.actions.settings', - action: DuplicateMenuAction.Settings, + action: NodeMenuAction.Settings, }, { label: 'common.labels.delete', - action: DuplicateMenuAction.Delete, + action: NodeMenuAction.Delete, }, ]); }); @@ -84,12 +83,12 @@ describe('DuplicateCardComponent', () => { it('should emit menuAction when handleMenuAction is called', () => { setup(); - const emitted: DuplicateMenuAction[] = []; + const emitted: NodeMenuAction[] = []; component.menuAction.subscribe((action) => emitted.push(action)); - component.handleMenuAction(DuplicateMenuAction.Settings); - component.handleMenuAction(DuplicateMenuAction.Delete); + component.handleMenuAction(NodeMenuAction.Settings); + component.handleMenuAction(NodeMenuAction.Delete); - expect(emitted).toEqual([DuplicateMenuAction.Settings, DuplicateMenuAction.Delete]); + expect(emitted).toEqual([NodeMenuAction.Settings, NodeMenuAction.Delete]); }); }); diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts index c34080430..fd73ecb16 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts @@ -2,42 +2,33 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { PaginatorState } from 'primeng/paginator'; - -import { of, Subject, throwError } from 'rxjs'; - import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@osf/core/store/user/user.selectors'; -import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components/delete-component-dialog/delete-component-dialog.component'; import { ForkProject } from '@osf/features/project/overview/store'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; import { ForkRegistry } from '@osf/features/registry/store/registry'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@osf/shared/stores/duplicates'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { - CustomDialogServiceMock, - CustomDialogServiceMockBuilder, - CustomDialogServiceMockType, -} from '@testing/providers/custom-dialog-provider.mock'; + DeleteResourceServiceMock, + DeleteResourceServiceMockType, +} from '@testing/providers/delete-resource-provider.mock'; import { ForkDialogServiceMock, ForkDialogServiceMockType } from '@testing/providers/fork-dialog.service.mock'; -import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; -import { DuplicateMenuAction } from '../../enums/duplicate-menu-action.enum'; import { DuplicateCardComponent } from '../duplicate-card/duplicate-card.component'; import { ViewDuplicatesComponent } from './view-duplicates.component'; @@ -51,14 +42,12 @@ describe('ViewDuplicatesComponent', () => { let fixture: ComponentFixture; let store: Store; let routerMock: ReturnType; - let mockCustomDialogService: CustomDialogServiceMockType; let forkDialogService: ForkDialogServiceMockType; - let loaderService: LoaderServiceMock; + let deleteResourceService: DeleteResourceServiceMockType; function setup(overrides: SetupOverrides = {}) { - mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); forkDialogService = ForkDialogServiceMock.simple(); - loaderService = new LoaderServiceMock(); + deleteResourceService = DeleteResourceServiceMock.simple(); routerMock = RouterMockBuilder.create().build(); const routeBuilder = ActivatedRouteMockBuilder.create() @@ -92,9 +81,8 @@ describe('ViewDuplicatesComponent', () => { provideMockStore({ signals: mergeSignalOverrides(defaultSelectors, overrides.selectorOverrides), }), - MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(ForkDialogService, forkDialogService), - MockProvider(LoaderService, loaderService), + MockProvider(DeleteResourceService, deleteResourceService), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, routeBuilder.build()), ], @@ -144,19 +132,17 @@ describe('ViewDuplicatesComponent', () => { expect(component.firstIndex()).toBe(20); }); - it('should compute apiResourceType for project and registration', () => { + it('should use the projects resource type for a project', () => { setup(); - expect(component.apiResourceType()).toBe(CurrentResourceType.Projects); - - setup({ resourceType: ResourceType.Registration }); - expect(component.apiResourceType()).toBe(CurrentResourceType.Registrations); - }); - it('should compute duplicate button label for project and registration', () => { - setup(); + expect(component.apiResourceType()).toBe(CurrentResourceType.Projects); expect(component.duplicateButtonLabel()).toBe('project.overview.actions.forkProjectLabel'); + }); + it('should use the registrations resource type for a registration', () => { setup({ resourceType: ResourceType.Registration }); + + expect(component.apiResourceType()).toBe(CurrentResourceType.Registrations); expect(component.duplicateButtonLabel()).toBe('project.overview.actions.duplicateRegistration'); }); @@ -174,53 +160,36 @@ describe('ViewDuplicatesComponent', () => { expect(component.duplicateButtonTooltip()).toBe(''); }); - it('should navigate for manageContributors and settings menu actions', () => { + it('should navigate to contributors for the manageContributors action', () => { setup(); - component.handleMenuAction(DuplicateMenuAction.ManageContributors, 'node-1'); - component.handleMenuAction(DuplicateMenuAction.Settings, 'node-1'); + component.handleMenuAction(NodeMenuAction.ManageContributors, 'node-1'); expect(routerMock.navigate).toHaveBeenCalledWith(['node-1', 'contributors']); - expect(routerMock.navigate).toHaveBeenCalledWith(['node-1', 'settings']); }); - it('should open delete dialog and refresh duplicates when delete succeeds', () => { + it('should navigate to settings for the settings action', () => { setup(); - const onClose$ = new Subject<{ success: boolean }>(); - mockCustomDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); - (store.dispatch as Mock).mockClear(); - component.handleMenuAction(DuplicateMenuAction.Delete, 'fork-1'); + component.handleMenuAction(NodeMenuAction.Settings, 'node-1'); - expect(loaderService.show).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(new GetResourceWithChildren('fork-1', 'fork-1', ResourceType.Project)); - expect(loaderService.hide).toHaveBeenCalled(); - expect(mockCustomDialogService.open).toHaveBeenCalledWith(DeleteComponentDialogComponent, { - header: 'project.overview.dialog.deleteComponent.header', - width: '650px', - data: { isForksContext: true }, - }); - - (store.dispatch as Mock).mockClear(); - onClose$.next({ success: true }); - - expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 1, 10)); + expect(routerMock.navigate).toHaveBeenCalledWith(['node-1', 'settings']); }); - it('should hide loader when delete components tree fails', () => { + it('should delete the fork and refresh duplicates on success', () => { setup(); - (store.dispatch as Mock).mockImplementation((action: unknown) => { - if (action instanceof GetResourceWithChildren) { - return throwError(() => new Error('failed')); - } - return of(true); - }); + (store.dispatch as Mock).mockClear(); - component.handleMenuAction(DuplicateMenuAction.Delete, 'fork-1'); + component.handleMenuAction(NodeMenuAction.Delete, 'fork-1'); + deleteResourceService.deleteProject.mock.calls[0][0].onSuccess(); - expect(loaderService.show).toHaveBeenCalled(); - expect(loaderService.hide).toHaveBeenCalled(); - expect(mockCustomDialogService.open).not.toHaveBeenCalled(); + expect(deleteResourceService.deleteProject).toHaveBeenCalledWith( + expect.objectContaining({ + rootParentId: 'fork-1', + resourceId: 'fork-1', + }) + ); + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 1, 10)); }); it('should confirm fork for the current project', () => { @@ -275,18 +244,12 @@ describe('ViewDuplicatesComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new ForkRegistry('rid')); }); - it('should update currentPage when page is defined', () => { - setup(); - const event: PaginatorState = { page: 1 } as PaginatorState; - component.onPageChange(event); - expect(component.currentPage()).toBe(2); - }); - it('should not update currentPage when page is undefined', () => { setup(); component.currentPage.set(5); - const event: PaginatorState = { page: undefined } as PaginatorState; - component.onPageChange(event); + + component.onPageChange({ page: undefined }); + expect(component.currentPage()).toBe(5); }); @@ -294,9 +257,10 @@ describe('ViewDuplicatesComponent', () => { setup(); (store.dispatch as Mock).mockClear(); - component.onPageChange({ page: 2 } as PaginatorState); + component.onPageChange({ page: 2 }); fixture.detectChanges(); + expect(component.currentPage()).toBe(3); expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 3, 10)); }); }); 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 index 30f527e6f..d44a7195d 100644 --- 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 @@ -4,6 +4,8 @@ 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 { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { MOCK_NODE_WITH_ADMIN, MOCK_NODE_WITHOUT_ADMIN } from '@testing/mocks/node.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; @@ -16,7 +18,10 @@ describe('ComponentCardComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ComponentCardComponent, ...MockComponents(IconComponent, ContributorsListComponent)], + imports: [ + ComponentCardComponent, + ...MockComponents(IconComponent, ContributorsListComponent, TruncatedTextComponent), + ], providers: [provideOSFCore()], }); @@ -27,53 +32,54 @@ describe('ComponentCardComponent', () => { fixture.detectChanges(); }); - it('should emit navigate when handleNavigate is called', () => { + it('should emit navigate when the title is clicked', () => { const emitSpy = vi.spyOn(component.navigate, 'emit'); - component.handleNavigate('test-id'); - expect(emitSpy).toHaveBeenCalledWith('test-id'); + + fixture.nativeElement.querySelector('.node-title').click(); + + expect(emitSpy).toHaveBeenCalledWith(MOCK_NODE_WITH_ADMIN.id); }); - it('should emit menuAction when handleMenuAction is called', () => { + it('should emit menuAction when a menu action is selected', () => { const emitSpy = vi.spyOn(component.menuAction, 'emit'); - component.handleMenuAction('settings'); - expect(emitSpy).toHaveBeenCalledWith('settings'); + + component.handleMenuAction(NodeMenuAction.Settings); + + expect(emitSpy).toHaveBeenCalledWith(NodeMenuAction.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 hide the menu when the user is not a contributor', () => { + fixture.componentRef.setInput('component', { ...MOCK_NODE_WITH_ADMIN, currentUserIsContributor: false }); + fixture.detectChanges(); - 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'); - }); + expect(fixture.nativeElement.querySelector('p-button')).toBeNull(); + }); - 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 show contributors and settings actions without delete when the user is not an admin', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITHOUT_ADMIN); + fixture.detectChanges(); - 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); - }); + expect(component.componentActionItems().map((item) => item.action)).toEqual([ + NodeMenuAction.ManageContributors, + NodeMenuAction.Settings, + ]); + }); + + it('should include delete when the user is an admin', () => { + expect(component.componentActionItems().map((item) => item.action)).toEqual([ + NodeMenuAction.ManageContributors, + NodeMenuAction.Settings, + NodeMenuAction.Delete, + ]); + }); + + it('should hide delete when hideDeleteAction is true', () => { + fixture.componentRef.setInput('hideDeleteAction', true); + fixture.detectChanges(); + + expect(component.componentActionItems().map((item) => item.action)).toEqual([ + NodeMenuAction.ManageContributors, + NodeMenuAction.Settings, + ]); }); }); 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 0b84dfb28..d0d4eacdf 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 @@ -8,41 +8,43 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; -import { UserSelectors } from '@osf/core/store/user/user.selectors'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { UserSelectors } from '@osf/core/store/user'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.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 { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { + DeleteResourceServiceMock, + DeleteResourceServiceMockType, +} from '@testing/providers/delete-resource-provider.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; -import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; -import { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; +import { GetComponents, 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'; import { OverviewComponentsComponent } from './overview-components.component'; -interface SetupOverrides extends BaseSetupOverrides { - selectors?: any[]; -} - describe('OverviewComponentsComponent', () => { let component: OverviewComponentsComponent; let fixture: ComponentFixture; let store: Store; let routerMock: RouterMockType; let customDialogService: ReturnType; - let loaderService: LoaderServiceMock; let toastService: ToastServiceMockType; + let deleteResourceService: DeleteResourceServiceMockType; const componentA = { id: 'comp-a', title: 'Component A' } as NodeModel; const componentB = { id: 'comp-b', title: 'Component B' } as NodeModel; @@ -53,21 +55,20 @@ describe('OverviewComponentsComponent', () => { rootParentId: 'root-1', }; - function setup(overrides: SetupOverrides = {}) { + const defaultSignals: SignalOverride[] = [ + { selector: ProjectOverviewSelectors.getComponents, value: components }, + { selector: ProjectOverviewSelectors.getComponentsLoading, value: false }, + { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, + { selector: ProjectOverviewSelectors.hasMoreComponents, value: true }, + { selector: ProjectOverviewSelectors.getProject, value: project }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, + ]; + + function setup(overrides: BaseSetupOverrides = {}) { routerMock = RouterMockBuilder.create().build(); customDialogService = CustomDialogServiceMockBuilder.create().build(); - loaderService = new LoaderServiceMock(); toastService = ToastServiceMock.simple(); - - const defaultSelectors = [ - { selector: ProjectOverviewSelectors.getComponents, value: components }, - { selector: ProjectOverviewSelectors.getComponentsLoading, value: false }, - { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, - { selector: ProjectOverviewSelectors.hasMoreComponents, value: true }, - { selector: ProjectOverviewSelectors.getProject, value: project }, - { selector: UserSelectors.isProjectCreationDisabled, value: false }, - ]; - const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors); + deleteResourceService = DeleteResourceServiceMock.simple(); TestBed.configureTestingModule({ imports: [OverviewComponentsComponent, MockComponent(ComponentCardComponent)], @@ -75,11 +76,9 @@ describe('OverviewComponentsComponent', () => { provideOSFCore(), MockProvider(Router, routerMock), MockProvider(CustomDialogService, customDialogService), - MockProvider(LoaderService, loaderService), MockProvider(ToastService, toastService), - provideMockStore({ - signals, - }), + MockProvider(DeleteResourceService, deleteResourceService), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), ], }); @@ -92,16 +91,19 @@ describe('OverviewComponentsComponent', () => { it('should create', () => { setup(); + expect(component).toBeTruthy(); }); it('should initialize reorderedComponents from components selector', () => { setup(); + expect(component.reorderedComponents()).toEqual(components); }); it('should open add component dialog', () => { setup(); + component.handleAddComponent(); expect(customDialogService.open).toHaveBeenCalledWith(AddComponentDialogComponent, { @@ -110,36 +112,53 @@ describe('OverviewComponentsComponent', () => { }); }); + it('should not open add component dialog when project creation is disabled', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + }); + + component.handleAddComponent(); + + expect(component.preventComponentCreation()).toBe(true); + expect(component.createComponentTooltip()).toBe('project.overview.components.addComponentDisabled'); + expect(customDialogService.open).not.toHaveBeenCalled(); + }); + it('should navigate for manageContributors action', () => { setup(); - component.handleMenuAction('manageContributors', 'comp-a'); + + component.handleMenuAction(NodeMenuAction.ManageContributors, 'comp-a'); expect(routerMock.navigate).toHaveBeenCalledWith(['comp-a', 'contributors']); }); it('should navigate for settings action', () => { setup(); - component.handleMenuAction('settings', 'comp-a'); + + component.handleMenuAction(NodeMenuAction.Settings, 'comp-a'); expect(routerMock.navigate).toHaveBeenCalledWith(['comp-a', 'settings']); }); - it('should open delete component dialog through delete menu action', () => { + it('should run delete resource flow through delete menu action', () => { setup(); - component.handleMenuAction('delete', 'comp-a'); - - expect(loaderService.show).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(new GetResourceWithChildren('root-1', 'comp-a', ResourceType.Project)); - expect(customDialogService.open).toHaveBeenCalledWith(DeleteComponentDialogComponent, { - header: 'project.overview.dialog.deleteComponent.header', - width: '650px', - data: { componentId: 'comp-a', resourceType: ResourceType.Project }, - }); - expect(loaderService.hide).toHaveBeenCalled(); + (store.dispatch as Mock).mockClear(); + + component.handleMenuAction(NodeMenuAction.Delete, 'comp-a'); + deleteResourceService.deleteComponent.mock.calls[0][0].onSuccess(); + + expect(deleteResourceService.deleteComponent).toHaveBeenCalledWith( + expect.objectContaining({ + rootParentId: 'root-1', + resourceId: 'comp-a', + }) + ); + expect(store.dispatch).toHaveBeenCalledWith(new GetComponents('project-1')); }); it('should open component url in same tab on navigate', () => { setup(); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); vi.spyOn(routerMock, 'createUrlTree').mockReturnValue({} as any); vi.spyOn(routerMock, 'serializeUrl').mockReturnValue('/comp-a'); @@ -182,13 +201,4 @@ describe('OverviewComponentsComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ReorderComponents)); }); - - it('should disable add component button and show tooltip when isProjectCreationDisabled flag is true', () => { - setup({ - selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], - }); - - expect(component.preventComponentCreation()).toBe(true); - expect(component.createComponentTooltip()).toBe('project.overview.components.addComponentDisabled'); - }); }); 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 ab020357e..2a610ef59 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,13 +1,13 @@ import { MockComponents, MockProvider } from 'ng-mocks'; -import { Mock } from 'vitest'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; +import { Router, UrlTree } from '@angular/router'; + +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { ComponentCardComponent } from '../component-card/component-card.component'; @@ -16,20 +16,16 @@ import { OverviewParentProjectComponent } from './overview-parent-project.compon describe('OverviewParentProjectComponent', () => { let component: OverviewParentProjectComponent; let fixture: ComponentFixture; - let createUrlTreeSpy: Mock; - let serializeUrlSpy: Mock; - let navigateSpy: Mock; - - beforeEach(() => { - const mockUrlTree = {} as any; - createUrlTreeSpy = vi.fn().mockReturnValue(mockUrlTree); - serializeUrlSpy = vi.fn().mockReturnValue('/test-id'); - navigateSpy = vi.fn().mockResolvedValue(true); + let routerMock: RouterMockType; - const routerMock = RouterMockBuilder.create().withCreateUrlTree(createUrlTreeSpy).build(); + const mockUrlTree = {} as UrlTree; - routerMock.serializeUrl = serializeUrlSpy; - routerMock.navigate = navigateSpy; + beforeEach(() => { + routerMock = RouterMockBuilder.create() + .withCreateUrlTree(vi.fn().mockReturnValue(mockUrlTree)) + .withSerializeUrl(vi.fn().mockReturnValue('/test-id-1')) + .withNavigate(vi.fn().mockResolvedValue(true)) + .build(); TestBed.configureTestingModule({ imports: [OverviewParentProjectComponent, ...MockComponents(ComponentCardComponent)], @@ -42,57 +38,46 @@ describe('OverviewParentProjectComponent', () => { fixture.detectChanges(); }); - it('should create URL tree with correct path and queryParamsHandling', () => { + it('should open the parent project in the same window', () => { const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); component.navigateToParent(); - expect(createUrlTreeSpy).toHaveBeenCalledWith(['/', MOCK_NODE_WITH_ADMIN.id], { + expect(routerMock.createUrlTree).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 = vi.spyOn(window, 'open').mockImplementation(() => null); - - component.navigateToParent(); - - expect(serializeUrlSpy).toHaveBeenCalledWith(mockUrlTree); - expect(windowOpenSpy).toHaveBeenCalledWith('/test-id', '_self'); + expect(routerMock.serializeUrl).toHaveBeenCalledWith(mockUrlTree); + expect(windowOpenSpy).toHaveBeenCalledWith('/test-id-1', '_self'); windowOpenSpy.mockRestore(); }); it('should navigate to contributors route when action is manageContributors', () => { - component.handleMenuAction('manageContributors'); + component.handleMenuAction(NodeMenuAction.ManageContributors); - expect(navigateSpy).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'contributors']); + expect(routerMock.navigate).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'contributors']); }); it('should navigate to settings route when action is settings', () => { - component.handleMenuAction('settings'); + component.handleMenuAction(NodeMenuAction.Settings); - expect(navigateSpy).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'settings']); + expect(routerMock.navigate).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); + it('should not navigate when the project id is empty', () => { + fixture.componentRef.setInput('project', { ...MOCK_NODE_WITH_ADMIN, id: '' }); fixture.detectChanges(); - component.handleMenuAction('manageContributors'); + component.handleMenuAction(NodeMenuAction.ManageContributors); - expect(navigateSpy).not.toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); }); - it('should not navigate for unknown actions', () => { - navigateSpy.mockClear(); - - component.handleMenuAction('unknownAction'); + it('should show a skeleton while the parent project is loading', () => { + fixture.componentRef.setInput('isLoading', true); + fixture.detectChanges(); - expect(navigateSpy).not.toHaveBeenCalled(); + expect(fixture.nativeElement.querySelector('p-skeleton')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('osf-component-card')).toBeNull(); }); }); diff --git a/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.spec.ts b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.spec.ts new file mode 100644 index 000000000..03a70851b --- /dev/null +++ b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.spec.ts @@ -0,0 +1,137 @@ +import { MockProvider } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; + +import { DeleteResourceDialogData } from '../../models/delete-resource-options.model'; + +import { DeleteResourceDialogComponent } from './delete-resource-dialog.component'; + +interface SetupOverrides extends BaseSetupOverrides { + dialogData?: DeleteResourceDialogData; +} + +describe('DeleteResourceDialogComponent', () => { + let component: DeleteResourceDialogComponent; + let fixture: ComponentFixture; + let dialogRef: DynamicDialogRef; + + const resources: NodeShortInfoModel[] = [ + { + id: 'c1', + title: 'Component 1', + isPublic: true, + permissions: [UserPermissions.Admin], + }, + { + id: 'c2', + title: 'Component 2', + isPublic: true, + permissions: [UserPermissions.Admin, UserPermissions.Write], + }, + ]; + + const dialogData: DeleteResourceDialogData = { + listMessageKey: 'list', + warningMessageKey: 'warning', + confirmationKey: 'confirmation', + noPermissionsMessageKey: 'no-permissions', + singleMessageKey: 'single', + }; + + const defaultSignals: SignalOverride[] = [ + { selector: CurrentResourceSelectors.getResourceWithChildren, value: resources }, + { selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: true }, + ]; + + function setup(overrides: SetupOverrides = {}) { + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [DeleteResourceDialogComponent], + providers: [ + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: overrides.dialogData ?? dialogData }), + provideMockStore({ signals }), + ], + }); + + dialogRef = TestBed.inject(DynamicDialogRef); + fixture = TestBed.createComponent(DeleteResourceDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + it('should create', () => { + setup(); + + expect(component).toBeTruthy(); + }); + + it('should return false for hasAdminAccessForAllComponents when access is denied', () => { + setup({ + selectorOverrides: [{ selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: false }], + }); + + expect(component.hasAdminAccessForAllComponents()).toBe(false); + }); + + it('should show the list when there are multiple resources', () => { + setup(); + + expect(component.showList()).toBe(true); + }); + + it('should show the single message when one resource has a single message key', () => { + setup({ + selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [resources[0]] }], + }); + + expect(component.showList()).toBe(false); + }); + + it('should show the list for one resource when no single message key is set', () => { + setup({ + dialogData: { + listMessageKey: 'list', + warningMessageKey: 'warning', + confirmationKey: 'confirmation', + noPermissionsMessageKey: 'no-permissions', + }, + selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [resources[0]] }], + }); + + expect(component.showList()).toBe(true); + }); + + it('should return true for isInputValid when confirmation matches selected scientist', () => { + setup(); + + component.userInput.set(component.selectedScientist); + + expect(component.isInputValid()).toBe(true); + }); + + it('should close dialog with true on delete', () => { + setup(); + + component.handleDelete(); + + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts index dcfbbcb63..048b17084 100644 --- a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts +++ b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts @@ -3,7 +3,9 @@ import { MockComponent, MockDirective } from 'ng-mocks'; import { Textarea } from 'primeng/textarea'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { UserSelectors } from '@osf/core/store/user'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; @@ -24,7 +26,12 @@ describe('SettingsProjectFormCardComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [SettingsProjectFormCardComponent, MockComponent(TextInputComponent), MockDirective(Textarea)], - providers: [provideOSFCore(), provideMockStore()], + providers: [ + provideOSFCore(), + provideMockStore({ + signals: [{ selector: UserSelectors.isProjectReadOnly, value: false }], + }), + ], }); fixture = TestBed.createComponent(SettingsProjectFormCardComponent); @@ -79,13 +86,18 @@ describe('SettingsProjectFormCardComponent', () => { }); it('should emit deleteProject when delete button is clicked', () => { - vi.spyOn(component.deleteProject, 'emit'); + const emitSpy = vi.spyOn(component.deleteProject, 'emit'); fixture.componentRef.setInput('projectDetails', mockNodeDetails); + fixture.componentRef.setInput('canDelete', true); fixture.detectChanges(); - component.deleteProject.emit(); + const deleteButton = fixture.debugElement + .queryAll(By.css('p-button')) + .find((element) => element.nativeElement.textContent?.includes('myProjects.settings.deleteProject')); - expect(component.deleteProject.emit).toHaveBeenCalled(); + deleteButton?.triggerEventHandler('onClick'); + + expect(emitSpy).toHaveBeenCalled(); }); it('should reset form to original values', () => { diff --git a/src/app/features/project/settings/settings.component.spec.ts b/src/app/features/project/settings/settings.component.spec.ts index 96afbff7f..448838e37 100644 --- a/src/app/features/project/settings/settings.component.spec.ts +++ b/src/app/features/project/settings/settings.component.spec.ts @@ -1,23 +1,47 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { Mock } from 'vitest'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { SubscriptionEvent } from '@osf/shared/enums/subscriptions/subscription-event.enum'; +import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { ViewOnlyLinkSelectors } from '@osf/shared/stores/view-only-links'; +import { GetResource } from '@osf/shared/stores/current-resource'; +import { DeleteViewOnlyLink, FetchViewOnlyLinks, ViewOnlyLinkSelectors } from '@osf/shared/stores/view-only-links'; +import { MOCK_NODE_DETAILS } from '@testing/mocks/node-details.mock'; import { MOCK_VIEW_ONLY_LINK } from '@testing/mocks/view-only-link.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { + DeleteResourceServiceMock, + DeleteResourceServiceMockType, +} from '@testing/providers/delete-resource-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; +import { DeleteResourceService } from './services/delete-resource.service'; import { ProjectSettingNotificationsComponent, SettingsAccessRequestsCardComponent, @@ -27,20 +51,35 @@ import { SettingsViewOnlyLinksCardComponent, SettingsWikiCardComponent, } from './components'; +import { NodeDetailsModel, ProjectSettingsDataJsonApi } from './models'; import { SettingsComponent } from './settings.component'; -import { SettingsSelectors } from './store'; +import { + DeleteInstitution, + GetProjectDetails, + GetProjectNotificationSubscriptions, + GetProjectSettings, + SettingsSelectors, + UpdateProjectDetails, + UpdateProjectNotificationSubscription, + UpdateProjectSettings, +} from './store'; -describe.skip('SettingsComponent', () => { +describe('SettingsComponent', () => { let component: SettingsComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; - let toastServiceMock: ReturnType; - let customConfirmationServiceMock: ReturnType; - let mockLoaderService: LoaderService; - - const mockProjectId = 'test-project-123'; - const mockSettings = { + let store: Store; + let routerMock: RouterMockType; + let toastService: ToastServiceMockType; + let customConfirmationService: CustomConfirmationServiceMockType; + let deleteResourceService: DeleteResourceServiceMockType; + + const projectId = 'test-project-123'; + const projectDetails: NodeDetailsModel = { + ...MOCK_NODE_DETAILS, + id: projectId, + rootId: 'root-1', + }; + const settings = { attributes: { accessRequestsEnabled: true, wikiEnabled: false, @@ -48,23 +87,30 @@ describe.skip('SettingsComponent', () => { anyoneCanComment: true, }, }; - const mockProjectDetails = { - id: mockProjectId, - title: 'Test Project', - description: 'Test Description', - isPublic: true, - region: { name: 'US East' }, - affiliatedInstitutions: [], - }; - beforeEach(() => { - activatedRouteMock = ActivatedRouteMockBuilder.create().withParams({ id: mockProjectId }).build(); + const defaultSignals: SignalOverride[] = [ + { selector: SettingsSelectors.getSettings, value: settings }, + { selector: SettingsSelectors.getNotificationSubscriptions, value: [] }, + { selector: SettingsSelectors.areNotificationsLoading, value: false }, + { selector: SettingsSelectors.getProjectDetails, value: projectDetails }, + { selector: SettingsSelectors.areProjectDetailsLoading, value: false }, + { selector: SettingsSelectors.hasAdminAccess, value: true }, + { selector: SettingsSelectors.hasWriteAccess, value: true }, + { selector: ViewOnlyLinkSelectors.getViewOnlyLinks, value: [] }, + { selector: ViewOnlyLinkSelectors.isViewOnlyLinksLoading, value: false }, + ]; - routerMock = RouterMockBuilder.create().build(); + function setup(overrides: BaseSetupOverrides = {}) { + const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: projectId }); - toastServiceMock = ToastServiceMockBuilder.create().build(); + if (overrides.hasParent === false) { + routeBuilder.withNoParent(); + } - customConfirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); + routerMock = RouterMockBuilder.create().build(); + toastService = ToastServiceMock.simple(); + customConfirmationService = CustomConfirmationServiceMock.simple(); + deleteResourceService = DeleteResourceServiceMock.simple(); TestBed.configureTestingModule({ imports: [ @@ -83,98 +129,183 @@ describe.skip('SettingsComponent', () => { ], providers: [ provideOSFCore(), - MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(ActivatedRoute, routeBuilder.build()), MockProvider(Router, routerMock), - MockProvider(CustomConfirmationService, customConfirmationServiceMock), - MockProvider(LoaderService, mockLoaderService), - MockProvider(ToastService, toastServiceMock), - provideMockStore({ - signals: [ - { selector: SettingsSelectors.getSettings, value: mockSettings }, - { selector: SettingsSelectors.getNotificationSubscriptions, value: [] }, - { selector: SettingsSelectors.areNotificationsLoading, value: false }, - { selector: SettingsSelectors.getProjectDetails, value: mockProjectDetails }, - { selector: SettingsSelectors.areProjectDetailsLoading, value: false }, - { selector: ViewOnlyLinkSelectors.getViewOnlyLinks, value: [] }, - { selector: ViewOnlyLinkSelectors.isViewOnlyLinksLoading, value: false }, - ], - }), + MockProvider(CustomConfirmationService, customConfirmationService), + MockProvider(DeleteResourceService, deleteResourceService), + MockProvider(LoaderService, new LoaderServiceMock()), + MockProvider(ToastService, toastService), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(SettingsComponent); component = fixture.componentInstance; - - mockLoaderService = TestBed.inject(LoaderService); - }); + fixture.detectChanges(); + } it('should create', () => { - fixture.detectChanges(); + setup(); + expect(component).toBeTruthy(); }); - it('should initialize with project ID from route', () => { - fixture.detectChanges(); - expect(component.projectId()).toBe(mockProjectId); + it('should load project settings from the parent route', () => { + setup(); + + expect(component.projectId()).toBe(projectId); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectSettings(projectId)); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectNotificationSubscriptions(projectId)); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectDetails(projectId)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchViewOnlyLinks(projectId, ResourceType.Project)); }); - it('should not call actions on ngOnInit when project ID is undefined', () => { - expect(typeof component.ngOnInit).toBe('function'); + it('should not load project settings when the route has no parent', () => { + setup({ hasParent: false }); + + expect(component.projectId()).toBeUndefined(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetProjectSettings)); }); - it('should handle access request change', () => { - component.onAccessRequestChange(true); + it('should apply saved settings', () => { + setup(); + expect(component.accessRequest()).toBe(true); + expect(component.wikiEnabled()).toBe(false); + expect(component.anyoneCanEditWiki()).toBe(false); }); - it('should handle wiki request change', () => { + it('should update access requests', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.onAccessRequestChange(false); + + expect(component.accessRequest()).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateProjectSettings({ + id: projectId, + type: 'node-settings', + attributes: { access_requests_enabled: false }, + } as ProjectSettingsDataJsonApi) + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('myProjects.settings.updateProjectSettingsMessage'); + }); + + it('should refresh the current resource when wiki access changes', () => { + setup(); + (store.dispatch as Mock).mockClear(); + component.onWikiRequestChange(true); - expect(component.wikiEnabled()).toBe(true); + + expect(store.dispatch).toHaveBeenCalledWith(new GetResource(projectId, true)); }); - it('should handle anyone can edit wiki request change', () => { - component.onAnyoneCanEditWikiRequestChange(true); - expect(component.anyoneCanEditWiki()).toBe(true); + it('should not update project details when they are unchanged', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.submitForm({ title: projectDetails.title, description: projectDetails.description }); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should handle delete link item', () => { - const mockLink = MOCK_VIEW_ONLY_LINK; + it('should update project details when they change', () => { + setup(); + (store.dispatch as Mock).mockClear(); - component.deleteLinkItem(mockLink); + component.submitForm({ title: 'New Title', description: 'New Description' }); - expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalledWith({ - headerKey: 'myProjects.settings.delete.title', - headerParams: { name: mockLink.name }, - messageKey: 'myProjects.settings.delete.message', - onConfirm: expect.any(Function), - }); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateProjectDetails({ + data: { + type: 'nodes', + id: projectId, + attributes: { title: 'New Title', description: 'New Description' }, + }, + } as UpdateProjectDetails['payload']) + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('myProjects.settings.updateProjectDetailsMessage'); + }); + + it('should delete a view-only link after confirmation', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.deleteLinkItem(MOCK_VIEW_ONLY_LINK); + customConfirmationService.confirmDelete.mock.calls[0][0].onConfirm(); + + expect(customConfirmationService.confirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ + headerKey: 'myProjects.settings.delete.title', + headerParams: { name: MOCK_VIEW_ONLY_LINK.name }, + messageKey: 'myProjects.settings.delete.message', + }) + ); + expect(store.dispatch).toHaveBeenCalledWith( + new DeleteViewOnlyLink(projectId, ResourceType.Project, MOCK_VIEW_ONLY_LINK.id) + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('myProjects.settings.viewOnlyLinkDeleted'); }); - it('should handle delete project', () => { - expect(() => component.deleteProject()).not.toThrow(); - expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalled(); + it('should delete the project and navigate home on success', () => { + setup(); + + component.deleteProject(); + deleteResourceService.deleteProject.mock.calls[0][0].onSuccess(); + + expect(deleteResourceService.deleteProject).toHaveBeenCalledWith( + expect.objectContaining({ + rootParentId: 'root-1', + resourceId: projectId, + }) + ); + expect(routerMock.navigate).toHaveBeenCalledWith(['/']); }); - it('should submit form when project details change', () => { - const mockFormData = { title: 'New Title', description: 'New Description' }; - expect(() => component.submitForm(mockFormData)).not.toThrow(); + it('should not delete the project when the route has no parent', () => { + setup({ hasParent: false }); + + component.deleteProject(); + + expect(deleteResourceService.deleteProject).not.toHaveBeenCalled(); }); - it('should not submit form when project details are unchanged', () => { - const mockFormData = { title: 'Same Title', description: 'Same Description' }; + it('should update a notification subscription', () => { + setup(); + (store.dispatch as Mock).mockClear(); - Object.defineProperty(component, 'projectDetails', { - value: () => ({ title: 'Same Title', description: 'Same Description' }), - writable: true, + component.onNotificationRequestChange({ + event: SubscriptionEvent.FileUpdated, + frequency: SubscriptionFrequency.Daily, }); - expect(() => component.submitForm(mockFormData)).not.toThrow(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateProjectNotificationSubscription({ + id: `${projectId}_${SubscriptionEvent.FileUpdated}`, + frequency: SubscriptionFrequency.Daily, + }) + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('myProjects.settings.updateProjectSettingsMessage'); }); - it('should initialize signals with default values', () => { - expect(component.accessRequest()).toBe(false); - expect(component.wikiEnabled()).toBe(false); - expect(component.anyoneCanEditWiki()).toBe(false); - expect(component.anyoneCanComment()).toBe(false); + it('should remove an affiliation after confirmation', () => { + setup(); + (store.dispatch as Mock).mockClear(); + const affiliation = { id: 'inst-1', name: 'Center' } as Institution; + + component.removeAffiliation(affiliation); + customConfirmationService.confirmDelete.mock.calls[0][0].onConfirm(); + + expect(customConfirmationService.confirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ + headerKey: 'project.deleteInstitution.title', + messageKey: 'project.deleteInstitution.message', + messageParams: { name: 'Center' }, + }) + ); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteInstitution('inst-1', projectId)); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.deleteInstitution.success'); }); }); diff --git a/src/app/features/project/settings/settings.component.ts b/src/app/features/project/settings/settings.component.ts index 37f804c42..350f08fa4 100644 --- a/src/app/features/project/settings/settings.component.ts +++ b/src/app/features/project/settings/settings.component.ts @@ -6,10 +6,8 @@ import { map, of } from 'rxjs'; import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { UserSelectors } from '@core/store/user'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -51,8 +49,6 @@ import { imports: [ TranslatePipe, SubHeaderComponent, - FormsModule, - ReactiveFormsModule, SettingsProjectFormCardComponent, SettingsStorageLocationCardComponent, SettingsViewOnlyLinksCardComponent, @@ -77,7 +73,6 @@ export class SettingsComponent implements OnInit { readonly projectId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); - currentUser = select(UserSelectors.getCurrentUser); settings = select(SettingsSelectors.getSettings); notifications = select(SettingsSelectors.getNotificationSubscriptions); areNotificationsLoading = select(SettingsSelectors.areNotificationsLoading); @@ -104,7 +99,6 @@ export class SettingsComponent implements OnInit { accessRequest = signal(false); wikiEnabled = signal(false); anyoneCanEditWiki = signal(false); - anyoneCanComment = signal(false); constructor() { this.setupEffects(); @@ -244,7 +238,6 @@ export class SettingsComponent implements OnInit { this.accessRequest.set(settings.attributes.accessRequestsEnabled); this.wikiEnabled.set(settings.attributes.wikiEnabled); this.anyoneCanEditWiki.set(settings.attributes.anyoneCanEditWiki); - this.anyoneCanComment.set(settings.attributes.anyoneCanComment); } }); diff --git a/src/app/shared/mappers/wiki/wiki.mapper.ts b/src/app/shared/mappers/wiki/wiki.mapper.ts index a522efcec..915dec5e9 100644 --- a/src/app/shared/mappers/wiki/wiki.mapper.ts +++ b/src/app/shared/mappers/wiki/wiki.mapper.ts @@ -35,7 +35,7 @@ export class WikiMapper { return { id: response.id, createdAt: response.attributes.date_created, - createdBy: UserMapper.getUserInfo(response.embeds.user)?.fullName, + createdBy: UserMapper.getUserInfo(response.embeds?.user)?.fullName, }; } diff --git a/src/app/shared/models/wiki/wiki-json-api.model.ts b/src/app/shared/models/wiki/wiki-json-api.model.ts index 19950715b..c89ff401c 100644 --- a/src/app/shared/models/wiki/wiki-json-api.model.ts +++ b/src/app/shared/models/wiki/wiki-json-api.model.ts @@ -6,7 +6,6 @@ import { ItemResponse, ListResponse } from '../common/json-api/responses.model'; export type WikiResponseJsonApi = ListResponse; export type WikiItemResponseJsonApi = ItemResponse; export type WikiVersionResponseJsonApi = ListResponse; -export type WikiVersionItemResponseJsonApi = ItemResponse; export interface WikiDataJsonApi extends JsonApiResource<'wikis', WikiAttributesJsonApi> { links: WikiLinksJsonApi; diff --git a/src/app/shared/services/wiki.service.ts b/src/app/shared/services/wiki.service.ts index 4bd4997b1..dfb1ef4c5 100644 --- a/src/app/shared/services/wiki.service.ts +++ b/src/app/shared/services/wiki.service.ts @@ -15,7 +15,6 @@ import { WikiDataJsonApi, WikiItemResponseJsonApi, WikiResponseJsonApi, - WikiVersionItemResponseJsonApi, WikiVersionResponseJsonApi, } from '../models/wiki/wiki-json-api.model'; import { ComponentWiki } from '../stores/wiki'; @@ -133,8 +132,8 @@ export class WikiService { const body = { data: { type: 'wiki-versions', attributes: { content } } }; return this.jsonApiService - .post(`${this.apiUrl}/wikis/${wikiId}/versions/`, body) - .pipe(map((response) => WikiMapper.fromGetWikiVersionResponse(response.data))); + .post(`${this.apiUrl}/wikis/${wikiId}/versions/`, body) + .pipe(map((response) => WikiMapper.fromCreateWikiVersionResponse(response.data))); } getWikiVersionContent(wikiId: string, versionId: string): Observable { diff --git a/src/testing/providers/delete-resource-provider.mock.ts b/src/testing/providers/delete-resource-provider.mock.ts new file mode 100644 index 000000000..8e1f1eecc --- /dev/null +++ b/src/testing/providers/delete-resource-provider.mock.ts @@ -0,0 +1,20 @@ +import { Mock } from 'vitest'; + +import { DeleteResourceBaseOptions } from '@osf/features/project/settings/models/delete-resource-options.model'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; + +type DeleteResourceFn = (options: DeleteResourceBaseOptions) => void; + +export type DeleteResourceServiceMockType = Partial & { + deleteComponent: Mock; + deleteProject: Mock; +}; + +export const DeleteResourceServiceMock = { + simple(): DeleteResourceServiceMockType { + return { + deleteComponent: vi.fn(), + deleteProject: vi.fn(), + }; + }, +}; From 865afe32ba4f7c8118770cefca2806b560b2ac18 Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 19 Oct 2026 15:09:44 +0300 Subject: [PATCH 4/5] test(delete-resource): added unit tests for service --- .../services/delete-resource.service.spec.ts | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/app/features/project/settings/services/delete-resource.service.spec.ts diff --git a/src/app/features/project/settings/services/delete-resource.service.spec.ts b/src/app/features/project/settings/services/delete-resource.service.spec.ts new file mode 100644 index 000000000..fb55e51c5 --- /dev/null +++ b/src/app/features/project/settings/services/delete-resource.service.spec.ts @@ -0,0 +1,183 @@ +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { of, Subject, throwError } from 'rxjs'; + +import { Mock } from 'vitest'; + +import { DestroyRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.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 { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; + +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { DeleteResourceDialogComponent } from '../components/delete-resource-dialog/delete-resource-dialog.component'; +import { DeleteProject } from '../store/settings.actions'; + +import { DeleteResourceService } from './delete-resource.service'; + +describe('DeleteResourceService', () => { + let service: DeleteResourceService; + let store: Store; + let customDialogService: CustomDialogServiceMockType; + let loaderService: LoaderServiceMock; + let toastService: ToastServiceMockType; + let destroyRef: DestroyRef; + let onClose$: Subject; + let onSuccess: Mock<() => void>; + + const resources: NodeShortInfoModel[] = [ + { + id: 'resource-1', + title: 'Resource 1', + isPublic: true, + permissions: [UserPermissions.Admin], + }, + ]; + + const baseOptions = { + rootParentId: 'root-1', + resourceId: 'resource-1', + }; + + function setup() { + customDialogService = CustomDialogServiceMock.simple(); + loaderService = new LoaderServiceMock(); + toastService = ToastServiceMock.simple(); + destroyRef = { onDestroy: vi.fn() } as unknown as DestroyRef; + onClose$ = new Subject(); + onSuccess = vi.fn<() => void>(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + TestBed.configureTestingModule({ + providers: [ + DeleteResourceService, + MockProvider(CustomDialogService, customDialogService), + MockProvider(LoaderService, loaderService), + MockProvider(ToastService, toastService), + provideMockStore({ + signals: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: resources }], + }), + ], + }); + + service = TestBed.inject(DeleteResourceService); + store = TestBed.inject(Store); + } + + it('should create', () => { + setup(); + expect(service).toBeTruthy(); + }); + + it('deleteComponent should load children, open component dialog, delete, toast, and call onSuccess', () => { + setup(); + + service.deleteComponent({ ...baseOptions, destroyRef, onSuccess }); + + expect(store.dispatch).toHaveBeenCalledWith( + new GetResourceWithChildren('root-1', 'resource-1', ResourceType.Project) + ); + expect(loaderService.show).toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + expect(customDialogService.open).toHaveBeenCalledWith(DeleteResourceDialogComponent, { + header: 'project.overview.dialog.deleteComponent.header', + width: '500px', + data: { + listMessageKey: 'project.overview.dialog.deleteComponent.listMessage', + warningMessageKey: 'project.overview.dialog.deleteComponent.warningMessage', + confirmationKey: 'project.overview.dialog.deleteComponent.confirmation', + noPermissionsMessageKey: 'project.overview.dialog.deleteComponent.noPermissionsMessage', + singleMessageKey: 'project.overview.dialog.deleteComponent.message', + }, + }); + + (store.dispatch as Mock).mockClear(); + loaderService.show.mockClear(); + loaderService.hide.mockClear(); + + onClose$.next(true); + + expect(loaderService.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteProject(resources)); + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.deleteComponent.success'); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('deleteProject should open project dialog and show project success toast', () => { + setup(); + + service.deleteProject({ ...baseOptions, destroyRef, onSuccess }); + + expect(customDialogService.open).toHaveBeenCalledWith(DeleteResourceDialogComponent, { + header: 'project.deleteProject.dialog.deleteProject', + width: '500px', + data: { + listMessageKey: 'project.deleteProject.dialog.listMessage', + warningMessageKey: 'project.deleteProject.dialog.warningMessage', + confirmationKey: 'project.deleteProject.dialog.confirmation', + noPermissionsMessageKey: 'project.deleteProject.dialog.noPermissionsMessage', + }, + }); + + onClose$.next(true); + + expect(toastService.showSuccess).toHaveBeenCalledWith('project.deleteProject.success'); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('should not delete when dialog is dismissed', () => { + setup(); + + service.deleteProject({ ...baseOptions, destroyRef, onSuccess }); + (store.dispatch as Mock).mockClear(); + + onClose$.next(false); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('should hide loader and skip success handlers when delete fails', () => { + setup(); + (store.dispatch as Mock).mockImplementation((action: unknown) => { + if (action instanceof DeleteProject) { + return throwError(() => new Error('delete failed')); + } + return of(true); + }); + + service.deleteProject({ ...baseOptions, destroyRef, onSuccess }); + loaderService.hide.mockClear(); + + onClose$.next(true); + + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('should skip dialog when loading children fails', () => { + setup(); + (store.dispatch as Mock).mockReturnValue(throwError(() => new Error('load failed'))); + + service.deleteProject({ ...baseOptions, destroyRef, onSuccess }); + + expect(customDialogService.open).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); +}); From 543a4aed7799b6aa6f20aa092a55b581dc278bdb Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 20 Oct 2026 15:38:39 +0300 Subject: [PATCH 5/5] fix(project): clean up and update tests --- .../overview-components.component.scss | 4 + .../overview-components.component.spec.ts | 49 +++- .../overview-components.component.ts | 8 +- .../project/settings/services/index.ts | 1 - .../services/settings.service.spec.ts | 239 ++++++++++++++++++ .../settings/settings.component.spec.ts | 1 - .../project/settings/store/settings.state.ts | 2 +- 7 files changed, 295 insertions(+), 9 deletions(-) delete mode 100644 src/app/features/project/settings/services/index.ts create mode 100644 src/app/features/project/settings/services/settings.service.spec.ts 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 a7950ea0b..702a1c223 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 @@ -6,6 +6,10 @@ .component-drag-item { cursor: move; + &.cdk-drag-disabled { + cursor: default; + } + &.cdk-drag-preview { background-color: var(--white); border-radius: 0.6rem; 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 d0d4eacdf..b0cd3c4ef 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 @@ -2,11 +2,13 @@ import { Store } from '@ngxs/store'; import { MockComponent, MockProvider } from 'ng-mocks'; +import { throwError } from 'rxjs'; + import { Mock } from 'vitest'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; +import { Router, UrlTree } from '@angular/router'; import { UserSelectors } from '@osf/core/store/user'; import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; @@ -46,6 +48,7 @@ describe('OverviewComponentsComponent', () => { let toastService: ToastServiceMockType; let deleteResourceService: DeleteResourceServiceMockType; + const mockUrlTree = {} as UrlTree; const componentA = { id: 'comp-a', title: 'Component A' } as NodeModel; const componentB = { id: 'comp-b', title: 'Component B' } as NodeModel; const components = [componentA, componentB]; @@ -65,7 +68,10 @@ describe('OverviewComponentsComponent', () => { ]; function setup(overrides: BaseSetupOverrides = {}) { - routerMock = RouterMockBuilder.create().build(); + routerMock = RouterMockBuilder.create() + .withCreateUrlTree(vi.fn().mockReturnValue(mockUrlTree)) + .withSerializeUrl(vi.fn().mockReturnValue('/comp-a')) + .build(); customDialogService = CustomDialogServiceMockBuilder.create().build(); toastService = ToastServiceMock.simple(); deleteResourceService = DeleteResourceServiceMock.simple(); @@ -101,6 +107,30 @@ describe('OverviewComponentsComponent', () => { expect(component.reorderedComponents()).toEqual(components); }); + it('should disable drag when user cannot edit', () => { + setup(); + fixture.componentRef.setInput('canEdit', false); + fixture.detectChanges(); + + expect(component.isDragDisabled()).toBe(true); + }); + + it('should disable drag while components are submitting', () => { + setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getComponentsSubmitting, value: true }], + }); + + expect(component.isDragDisabled()).toBe(true); + }); + + it('should disable drag when there is only one component', () => { + setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getComponents, value: [componentA] }], + }); + + expect(component.isDragDisabled()).toBe(true); + }); + it('should open add component dialog', () => { setup(); @@ -160,11 +190,11 @@ describe('OverviewComponentsComponent', () => { setup(); const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); - vi.spyOn(routerMock, 'createUrlTree').mockReturnValue({} as any); - vi.spyOn(routerMock, 'serializeUrl').mockReturnValue('/comp-a'); component.handleComponentNavigate('comp-a'); + expect(routerMock.createUrlTree).toHaveBeenCalledWith(['/', 'comp-a'], { queryParamsHandling: 'preserve' }); + expect(routerMock.serializeUrl).toHaveBeenCalledWith(mockUrlTree); expect(window.open).toHaveBeenCalledWith('/comp-a', '_self'); openSpy.mockRestore(); }); @@ -201,4 +231,15 @@ describe('OverviewComponentsComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ReorderComponents)); }); + + it('should restore previous order when reorder fails', () => { + setup(); + (store.dispatch as Mock).mockReturnValueOnce(throwError(() => new Error('reorder failed'))); + const event = { previousIndex: 0, currentIndex: 1 } as CdkDragDrop; + + component.onReorder(event); + + expect(component.reorderedComponents().map((c) => c.id)).toEqual(['comp-a', 'comp-b']); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + }); }); 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 07c6c1377..9ae653d50 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 @@ -54,7 +54,7 @@ export class OverviewComponentsComponent { readonly reorderedComponents = signal([]); readonly isDragDisabled = computed( - () => this.isComponentsSubmitting() || (!this.canEdit() && this.reorderedComponents().length <= 1) + () => this.isComponentsSubmitting() || !this.canEdit() || this.reorderedComponents().length <= 1 ); readonly createComponentTooltip = computed(() => @@ -110,7 +110,8 @@ export class OverviewComponentsComponent { const project = this.project(); if (!project || !this.canEdit()) return; - const components = [...this.reorderedComponents()]; + const previousComponents = [...this.reorderedComponents()]; + const components = [...previousComponents]; moveItemInArray(components, event.previousIndex, event.currentIndex); this.reorderedComponents.set(components); @@ -119,6 +120,9 @@ export class OverviewComponentsComponent { next: () => { this.toastService.showSuccess('project.overview.dialog.toast.reorderComponents.success'); }, + error: () => { + this.reorderedComponents.set(previousComponents); + }, }); } diff --git a/src/app/features/project/settings/services/index.ts b/src/app/features/project/settings/services/index.ts deleted file mode 100644 index 31cb466fb..000000000 --- a/src/app/features/project/settings/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SettingsService } from './settings.service'; diff --git a/src/app/features/project/settings/services/settings.service.spec.ts b/src/app/features/project/settings/services/settings.service.spec.ts new file mode 100644 index 000000000..6ce7330a4 --- /dev/null +++ b/src/app/features/project/settings/services/settings.service.spec.ts @@ -0,0 +1,239 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum'; +import { NotificationSubscriptionMapper } from '@osf/shared/mappers/notification-subscription.mapper'; +import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; +import { NodeResponseJsonApi, UpdateNodeRequestModel } from '@osf/shared/models/nodes/nodes-json-api.model'; +import { NotificationSubscription } from '@osf/shared/models/notifications/notification-subscription.model'; +import { + NotificationSubscriptionDataJsonApi, + NotificationSubscriptionsListResponseJsonApi, +} from '@osf/shared/models/notifications/notification-subscription-json-api.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { MOCK_NODE_DETAILS } from '@testing/mocks/node-details.mock'; +import { MOCK_NOTIFICATION_SUBSCRIPTIONS } from '@testing/mocks/notification-subscription.mock'; +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { SettingsMapper } from '../mappers'; +import { + NodeDetailsModel, + ProjectSettingsDataJsonApi, + ProjectSettingsModel, + ProjectSettingsResponseJsonApi, +} from '../models'; + +import { SettingsService } from './settings.service'; + +describe('SettingsService', () => { + let service: SettingsService; + let jsonApiService: JsonApiServiceMockType; + + const nodeId = 'node-1'; + const mappedSettings = { + id: nodeId, + attributes: { + accessRequestsEnabled: true, + anyoneCanEditWiki: false, + wikiEnabled: true, + }, + } as ProjectSettingsModel; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [SettingsService, MockProvider(JsonApiService, jsonApiService), provideMockEnvironment()], + }); + + service = TestBed.inject(SettingsService); + } + + it('should expose apiUrl from environment', () => { + setup(); + + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('getProjectSettings should request node settings and map response', async () => { + setup(); + const apiResponse = { + data: { + id: nodeId, + type: 'node-settings', + attributes: { + access_requests_enabled: true, + anyone_can_edit_wiki: false, + wiki_enabled: true, + }, + }, + } as ProjectSettingsResponseJsonApi; + const mapperSpy = vi.spyOn(SettingsMapper, 'fromResponse').mockReturnValue(mappedSettings); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getProjectSettings(nodeId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${nodeId}/settings/`); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse, nodeId); + expect(response).toEqual(mappedSettings); + }); + + it('updateProjectSettings should patch node settings and map response', async () => { + setup(); + const model = { + id: nodeId, + type: 'node-settings', + attributes: { + access_requests_enabled: false, + anyone_can_edit_wiki: true, + wiki_enabled: false, + }, + } as ProjectSettingsDataJsonApi; + const mapperSpy = vi.spyOn(SettingsMapper, 'fromResponse').mockReturnValue(mappedSettings); + jsonApiService.patch.mockReturnValue(of(model)); + + const response = await firstValueFrom(service.updateProjectSettings(model)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${nodeId}/settings/`, { data: model }); + expect(mapperSpy).toHaveBeenCalledWith(model, nodeId); + expect(response).toEqual(mappedSettings); + }); + + it('getNotificationSubscriptions should request filtered subscriptions and map response', async () => { + setup(); + const apiItem = { + id: `${nodeId}_file_updated`, + type: 'subscription', + attributes: { + event_name: 'file_updated', + frequency: SubscriptionFrequency.Daily, + }, + } as NotificationSubscriptionDataJsonApi; + const apiResponse = { data: [apiItem] } as NotificationSubscriptionsListResponseJsonApi; + const mapped = [MOCK_NOTIFICATION_SUBSCRIPTIONS[0]]; + const mapperSpy = vi.spyOn(NotificationSubscriptionMapper, 'fromGetResponse').mockReturnValue(mapped[0]); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getNotificationSubscriptions(nodeId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/subscriptions/`, { + 'filter[id]': `${nodeId}_file_updated`, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiItem); + expect(response).toEqual(mapped); + }); + + it('updateSubscription should patch subscription and map response', async () => { + setup(); + const subscriptionId = `${nodeId}_file_updated`; + const request = NotificationSubscriptionMapper.toUpdateRequest( + subscriptionId, + SubscriptionFrequency.Instant, + false + ); + const apiResponse = { + id: subscriptionId, + type: 'subscription', + attributes: { + event_name: 'file_updated', + frequency: SubscriptionFrequency.Instant, + }, + } as NotificationSubscriptionDataJsonApi; + const mapped = { + id: subscriptionId, + event: MOCK_NOTIFICATION_SUBSCRIPTIONS[0].event, + frequency: SubscriptionFrequency.Instant, + } as NotificationSubscription; + const toRequestSpy = vi.spyOn(NotificationSubscriptionMapper, 'toUpdateRequest').mockReturnValue(request); + const fromResponseSpy = vi.spyOn(NotificationSubscriptionMapper, 'fromGetResponse').mockReturnValue(mapped); + jsonApiService.patch.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.updateSubscription(subscriptionId, SubscriptionFrequency.Instant)); + + expect(toRequestSpy).toHaveBeenCalledWith(subscriptionId, SubscriptionFrequency.Instant, false); + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/subscriptions/${subscriptionId}/`, request); + expect(fromResponseSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mapped); + }); + + it('getProjectById should request node with embeds and map response', async () => { + setup(); + const nodeData = { id: nodeId } as BaseNodeDataJsonApi; + const apiResponse = { data: nodeData } as NodeResponseJsonApi; + const mapperSpy = vi.spyOn(SettingsMapper, 'fromNodeResponse').mockReturnValue(MOCK_NODE_DETAILS); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getProjectById(nodeId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${nodeId}/`, { + 'embed[]': ['affiliated_institutions', 'region'], + }); + expect(mapperSpy).toHaveBeenCalledWith(nodeData); + expect(response).toEqual(MOCK_NODE_DETAILS); + }); + + it('updateProjectById should patch node and map response', async () => { + setup(); + const model = { + data: { + type: 'nodes', + id: nodeId, + attributes: { title: 'New Title', description: 'New Description' }, + }, + } as UpdateNodeRequestModel; + const apiResponse = { id: nodeId } as BaseNodeDataJsonApi; + const mapped = { ...MOCK_NODE_DETAILS, title: 'New Title' } as NodeDetailsModel; + const mapperSpy = vi.spyOn(SettingsMapper, 'fromNodeResponse').mockReturnValue(mapped); + jsonApiService.patch.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.updateProjectById(model)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${nodeId}/`, model); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mapped); + }); + + it('deleteProject should bulk delete nodes', async () => { + setup(); + const projects = [ + { id: 'node-1', title: 'One' }, + { id: 'node-2', title: 'Two' }, + ] as NodeShortInfoModel[]; + jsonApiService.delete.mockReturnValue(of(void 0)); + + await firstValueFrom(service.deleteProject(projects)); + + expect(jsonApiService.delete).toHaveBeenCalledWith( + `${MOCK_API_URL}/nodes/`, + { + data: [ + { id: 'node-1', type: CurrentResourceType.Projects }, + { id: 'node-2', type: CurrentResourceType.Projects }, + ], + }, + { 'Content-Type': 'application/vnd.api+json; ext=bulk' } + ); + }); + + it('deleteInstitution should delete institution node relationship', async () => { + setup(); + const institutionId = 'inst-1'; + jsonApiService.delete.mockReturnValue(of(void 0)); + + await firstValueFrom(service.deleteInstitution(institutionId, nodeId)); + + expect(jsonApiService.delete).toHaveBeenCalledWith( + `${MOCK_API_URL}/institutions/${institutionId}/relationships/nodes/`, + { + data: [{ type: 'nodes', id: nodeId }], + } + ); + }); +}); diff --git a/src/app/features/project/settings/settings.component.spec.ts b/src/app/features/project/settings/settings.component.spec.ts index 448838e37..39a66ba4b 100644 --- a/src/app/features/project/settings/settings.component.spec.ts +++ b/src/app/features/project/settings/settings.component.spec.ts @@ -84,7 +84,6 @@ describe('SettingsComponent', () => { accessRequestsEnabled: true, wikiEnabled: false, anyoneCanEditWiki: false, - anyoneCanComment: true, }, }; diff --git a/src/app/features/project/settings/store/settings.state.ts b/src/app/features/project/settings/store/settings.state.ts index 21b038480..10b6128c4 100644 --- a/src/app/features/project/settings/store/settings.state.ts +++ b/src/app/features/project/settings/store/settings.state.ts @@ -9,7 +9,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { NotificationSubscription } from '@osf/shared/models/notifications/notification-subscription.model'; -import { SettingsService } from '../services'; +import { SettingsService } from '../services/settings.service'; import { DeleteInstitution,