diff --git a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts index 4a0f25eb2..34f80a903 100644 --- a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts +++ b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts @@ -19,6 +19,7 @@ describe('MetadataRegistryInfoComponent', () => { iri: 'https://example.com/registry', reviewsWorkflow: 'standard', allowSubmissions: true, + allowUpdates: true, }; beforeEach(() => { diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts index abdc2a725..8fdba2575 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts @@ -37,6 +37,7 @@ describe('RegistryProviderHeroComponent', () => { iri: '', reviewsWorkflow: '', allowSubmissions: false, + allowUpdates: true, }; beforeEach(() => { diff --git a/src/app/features/registries/pages/justification/justification.component.ts b/src/app/features/registries/pages/justification/justification.component.ts index 610dfd668..96ddf6e12 100644 --- a/src/app/features/registries/pages/justification/justification.component.ts +++ b/src/app/features/registries/pages/justification/justification.component.ts @@ -165,23 +165,20 @@ export class JustificationComponent implements OnDestroy { private initStepValidation(): void { effect(() => { - const currentIndex = this.currentStepIndex(); - const pages = this.pages(); - const revisionData = this.schemaResponseRevisionData(); const stepState = untracked(() => this.stepsState()); - if (currentIndex > 0) { + if (this.currentStepIndex() > 0) { this.actions.updateStepState('0', true, stepState?.[0]?.touched || false); } - if (pages.length && currentIndex > 0 && revisionData) { - for (let i = 1; i < currentIndex; i++) { - const pageStep = pages[i - 1]; + if (this.pages().length && this.currentStepIndex() > 0 && this.schemaResponseRevisionData()) { + for (let i = 1; i < this.currentStepIndex(); i++) { + const pageStep = this.pages()[i - 1]; const isStepInvalid = pageStep?.questions?.some((question) => { - const questionData = revisionData[question.responseKey!]; + const questionData = this.schemaResponseRevisionData()[question.responseKey!]; return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); - }) || false; + }) ?? false; this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); } } diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts index e30b7d3c0..2e2fe0908 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -36,6 +36,7 @@ const MOCK_PROVIDER: RegistryProviderDetails = { iri: 'http://iri.example.com', reviewsWorkflow: 'pre-moderation', allowSubmissions: true, + allowUpdates: true, }; describe('RegistriesProviderSearchComponent', () => { diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.html b/src/app/features/registry/components/registry-revisions/registry-revisions.component.html index 794707631..6a50baf07 100644 --- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.html +++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.html @@ -31,7 +31,7 @@ diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts b/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts index 24b0310b9..47d0b876a 100644 --- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts +++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts @@ -168,6 +168,28 @@ describe('RegistryRevisionsComponent', () => { expect(spy).toHaveBeenCalledWith(1); }); + it('should emit updateRegistration with registry id on startUpdateRegistration', () => { + const { component } = setup(); + const spy = vi.fn(); + component.updateRegistration.subscribe(spy); + + component.startUpdateRegistration(); + + expect(spy).toHaveBeenCalledWith(MOCK_REGISTRY.id); + }); + + it('should not emit updateRegistration when registry id is missing on startUpdateRegistration', () => { + const { fixture, component } = setup(); + const spy = vi.fn(); + component.updateRegistration.subscribe(spy); + fixture.componentRef.setInput('registry', { ...MOCK_REGISTRY, id: '' }); + fixture.detectChanges(); + + component.startUpdateRegistration(); + + expect(spy).not.toHaveBeenCalled(); + }); + it('should emit continueUpdate on continueUpdateHandler', () => { const { component } = setup(); const spy = vi.fn(); diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts index 46560bbcf..1a88fab9e 100644 --- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts +++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts @@ -75,6 +75,16 @@ export class RegistryRevisionsComponent { }); }); + startUpdateRegistration() { + const registryId = this.registry()?.id; + + if (!registryId) { + return; + } + + this.updateRegistration.emit(registryId); + } + emitOpenRevision(index: number) { this.openRevision.emit(index); } diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.html b/src/app/features/registry/pages/registry-overview/registry-overview.component.html index b7d67b3ca..4712f0918 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.html +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.html @@ -84,7 +84,7 @@

(continueUpdate)="onContinueUpdateRegistration()" [isModeration]="isModeration()" [isSubmitting]="isSchemaResponsesLoading()" - [canEdit]="hasAdminAccess()" + [canEdit]="canUpdate()" > diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts index d4626ee13..d8833dd28 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts @@ -20,6 +20,7 @@ import { SchemaResponse } from '@osf/shared/models/registration/schema-response. import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; @@ -28,7 +29,7 @@ import { CustomDialogServiceMock } from '@testing/providers/custom-dialog-provid import { LoaderServiceMock, provideLoaderServiceMock } 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 { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { ViewOnlyLinkHelperMock } from '@testing/providers/view-only-link-helper.mock'; @@ -44,7 +45,7 @@ import { RegistrySelectors } from '../../store/registry'; import { RegistryOverviewComponent } from './registry-overview.component'; -interface SetupOverrides { +interface SetupOverrides extends BaseSetupOverrides { registry?: RegistrationOverviewModel | null; schemaResponses?: SchemaResponse[]; queryParams?: Record; @@ -67,6 +68,19 @@ function setup(overrides: SetupOverrides = {}) { const mockLoaderService = new LoaderServiceMock(); const mockToastService = ToastServiceMock.simple(); const mockViewOnlyHelper = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnly); + const signalDefaults = [ + { selector: RegistrySelectors.getRegistry, value: registry }, + { selector: RegistrySelectors.isRegistryLoading, value: false }, + { selector: RegistrySelectors.isRegistryAnonymous, value: false }, + { selector: RegistrySelectors.getSchemaResponses, value: schemaResponses }, + { selector: RegistrySelectors.isSchemaResponsesLoading, value: false }, + { selector: RegistrySelectors.getSchemaBlocks, value: [] }, + { selector: RegistrySelectors.isSchemaBlocksLoading, value: false }, + { selector: RegistrySelectors.areReviewActionsLoading, value: false }, + { selector: RegistrySelectors.getSchemaResponse, value: schemaResponses[0] ?? null }, + { selector: RegistrySelectors.hasAdminAccess, value: false }, + { selector: RegistrationProviderSelectors.allowUpdates, value: false }, + ]; TestBed.configureTestingModule({ imports: [ @@ -94,18 +108,7 @@ function setup(overrides: SetupOverrides = {}) { MockProvider(ToastService, mockToastService), MockProvider(ViewOnlyLinkHelperService, mockViewOnlyHelper), provideMockStore({ - signals: [ - { selector: RegistrySelectors.getRegistry, value: registry }, - { selector: RegistrySelectors.isRegistryLoading, value: false }, - { selector: RegistrySelectors.isRegistryAnonymous, value: false }, - { selector: RegistrySelectors.getSchemaResponses, value: schemaResponses }, - { selector: RegistrySelectors.isSchemaResponsesLoading, value: false }, - { selector: RegistrySelectors.getSchemaBlocks, value: [] }, - { selector: RegistrySelectors.isSchemaBlocksLoading, value: false }, - { selector: RegistrySelectors.areReviewActionsLoading, value: false }, - { selector: RegistrySelectors.getSchemaResponse, value: schemaResponses[0] ?? null }, - { selector: RegistrySelectors.hasAdminAccess, value: false }, - ], + signals: mergeSignalOverrides(signalDefaults, overrides.selectorOverrides), }), ], }); @@ -181,6 +184,30 @@ describe('RegistryOverviewComponent', () => { expect(component.canMakeDecision()).toBe(false); }); + it('should compute canUpdate as true when admin access and provider updates are allowed', () => { + const { component } = setup({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + selectorOverrides: [ + { selector: RegistrySelectors.hasAdminAccess, value: true }, + { selector: RegistrationProviderSelectors.allowUpdates, value: true }, + ], + }); + + expect(component.canUpdate()).toBe(true); + }); + + it('should compute canUpdate as false when provider updates are not allowed', () => { + const { component } = setup({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + selectorOverrides: [ + { selector: RegistrySelectors.hasAdminAccess, value: true }, + { selector: RegistrationProviderSelectors.allowUpdates, value: false }, + ], + }); + + expect(component.canUpdate()).toBe(false); + }); + it('should compute isInitialState from reviewsState', () => { const { component } = setup({ registry: { ...MOCK_REGISTRATION_OVERVIEW_MODEL, reviewsState: RegistrationReviewStates.Initial }, diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index 927fc222a..fcf3bbac3 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -37,6 +37,7 @@ import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; import { GetBibliographicContributors } from '@osf/shared/stores/contributors'; +import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { ArchivingMessageComponent } from '../../components/archiving-message/archiving-message.component'; import { RegistrationOverviewToolbarComponent } from '../../components/registration-overview-toolbar/registration-overview-toolbar.component'; @@ -98,6 +99,7 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { readonly areReviewActionsLoading = select(RegistrySelectors.areReviewActionsLoading); readonly currentRevision = select(RegistrySelectors.getSchemaResponse); readonly hasAdminAccess = select(RegistrySelectors.hasAdminAccess); + readonly allowUpdates = select(RegistrationProviderSelectors.allowUpdates); readonly selectedRevisionIndex = signal(0); @@ -112,6 +114,8 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { () => !this.registry()?.archiving && !this.registry()?.withdrawn && this.isModeration() ); + readonly canUpdate = computed(() => this.hasAdminAccess() && this.allowUpdates()); + isRootRegistration = computed(() => { const rootId = this.registry()?.rootParentId; return !rootId || rootId === this.registry()?.id; diff --git a/src/app/shared/mappers/registration-provider.mapper.ts b/src/app/shared/mappers/registration-provider.mapper.ts index d97c2ebf8..8feb41303 100644 --- a/src/app/shared/mappers/registration-provider.mapper.ts +++ b/src/app/shared/mappers/registration-provider.mapper.ts @@ -35,6 +35,7 @@ export class RegistrationProviderMapper { iri: response.links.iri, reviewsWorkflow: response.attributes.reviews_workflow, allowSubmissions: response.attributes.allow_submissions, + allowUpdates: response.attributes.allow_updates, }; } } diff --git a/src/app/shared/models/provider/registry-provider.model.ts b/src/app/shared/models/provider/registry-provider.model.ts index 1c914acd9..9ccec98f6 100644 --- a/src/app/shared/models/provider/registry-provider.model.ts +++ b/src/app/shared/models/provider/registry-provider.model.ts @@ -11,4 +11,5 @@ export interface RegistryProviderDetails { iri: string; reviewsWorkflow: string; allowSubmissions: boolean; + allowUpdates: boolean; } diff --git a/src/app/shared/stores/registration-provider/registration-provider.selectors.ts b/src/app/shared/stores/registration-provider/registration-provider.selectors.ts index 61010f5cb..d960ebd4e 100644 --- a/src/app/shared/stores/registration-provider/registration-provider.selectors.ts +++ b/src/app/shared/stores/registration-provider/registration-provider.selectors.ts @@ -13,4 +13,9 @@ export class RegistrationProviderSelectors { static isBrandedProviderLoading(state: RegistrationProviderStateModel) { return state.currentBrandedProvider.isLoading; } + + @Selector([RegistrationProviderState]) + static allowUpdates(state: RegistrationProviderStateModel) { + return state.currentBrandedProvider.data?.allowUpdates ?? false; + } }