diff --git a/src/app/shared/components/registration-card/registration-card.component.html b/src/app/shared/components/registration-card/registration-card.component.html index 823b0b300..6f304d0e5 100644 --- a/src/app/shared/components/registration-card/registration-card.component.html +++ b/src/app/shared/components/registration-card/registration-card.component.html @@ -5,7 +5,7 @@

- {{ (registrationData().title | fixSpecialChar) || ('project.registrations.card.noTitle' | translate) }} + {{ registrationData().title || ('project.registrations.card.noTitle' | translate) }}

@if (!isDraft()) { @@ -49,12 +49,11 @@

@if (isDraft()) { - @if (hasWriteAccess) { + @if (hasWriteAccess()) { [routerLink]="['/registries/drafts/', registrationData().id, 'metadata']" /> } - @if (hasAdminAccess) { + @if (hasAdminAccess()) { routerLinkActive="router-link-active" [label]="'common.buttons.view' | translate" > - @if (showButtons) { - @if (isApproved) { + @if (showButtons()) { + @if (isApproved()) { } - @if (isInProgress || isUnapproved) { + @if (isInProgress() || isUnapproved()) {
- @if (isApproved) { + @if (isApproved()) {

{{ 'shared.resources.title' | translate }}

{ let component: RegistrationCardComponent; let fixture: ComponentFixture; + let store: Store; + let routerMock: RouterMockType; const mockRegistrationData: RegistrationCard = MOCK_REGISTRATION; - beforeEach(() => { + const defaultSignals: SignalOverride[] = [ + { selector: RegistriesSelectors.getSchemaResponse, value: signal({ id: 'revision-id' }) }, + ]; + + type SetupOverrides = BaseSetupOverrides & { + registrationData?: RegistrationCard; + isDraft?: boolean; + }; + + function setup(overrides: SetupOverrides = {}): void { + routerMock = RouterMockBuilder.create().build(); + TestBed.configureTestingModule({ imports: [ RegistrationCardComponent, @@ -34,133 +58,165 @@ describe('RegistrationCardComponent', () => { ], providers: [ provideOSFCore(), - provideRouter([]), - provideMockStore({ - signals: [{ selector: RegistriesSelectors.getSchemaResponse, value: signal(null) }], - }), + MockProvider(Router, routerMock), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistrationCardComponent); component = fixture.componentInstance; - }); + fixture.componentRef.setInput('registrationData', overrides.registrationData ?? mockRegistrationData); + fixture.componentRef.setInput('isDraft', overrides.isDraft ?? false); + fixture.detectChanges(); + } it('should create', () => { - expect(component).toBeTruthy(); - }); + setup(); - it('should have registrationData as required input', () => { - fixture.componentRef.setInput('registrationData', mockRegistrationData); - expect(component.registrationData()).toEqual(mockRegistrationData); + expect(component).toBeTruthy(); }); - it('should compute isAccepted correctly when reviewsState is Accepted', () => { - const testData = { - ...mockRegistrationData, - reviewsState: RegistrationReviewStates.Accepted, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + it.each([ + [[UserPermissions.Read, UserPermissions.Write, UserPermissions.Admin], true, true], + [[UserPermissions.Write], false, true], + [[UserPermissions.Admin], true, false], + ] as [UserPermissions[], boolean, boolean][])( + 'should identify user permissions', + (currentUserPermissions, hasAdminAccess, hasWriteAccess) => { + setup({ + registrationData: { + ...mockRegistrationData, + currentUserPermissions, + }, + }); + + expect(component.hasAdminAccess()).toBe(hasAdminAccess); + expect(component.hasWriteAccess()).toBe(hasWriteAccess); + } + ); + + it.each([ + [RegistrationReviewStates.Accepted, true], + [RegistrationReviewStates.Pending, true], + [RegistrationReviewStates.Embargo, true], + [RegistrationReviewStates.Withdrawn, false], + ] as [RegistrationReviewStates, boolean][])( + 'should identify update-eligible review states', + (reviewsState, eligible) => { + setup({ + registrationData: { + ...mockRegistrationData, + reviewsState, + }, + }); + + expect(component.isAccepted()).toBe(reviewsState === RegistrationReviewStates.Accepted); + expect(component.isPending()).toBe(reviewsState === RegistrationReviewStates.Pending); + expect(component.isEmbargo()).toBe(reviewsState === RegistrationReviewStates.Embargo); + expect(component.showButtons()).toBe(eligible); + } + ); + + it.each([ + [RevisionReviewStates.Approved, true, false, false], + [RevisionReviewStates.Unapproved, false, true, false], + [RevisionReviewStates.RevisionInProgress, false, false, true], + ] as [RevisionReviewStates, boolean, boolean, boolean][])( + 'should identify revision states', + (revisionState, isApproved, isUnapproved, isInProgress) => { + setup({ + registrationData: { + ...mockRegistrationData, + revisionState, + }, + }); + + expect(component.isApproved()).toBe(isApproved); + expect(component.isUnapproved()).toBe(isUnapproved); + expect(component.isInProgress()).toBe(isInProgress); + } + ); + + it.each([ + [null, true], + [mockRegistrationData.id, true], + ['different-root-id', false], + ] as [string | null, boolean][])('should identify root registrations', (rootParentId, isRootRegistration) => { + setup({ + registrationData: { + ...mockRegistrationData, + rootParentId, + }, + }); - expect(component.isAccepted).toBe(true); + expect(component.isRootRegistration()).toBe(isRootRegistration); }); - it('should compute isPending correctly when reviewsState is Pending', () => { - const testData = { - ...mockRegistrationData, - reviewsState: RegistrationReviewStates.Pending, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + it.each([ + ['updates are disabled', { allowUpdates: false }], + ['user lacks admin access', { currentUserPermissions: [UserPermissions.Write] }], + ['registration is not root', { rootParentId: 'different-root-id' }], + ] as [string, Partial][])('should hide update buttons when %s', (_label, registrationData) => { + setup({ + registrationData: { + ...mockRegistrationData, + ...registrationData, + }, + }); - expect(component.isPending).toBe(true); + expect(component.showButtons()).toBe(false); }); - it('should compute isApproved correctly when revisionState is Approved', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Approved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + it('should dispatch create schema response and navigate to justification page on updateRegistration', () => { + setup(); + (store.dispatch as Mock).mockClear(); - expect(component.isApproved).toBe(true); - }); + component.updateRegistration(mockRegistrationData.id); - it('should compute isUnapproved correctly when revisionState is Unapproved', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Unapproved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); - - expect(component.isUnapproved).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith(new CreateSchemaResponse(mockRegistrationData.id)); + expect(routerMock.navigate).toHaveBeenCalledWith(['/registries/revisions/revision-id/justification']); }); - it('should compute isInProgress correctly when revisionState is RevisionInProgress', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.RevisionInProgress, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); - - expect(component.isInProgress).toBe(true); - }); + it('should dispatch fetch schema responses and navigate to review page for unapproved revision', () => { + setup({ + registrationData: { + ...mockRegistrationData, + revisionState: RevisionReviewStates.Unapproved, + }, + }); + (store.dispatch as Mock).mockClear(); - it('should compute isAccepted as false when reviewsState is not Accepted', () => { - const testData = { - ...mockRegistrationData, - reviewsState: RegistrationReviewStates.Pending, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + component.continueUpdateRegistration(mockRegistrationData.id); - expect(component.isAccepted).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAllSchemaResponses(mockRegistrationData.id)); + expect(routerMock.navigate).toHaveBeenCalledWith(['/registries/revisions/revision-id/review']); }); - it('should compute isPending as false when reviewsState is not Pending', () => { - const testData = { - ...mockRegistrationData, - reviewsState: RegistrationReviewStates.Accepted, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); - - expect(component.isPending).toBe(false); - }); + it('should dispatch fetch schema responses and navigate to justification page for non-unapproved revision', () => { + setup({ + registrationData: { + ...mockRegistrationData, + revisionState: RevisionReviewStates.RevisionInProgress, + }, + }); + (store.dispatch as Mock).mockClear(); - it('should compute isApproved as false when revisionState is not Approved', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Unapproved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + component.continueUpdateRegistration(mockRegistrationData.id); - expect(component.isApproved).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAllSchemaResponses(mockRegistrationData.id)); + expect(routerMock.navigate).toHaveBeenCalledWith(['/registries/revisions/revision-id/justification']); }); - it('should compute isUnapproved as false when revisionState is not Unapproved', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Approved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); - - expect(component.isUnapproved).toBe(false); - }); + it('should not navigate when schema response is not present', () => { + setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getSchemaResponse, value: signal(null) }], + }); + (store.dispatch as Mock).mockClear(); - it('should compute isInProgress as false when revisionState is not RevisionInProgress', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Approved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + component.updateRegistration(mockRegistrationData.id); - expect(component.isInProgress).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new CreateSchemaResponse(mockRegistrationData.id)); + expect(routerMock.navigate).not.toHaveBeenCalled(); }); }); diff --git a/src/app/shared/components/registration-card/registration-card.component.ts b/src/app/shared/components/registration-card/registration-card.component.ts index e9ba0f72a..56c0bf7e5 100644 --- a/src/app/shared/components/registration-card/registration-card.component.ts +++ b/src/app/shared/components/registration-card/registration-card.component.ts @@ -8,7 +8,7 @@ import { Card } from 'primeng/card'; import { tap } from 'rxjs'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; import { CreateSchemaResponse, FetchAllSchemaResponses, RegistriesSelectors } from '@osf/features/registries/store'; @@ -16,7 +16,6 @@ import { RegistrationReviewStates } from '@osf/shared/enums/registration-review- import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { RegistrationCard } from '@osf/shared/models/registration/registration-card.model'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { ContributorsListComponent } from '../contributors-list/contributors-list.component'; import { DataResourcesComponent } from '../data-resources/data-resources.component'; @@ -37,7 +36,6 @@ import { TruncatedTextComponent } from '../truncated-text/truncated-text.compone IconComponent, TruncatedTextComponent, ContributorsListComponent, - FixSpecialCharPipe, ], templateUrl: './registration-card.component.html', styleUrl: './registration-card.component.scss', @@ -52,6 +50,7 @@ export class RegistrationCardComponent { readonly deleteDraft = output(); private router = inject(Router); + schemaResponse = select(RegistriesSelectors.getSchemaResponse); actions = createDispatchMap({ @@ -59,46 +58,36 @@ export class RegistrationCardComponent { createSchemaResponse: CreateSchemaResponse, }); - get hasAdminAccess(): boolean { - return this.registrationData().currentUserPermissions.includes(UserPermissions.Admin); - } - - get hasWriteAccess(): boolean { - return this.registrationData().currentUserPermissions.includes(UserPermissions.Write); - } - - get isAccepted(): boolean { - return this.registrationData().reviewsState === RegistrationReviewStates.Accepted; - } - - get isPending(): boolean { - return this.registrationData().reviewsState === RegistrationReviewStates.Pending; - } - - get isApproved(): boolean { - return this.registrationData().revisionState === RevisionReviewStates.Approved; - } + readonly hasAdminAccess = computed(() => + this.registrationData().currentUserPermissions.includes(UserPermissions.Admin) + ); - get isUnapproved(): boolean { - return this.registrationData().revisionState === RevisionReviewStates.Unapproved; - } + readonly hasWriteAccess = computed(() => + this.registrationData().currentUserPermissions.includes(UserPermissions.Write) + ); - get isInProgress(): boolean { - return this.registrationData().revisionState === RevisionReviewStates.RevisionInProgress; - } + readonly isAccepted = computed(() => this.registrationData().reviewsState === RegistrationReviewStates.Accepted); + readonly isPending = computed(() => this.registrationData().reviewsState === RegistrationReviewStates.Pending); + readonly isApproved = computed(() => this.registrationData().revisionState === RevisionReviewStates.Approved); + readonly isUnapproved = computed(() => this.registrationData().revisionState === RevisionReviewStates.Unapproved); + readonly isEmbargo = computed(() => this.registrationData().reviewsState === RegistrationReviewStates.Embargo); - get isEmbargo(): boolean { - return this.registrationData().reviewsState === RegistrationReviewStates.Embargo; - } + readonly isInProgress = computed( + () => this.registrationData().revisionState === RevisionReviewStates.RevisionInProgress + ); - get isRootRegistration(): boolean { + readonly isRootRegistration = computed(() => { const registration = this.registrationData(); return !registration.rootParentId || registration.id === registration.rootParentId; - } + }); - get showButtons(): boolean { - return this.isRootRegistration && (this.isAccepted || this.isPending || this.isEmbargo) && this.hasAdminAccess; - } + readonly showButtons = computed( + () => + this.isRootRegistration() && + (this.isAccepted() || this.isPending() || this.isEmbargo()) && + this.hasAdminAccess() && + this.registrationData().allowUpdates + ); updateRegistration(id: string): void { this.actions @@ -125,11 +114,15 @@ export class RegistrationCardComponent { private navigateToJustificationPage(): void { const revisionId = this.schemaResponse()?.id; + if (!revisionId) return; + this.router.navigate([`/registries/revisions/${revisionId}/justification`]); } private navigateToJustificationReview(): void { const revisionId = this.schemaResponse()?.id; + if (!revisionId) return; + this.router.navigate([`/registries/revisions/${revisionId}/review`]); } } diff --git a/src/app/shared/mappers/registration/registration.mapper.ts b/src/app/shared/mappers/registration/registration.mapper.ts index 41fbe6ef4..740b6f8b1 100644 --- a/src/app/shared/mappers/registration/registration.mapper.ts +++ b/src/app/shared/mappers/registration/registration.mapper.ts @@ -73,6 +73,7 @@ export class RegistrationMapper { public: registration.attributes.public, contributors: ContributorsMapper.getContributors(registration.embeds?.bibliographic_contributors?.data), currentUserPermissions: registration.attributes.current_user_permissions, + allowUpdates: registration.embeds?.provider?.data?.attributes?.allow_updates ?? false, }; } @@ -97,6 +98,7 @@ export class RegistrationMapper { contributors: ContributorsMapper.getContributors(registration?.embeds?.bibliographic_contributors?.data), rootParentId: registration.relationships.root?.data?.id, currentUserPermissions: registration.attributes.current_user_permissions, + allowUpdates: registration.embeds?.provider?.data?.attributes?.allow_updates ?? false, }; } diff --git a/src/app/shared/models/registration/registration-card.model.ts b/src/app/shared/models/registration/registration-card.model.ts index 65c5dd6cd..b3174e8ac 100644 --- a/src/app/shared/models/registration/registration-card.model.ts +++ b/src/app/shared/models/registration/registration-card.model.ts @@ -26,4 +26,5 @@ export interface RegistrationCard { hasSupplements?: boolean; rootParentId?: string | null; currentUserPermissions: UserPermissions[]; + allowUpdates: boolean; } diff --git a/src/app/shared/models/registration/registration-json-api.model.ts b/src/app/shared/models/registration/registration-json-api.model.ts index 1e38892af..db3e46f74 100644 --- a/src/app/shared/models/registration/registration-json-api.model.ts +++ b/src/app/shared/models/registration/registration-json-api.model.ts @@ -5,6 +5,7 @@ import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { ApiData, MetaJsonApi, PaginationLinksJsonApi } from '../common/json-api.model'; import { ContributorDataJsonApi } from '../contributors/contributor-response-json-api.model'; import { LicenseRecordJsonApi } from '../license/licenses-json-api.model'; +import { RegistrationProviderAttributesJsonApi } from '../provider/registration-provider-json-api.model'; export interface DraftRegistrationResponseJsonApi { data: DraftRegistrationDataJsonApi; @@ -138,9 +139,7 @@ export interface RegistrationEmbedsJsonApi { }; provider?: { data: { - attributes: { - name: string; - }; + attributes: RegistrationProviderAttributesJsonApi; }; }; } diff --git a/src/testing/mocks/registration.mock.ts b/src/testing/mocks/registration.mock.ts index 684c0f1c1..a774d3e5a 100644 --- a/src/testing/mocks/registration.mock.ts +++ b/src/testing/mocks/registration.mock.ts @@ -25,4 +25,5 @@ export const MOCK_REGISTRATION: RegistrationCard = { hasPapers: false, hasSupplements: true, currentUserPermissions: [UserPermissions.Admin, UserPermissions.Write, UserPermissions.Read], + allowUpdates: true, };