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,
};