{{ 'common.labels.title' | translate }}
-
{{ draftRegistration()?.title | fixSpecialChar }}
+
{{ draftRegistration()?.title }}
@if (!draftRegistration()?.title) {
{{ 'common.labels.title' | translate }}
{{ 'common.labels.noData' | translate }}
@@ -16,7 +16,7 @@
{{ 'common.labels.title' | translate }}
{{ 'common.labels.description' | translate }}
-
{{ draftRegistration()?.description | fixSpecialChar }}
+
{{ draftRegistration()?.description }}
@if (!draftRegistration()?.description) {
{{ 'common.labels.noData' | translate }}
@@ -120,13 +120,13 @@ {{ section.title }}
[label]="'common.buttons.back' | translate"
severity="info"
class="mr-2"
- (click)="goBack()"
+ (onClick)="goBack()"
>
@@ -135,7 +135,7 @@ {{ section.title }}
data-test-goto-register
[label]="'registries.review.register' | translate"
[disabled]="registerButtonDisabled()"
- (click)="confirmRegistration()"
+ (onClick)="confirmRegistration()"
>
diff --git a/src/app/features/registries/components/review/review.component.spec.ts b/src/app/features/registries/components/review/review.component.spec.ts
index 510605975..1771c7971 100644
--- a/src/app/features/registries/components/review/review.component.spec.ts
+++ b/src/app/features/registries/components/review/review.component.spec.ts
@@ -1,119 +1,435 @@
+import { Store } from '@ngxs/store';
+
import { MockComponents, MockProvider } from 'ng-mocks';
-import { of } from 'rxjs';
+import { Subject } from 'rxjs';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
-import { RegistriesSelectors } from '@osf/features/registries/store';
+import {
+ ClearState,
+ DeleteDraft,
+ FetchLicenses,
+ FetchProjectChildren,
+ RegistriesSelectors,
+} from '@osf/features/registries/store';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component';
import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component';
-import { FieldType } from '@osf/shared/enums/field-type.enum';
+import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
import { ToastService } from '@osf/shared/services/toast.service';
-import { ContributorsSelectors } from '@osf/shared/stores/contributors';
-import { SubjectsSelectors } from '@osf/shared/stores/subjects';
+import {
+ ContributorsSelectors,
+ GetAllContributors,
+ LoadMoreContributors,
+ ResetContributorsState,
+} from '@osf/shared/stores/contributors';
+import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects';
import { ReviewComponent } from './review.component';
-import { OSFTestingModule } from '@testing/osf.testing.module';
-import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock';
-import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
+import { provideOSFCore } from '@testing/osf.testing.provider';
+import {
+ CustomConfirmationServiceMock,
+ CustomConfirmationServiceMockType,
+} from '@testing/providers/custom-confirmation-provider.mock';
+import {
+ CustomDialogServiceMockBuilder,
+ CustomDialogServiceMockType,
+} from '@testing/providers/custom-dialog-provider.mock';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
-import { RouterMockBuilder } from '@testing/providers/router-provider.mock';
+import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
import { provideMockStore } from '@testing/providers/store-provider.mock';
-import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock';
+import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock';
+
+const DEFAULT_DRAFT = {
+ id: 'draft-1',
+ providerId: 'prov-1',
+ currentUserPermissions: [],
+ hasProject: false,
+ license: { options: {} },
+ branchedFrom: { id: 'proj-1', type: 'nodes' },
+};
+
+function createDefaultSignals(overrides: { selector: any; value: any }[] = []) {
+ const defaults = [
+ { selector: RegistriesSelectors.getPagesSchema, value: [] },
+ { selector: RegistriesSelectors.getDraftRegistration, value: DEFAULT_DRAFT },
+ { selector: RegistriesSelectors.isDraftSubmitting, value: false },
+ { selector: RegistriesSelectors.isDraftLoading, value: false },
+ { selector: RegistriesSelectors.getStepsData, value: {} },
+ { selector: RegistriesSelectors.getRegistrationComponents, value: [] },
+ { selector: RegistriesSelectors.getRegistrationLicense, value: null },
+ { selector: RegistriesSelectors.getRegistration, value: { id: 'new-reg-1' } },
+ { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } },
+ { selector: RegistriesSelectors.hasDraftAdminAccess, value: true },
+ { selector: ContributorsSelectors.getContributors, value: [] },
+ { selector: ContributorsSelectors.isContributorsLoading, value: false },
+ { selector: ContributorsSelectors.hasMoreContributors, value: false },
+ { selector: SubjectsSelectors.getSelectedSubjects, value: [] },
+ ];
+
+ return overrides.length
+ ? defaults.map((s) => {
+ const override = overrides.find((o) => o.selector === s.selector);
+ return override ? { ...s, value: override.value } : s;
+ })
+ : defaults;
+}
+
+function setup(
+ opts: {
+ selectorOverrides?: { selector: any; value: any }[];
+ dialogCloseSubject?: Subject
;
+ } = {}
+) {
+ const mockRouter = RouterMockBuilder.create().withUrl('/registries/123/review').build();
+ const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build();
+
+ const dialogClose$ = opts.dialogCloseSubject ?? new Subject();
+ const mockDialog = CustomDialogServiceMockBuilder.create()
+ .withOpen(
+ jest.fn().mockReturnValue({
+ onClose: dialogClose$.pipe(),
+ close: jest.fn(),
+ })
+ )
+ .build();
+
+ const mockToast = ToastServiceMock.simple();
+ const mockConfirmation = CustomConfirmationServiceMock.simple();
+
+ TestBed.configureTestingModule({
+ imports: [
+ ReviewComponent,
+ ...MockComponents(RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent),
+ ],
+ providers: [
+ provideOSFCore(),
+ MockProvider(ActivatedRoute, mockActivatedRoute),
+ MockProvider(Router, mockRouter),
+ MockProvider(CustomDialogService, mockDialog),
+ MockProvider(CustomConfirmationService, mockConfirmation),
+ MockProvider(ToastService, mockToast),
+ provideMockStore({ signals: createDefaultSignals(opts.selectorOverrides) }),
+ ],
+ });
+
+ const store = TestBed.inject(Store);
+ const fixture = TestBed.createComponent(ReviewComponent);
+ const component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ return { fixture, component, store, mockRouter, mockDialog, mockToast, mockConfirmation, dialogClose$ };
+}
describe('ReviewComponent', () => {
let component: ReviewComponent;
- let fixture: ComponentFixture;
- let mockRouter: ReturnType;
- let mockActivatedRoute: ReturnType;
- let mockDialog: ReturnType;
- let mockConfirm: ReturnType;
- let mockToast: ReturnType;
-
- beforeEach(async () => {
- mockRouter = RouterMockBuilder.create().withUrl('/registries/123/review').build();
- mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build();
-
- mockDialog = CustomDialogServiceMockBuilder.create().withDefaultOpen().build();
- mockConfirm = CustomConfirmationServiceMockBuilder.create()
- .withConfirmDelete(jest.fn((opts) => opts.onConfirm && opts.onConfirm()))
- .build();
- mockToast = ToastServiceMockBuilder.create().build();
-
- await TestBed.configureTestingModule({
- imports: [
- ReviewComponent,
- OSFTestingModule,
- ...MockComponents(RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent),
- ],
- providers: [
- MockProvider(Router, mockRouter),
- MockProvider(ActivatedRoute, mockActivatedRoute),
- MockProvider(CustomDialogService, mockDialog),
- MockProvider(CustomConfirmationService, mockConfirm),
- MockProvider(ToastService, mockToast),
- provideMockStore({
- signals: [
- { selector: RegistriesSelectors.getPagesSchema, value: [] },
- {
- selector: RegistriesSelectors.getDraftRegistration,
- value: { id: 'draft-1', providerId: 'prov-1', currentUserPermissions: [], hasProject: false },
- },
- { selector: RegistriesSelectors.isDraftSubmitting, value: false },
- { selector: RegistriesSelectors.isDraftLoading, value: false },
- { selector: RegistriesSelectors.getStepsData, value: {} },
- { selector: RegistriesSelectors.getRegistrationComponents, value: [] },
- { selector: RegistriesSelectors.getRegistrationLicense, value: null },
- { selector: RegistriesSelectors.getRegistration, value: { id: 'new-reg-1' } },
- { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } },
- { selector: ContributorsSelectors.getContributors, value: [] },
- { selector: SubjectsSelectors.getSelectedSubjects, value: [] },
- ],
- }),
- ],
- }).compileComponents();
+ let store: Store;
+ let mockRouter: RouterMockType;
+ let mockDialog: CustomDialogServiceMockType;
+ let mockToast: ToastServiceMockType;
+ let mockConfirmation: CustomConfirmationServiceMockType;
+ let dialogClose$: Subject;
- fixture = TestBed.createComponent(ReviewComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ beforeEach(() => {
+ const result = setup();
+ component = result.component;
+ store = result.store;
+ mockRouter = result.mockRouter;
+ mockDialog = result.mockDialog;
+ mockToast = result.mockToast;
+ mockConfirmation = result.mockConfirmation;
+ dialogClose$ = result.dialogClose$;
});
it('should create', () => {
expect(component).toBeTruthy();
- expect(component.FieldType).toBe(FieldType);
});
- it('should navigate back to previous step', () => {
- const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate');
+ it('should dispatch getContributors, getSubjects and fetchLicenses on init', () => {
+ expect(store.dispatch).toHaveBeenCalledWith(new GetAllContributors('draft-1', ResourceType.DraftRegistration));
+ expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects('draft-1', ResourceType.DraftRegistration));
+ expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses('prov-1'));
+ });
+
+ it('should navigate to previous step on goBack', () => {
+ const { component: c, mockRouter: router } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.getPagesSchema, value: [{ id: '1' }, { id: '2' }] }],
+ });
+
+ c.goBack();
+
+ expect(router.navigate).toHaveBeenCalledWith(
+ ['../', 2],
+ expect.objectContaining({ relativeTo: expect.anything() })
+ );
+ });
+
+ it('should navigate to step 0 when pages is empty on goBack', () => {
component.goBack();
- expect(navSpy).toHaveBeenCalledWith(['../', 0], { relativeTo: TestBed.inject(ActivatedRoute) });
+
+ expect(mockRouter.navigate).toHaveBeenCalledWith(
+ ['../', 0],
+ expect.objectContaining({ relativeTo: expect.anything() })
+ );
});
- it('should open confirmation dialog when deleting draft and navigate on confirm', () => {
- const navSpy = jest.spyOn(TestBed.inject(Router), 'navigateByUrl');
- (component as any).actions = {
- ...component.actions,
- deleteDraft: jest.fn().mockReturnValue(of({})),
- clearState: jest.fn(),
- };
+ it('should dispatch deleteDraft and navigate on confirm', () => {
+ mockConfirmation.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm());
+ (store.dispatch as jest.Mock).mockClear();
component.deleteDraft();
- expect(mockConfirm.confirmDelete).toHaveBeenCalled();
- expect(navSpy).toHaveBeenCalledWith('/registries/prov-1/new');
+ expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1'));
+ expect(store.dispatch).toHaveBeenCalledWith(new ClearState());
+ expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/prov-1/new');
+ });
+
+ it('should open select components dialog when components exist', () => {
+ const { component: c, mockDialog: dialog } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }],
+ });
+
+ c.confirmRegistration();
+
+ expect(dialog.open).toHaveBeenCalled();
+ const firstCallArgs = (dialog.open as jest.Mock).mock.calls[0];
+ expect(firstCallArgs[1].header).toBe('registries.review.selectComponents.title');
});
- it('should open select components dialog when components exist and chain to confirm', () => {
- (component as any).components = () => ['c1', 'c2'];
- (mockDialog.open as jest.Mock).mockReturnValueOnce({ onClose: of(['c1']) } as any);
+ it('should open confirm registration dialog when no components', () => {
component.confirmRegistration();
expect(mockDialog.open).toHaveBeenCalled();
- expect((mockDialog.open as jest.Mock).mock.calls.length).toBeGreaterThan(1);
+ const firstCallArgs = (mockDialog.open as jest.Mock).mock.calls[0];
+ expect(firstCallArgs[1].header).toBe('registries.review.confirmation.title');
+ });
+
+ it('should show success toast and navigate on successful registration', () => {
+ component.openConfirmRegistrationDialog();
+ dialogClose$.next(true);
+
+ expect(mockToast.showSuccess).toHaveBeenCalledWith('registries.review.confirmation.successMessage');
+ expect(mockRouter.navigate).toHaveBeenCalledWith(['/new-reg-1/overview']);
+ });
+
+ it('should reopen select components dialog when confirm dialog closed with falsy result and components exist', () => {
+ const {
+ component: c,
+ mockDialog: dialog,
+ dialogClose$: close$,
+ } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }],
+ });
+
+ c.openConfirmRegistrationDialog(['comp-1']);
+ close$.next(false);
+
+ expect(dialog.open).toHaveBeenCalledTimes(2);
+ });
+
+ it('should not navigate when confirm dialog closed with falsy result and no components', () => {
+ component.openConfirmRegistrationDialog();
+ dialogClose$.next(false);
+
+ expect(mockRouter.navigate).not.toHaveBeenCalled();
+ });
+
+ it('should pass selected components from select dialog to confirm dialog', () => {
+ const selectClose$ = new Subject();
+ const confirmClose$ = new Subject();
+ let callCount = 0;
+
+ const { component: c, mockDialog: dialog } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }],
+ });
+
+ (dialog.open as jest.Mock).mockImplementation(() => {
+ callCount++;
+ const subj = callCount === 1 ? selectClose$ : confirmClose$;
+ return { onClose: subj.pipe(), close: jest.fn() };
+ });
+
+ c.openSelectComponentsForRegistrationDialog();
+ selectClose$.next(['comp-1']);
+
+ expect(dialog.open).toHaveBeenCalledTimes(2);
+ const secondCallArgs = (dialog.open as jest.Mock).mock.calls[1];
+ expect(secondCallArgs[1].data.components).toEqual(['comp-1']);
+ });
+
+ it('should not open confirm dialog when select components dialog returns falsy', () => {
+ const selectClose$ = new Subject();
+
+ const { component: c, mockDialog: dialog } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }],
+ });
+
+ (dialog.open as jest.Mock).mockReturnValue({
+ onClose: selectClose$.pipe(),
+ close: jest.fn(),
+ });
+
+ c.openSelectComponentsForRegistrationDialog();
+ selectClose$.next(null);
+
+ expect(dialog.open).toHaveBeenCalledTimes(1);
+ });
+
+ it('should dispatch loadMoreContributors', () => {
+ (store.dispatch as jest.Mock).mockClear();
+ component.loadMoreContributors();
+ expect(store.dispatch).toHaveBeenCalledWith(new LoadMoreContributors('draft-1', ResourceType.DraftRegistration));
+ });
+
+ it('should dispatch resetContributorsState on destroy', () => {
+ (store.dispatch as jest.Mock).mockClear();
+ component.ngOnDestroy();
+ expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState());
+ });
+
+ it('should compute isDraftInvalid as false when all steps are valid', () => {
+ expect(component.isDraftInvalid()).toBe(false);
+ });
+
+ it('should compute isDraftInvalid as true when any step is invalid', () => {
+ const { component: c } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: true } } }],
+ });
+ expect(c.isDraftInvalid()).toBe(true);
+ });
+
+ it('should compute registerButtonDisabled as false when valid and has admin access', () => {
+ expect(component.registerButtonDisabled()).toBe(false);
+ });
+
+ it('should compute registerButtonDisabled as true when draft is loading', () => {
+ const { component: c } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.isDraftLoading, value: true }],
+ });
+ expect(c.registerButtonDisabled()).toBe(true);
+ });
+
+ it('should compute registerButtonDisabled as true when draft is invalid', () => {
+ const { component: c } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: true } } }],
+ });
+ expect(c.registerButtonDisabled()).toBe(true);
+ });
+
+ it('should compute registerButtonDisabled as true when no admin access', () => {
+ const { component: c } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.hasDraftAdminAccess, value: false }],
+ });
+ expect(c.registerButtonDisabled()).toBe(true);
+ });
+
+ it('should compute licenseOptionsRecord from draft license options', () => {
+ const { component: c } = setup({
+ selectorOverrides: [
+ {
+ selector: RegistriesSelectors.getDraftRegistration,
+ value: { ...DEFAULT_DRAFT, license: { options: { year: '2026', copyright: 'Test' } } },
+ },
+ ],
+ });
+ expect(c.licenseOptionsRecord()).toEqual({ year: '2026', copyright: 'Test' });
+ });
+
+ it('should compute licenseOptionsRecord as empty when no license options', () => {
+ expect(component.licenseOptionsRecord()).toEqual({});
+ });
+
+ it('should pass draftId and providerId to confirm registration dialog data', () => {
+ component.openConfirmRegistrationDialog();
+
+ const callArgs = (mockDialog.open as jest.Mock).mock.calls[0];
+ expect(callArgs[1].data.draftId).toBe('draft-1');
+ expect(callArgs[1].data.providerId).toBe('prov-1');
+ expect(callArgs[1].data.projectId).toBe('proj-1');
+ });
+
+ it('should set projectId to null when branchedFrom type is not nodes', () => {
+ const { component: c, mockDialog: dialog } = setup({
+ selectorOverrides: [
+ {
+ selector: RegistriesSelectors.getDraftRegistration,
+ value: { ...DEFAULT_DRAFT, branchedFrom: { id: 'proj-1', type: 'registrations' } },
+ },
+ ],
+ });
+
+ c.openConfirmRegistrationDialog();
+
+ const callArgs = (dialog.open as jest.Mock).mock.calls[0];
+ expect(callArgs[1].data.projectId).toBeNull();
+ });
+
+ it('should pass components array to confirm registration dialog', () => {
+ component.openConfirmRegistrationDialog(['comp-1', 'comp-2']);
+
+ const callArgs = (mockDialog.open as jest.Mock).mock.calls[0];
+ expect(callArgs[1].data.components).toEqual(['comp-1', 'comp-2']);
+ });
+
+ it('should not navigate after registration when newRegistration has no id', () => {
+ const {
+ component: c,
+ mockRouter: router,
+ mockToast: toast,
+ dialogClose$: close$,
+ } = setup({
+ selectorOverrides: [{ selector: RegistriesSelectors.getRegistration, value: { id: null } }],
+ });
+
+ c.openConfirmRegistrationDialog();
+ close$.next(true);
+
+ expect(toast.showSuccess).toHaveBeenCalled();
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('should dispatch getProjectsComponents when draft hasProject is true', () => {
+ const { store: s } = setup({
+ selectorOverrides: [
+ {
+ selector: RegistriesSelectors.getDraftRegistration,
+ value: { ...DEFAULT_DRAFT, hasProject: true },
+ },
+ ],
+ });
+
+ expect(s.dispatch).toHaveBeenCalledWith(new FetchProjectChildren('proj-1'));
+ });
+
+ it('should dispatch getProjectsComponents with empty string when branchedFrom has no id', () => {
+ const { store: s } = setup({
+ selectorOverrides: [
+ {
+ selector: RegistriesSelectors.getDraftRegistration,
+ value: { ...DEFAULT_DRAFT, hasProject: true, branchedFrom: null },
+ },
+ ],
+ });
+
+ expect(s.dispatch).toHaveBeenCalledWith(new FetchProjectChildren(''));
+ });
+
+ it('should not dispatch getProjectsComponents when isDraftSubmitting is true', () => {
+ const { store: s } = setup({
+ selectorOverrides: [
+ { selector: RegistriesSelectors.isDraftSubmitting, value: true },
+ {
+ selector: RegistriesSelectors.getDraftRegistration,
+ value: { ...DEFAULT_DRAFT, hasProject: true },
+ },
+ ],
+ });
+
+ expect(s.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectChildren));
});
});
diff --git a/src/app/features/registries/components/review/review.component.ts b/src/app/features/registries/components/review/review.component.ts
index 0d9f2c339..bc634acbb 100644
--- a/src/app/features/registries/components/review/review.component.ts
+++ b/src/app/features/registries/components/review/review.component.ts
@@ -7,10 +7,19 @@ import { Card } from 'primeng/card';
import { Message } from 'primeng/message';
import { Tag } from 'primeng/tag';
-import { map, of } from 'rxjs';
+import { filter, map } from 'rxjs';
-import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy } from '@angular/core';
-import { toSignal } from '@angular/core/rxjs-interop';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ DestroyRef,
+ effect,
+ inject,
+ OnDestroy,
+ signal,
+} from '@angular/core';
+import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { ENVIRONMENT } from '@core/provider/environment.provider';
@@ -18,10 +27,7 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l
import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component';
import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component';
import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const';
-import { FieldType } from '@osf/shared/enums/field-type.enum';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
-import { UserPermissions } from '@osf/shared/enums/user-permissions.enum';
-import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe';
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
import { ToastService } from '@osf/shared/services/toast.service';
@@ -33,14 +39,7 @@ import {
} from '@osf/shared/stores/contributors';
import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects';
-import {
- ClearState,
- DeleteDraft,
- FetchLicenses,
- FetchProjectChildren,
- RegistriesSelectors,
- UpdateStepState,
-} from '../../store';
+import { ClearState, DeleteDraft, FetchLicenses, FetchProjectChildren, RegistriesSelectors } from '../../store';
import { ConfirmRegistrationDialogComponent } from '../confirm-registration-dialog/confirm-registration-dialog.component';
import { SelectComponentsDialogComponent } from '../select-components-dialog/select-components-dialog.component';
@@ -55,7 +54,6 @@ import { SelectComponentsDialogComponent } from '../select-components-dialog/sel
RegistrationBlocksDataComponent,
ContributorsListComponent,
LicenseDisplayComponent,
- FixSpecialCharPipe,
],
templateUrl: './review.component.html',
styleUrl: './review.component.scss',
@@ -67,6 +65,7 @@ export class ReviewComponent implements OnDestroy {
private readonly customConfirmationService = inject(CustomConfirmationService);
private readonly customDialogService = inject(CustomDialogService);
private readonly toastService = inject(ToastService);
+ private readonly destroyRef = inject(DestroyRef);
private readonly environment = inject(ENVIRONMENT);
readonly pages = select(RegistriesSelectors.getPagesSchema);
@@ -74,68 +73,65 @@ export class ReviewComponent implements OnDestroy {
readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting);
readonly isDraftLoading = select(RegistriesSelectors.isDraftLoading);
readonly stepsData = select(RegistriesSelectors.getStepsData);
- readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES;
+ readonly components = select(RegistriesSelectors.getRegistrationComponents);
+ readonly license = select(RegistriesSelectors.getRegistrationLicense);
+ readonly newRegistration = select(RegistriesSelectors.getRegistration);
+ readonly stepsState = select(RegistriesSelectors.getStepsState);
readonly contributors = select(ContributorsSelectors.getContributors);
readonly areContributorsLoading = select(ContributorsSelectors.isContributorsLoading);
readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors);
readonly subjects = select(SubjectsSelectors.getSelectedSubjects);
- readonly components = select(RegistriesSelectors.getRegistrationComponents);
- readonly license = select(RegistriesSelectors.getRegistrationLicense);
- readonly newRegistration = select(RegistriesSelectors.getRegistration);
+ readonly hasAdminAccess = select(RegistriesSelectors.hasDraftAdminAccess);
- readonly FieldType = FieldType;
-
- actions = createDispatchMap({
+ private readonly actions = createDispatchMap({
getContributors: GetAllContributors,
getSubjects: FetchSelectedSubjects,
deleteDraft: DeleteDraft,
clearState: ClearState,
getProjectsComponents: FetchProjectChildren,
fetchLicenses: FetchLicenses,
- updateStepState: UpdateStepState,
loadMoreContributors: LoadMoreContributors,
resetContributorsState: ResetContributorsState,
});
- private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined));
-
- stepsState = select(RegistriesSelectors.getStepsState);
-
- isDraftInvalid = computed(() => Object.values(this.stepsState()).some((step) => step.invalid));
+ readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES;
- licenseOptionsRecord = computed(() => (this.draftRegistration()?.license.options ?? {}) as Record);
+ private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])));
- hasAdminAccess = computed(() => {
- const registry = this.draftRegistration();
- if (!registry) return false;
- return registry.currentUserPermissions.includes(UserPermissions.Admin);
+ private readonly resolvedProviderId = computed(() => {
+ const draft = this.draftRegistration();
+ return draft ? (draft.providerId ?? this.environment.defaultProvider) : undefined;
});
+ private readonly componentsLoaded = signal(false);
+
+ isDraftInvalid = computed(() => Object.values(this.stepsState()).some((step) => step.invalid));
+ licenseOptionsRecord = computed(() => (this.draftRegistration()?.license.options ?? {}) as Record);
registerButtonDisabled = computed(() => this.isDraftLoading() || this.isDraftInvalid() || !this.hasAdminAccess());
constructor() {
if (!this.contributors()?.length) {
this.actions.getContributors(this.draftId(), ResourceType.DraftRegistration);
}
+
if (!this.subjects()?.length) {
this.actions.getSubjects(this.draftId(), ResourceType.DraftRegistration);
}
effect(() => {
- if (this.draftRegistration()) {
- this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? this.environment.defaultProvider);
+ const providerId = this.resolvedProviderId();
+
+ if (providerId) {
+ this.actions.fetchLicenses(providerId);
}
});
- let componentsLoaded = false;
effect(() => {
- if (!this.isDraftSubmitting()) {
- const draftRegistrations = this.draftRegistration();
- if (draftRegistrations?.hasProject) {
- if (!componentsLoaded) {
- this.actions.getProjectsComponents(draftRegistrations?.branchedFrom?.id ?? '');
- componentsLoaded = true;
- }
+ if (!this.isDraftSubmitting() && !this.componentsLoaded()) {
+ const draft = this.draftRegistration();
+ if (draft?.hasProject) {
+ this.actions.getProjectsComponents(draft.branchedFrom?.id ?? '');
+ this.componentsLoaded.set(true);
}
}
});
@@ -156,12 +152,13 @@ export class ReviewComponent implements OnDestroy {
messageKey: 'registries.confirmDeleteDraft',
onConfirm: () => {
const providerId = this.draftRegistration()?.providerId;
- this.actions.deleteDraft(this.draftId()).subscribe({
- next: () => {
+ this.actions
+ .deleteDraft(this.draftId())
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => {
this.actions.clearState();
this.router.navigateByUrl(`/registries/${providerId}/new`);
- },
- });
+ });
},
});
}
@@ -184,11 +181,11 @@ export class ReviewComponent implements OnDestroy {
components: this.components(),
},
})
- .onClose.subscribe((selectedComponents) => {
- if (selectedComponents) {
- this.openConfirmRegistrationDialog(selectedComponents);
- }
- });
+ .onClose.pipe(
+ filter((selectedComponents) => !!selectedComponents),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe((selectedComponents) => this.openConfirmRegistrationDialog(selectedComponents));
}
openConfirmRegistrationDialog(components?: string[]): void {
@@ -206,14 +203,16 @@ export class ReviewComponent implements OnDestroy {
components,
},
})
- .onClose.subscribe((res) => {
+ .onClose.pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((res) => {
if (res) {
this.toastService.showSuccess('registries.review.confirmation.successMessage');
- this.router.navigate([`/${this.newRegistration()?.id}/overview`]);
- } else {
- if (this.components()?.length) {
- this.openSelectComponentsForRegistrationDialog();
+ const id = this.newRegistration()?.id;
+ if (id) {
+ this.router.navigate([`/${id}/overview`]);
}
+ } else if (this.components()?.length) {
+ this.openSelectComponentsForRegistrationDialog();
}
});
}
diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html
index 334e43284..bd927b1c2 100644
--- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html
+++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html
@@ -13,7 +13,7 @@
class="w-12rem btn-full-width"
[label]="'common.buttons.back' | translate"
severity="info"
- (click)="dialogRef.close()"
+ (onClick)="dialogRef.close()"
/>
-
+
diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts
index 69346c419..e698bf519 100644
--- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts
+++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts
@@ -1,37 +1,39 @@
import { MockProvider } from 'ng-mocks';
+import { TreeNode } from 'primeng/api';
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ProjectShortInfoModel } from '../../models/project-short-info.model';
+
import { SelectComponentsDialogComponent } from './select-components-dialog.component';
-import { OSFTestingModule } from '@testing/osf.testing.module';
+import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock';
+import { provideOSFCore } from '@testing/osf.testing.provider';
describe('SelectComponentsDialogComponent', () => {
let component: SelectComponentsDialogComponent;
let fixture: ComponentFixture