From 2488f565ea66e341a61dc2a73425e2a9a1dcd3a0 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 30 Apr 2026 21:27:13 +0300 Subject: [PATCH 1/9] [ENG-10869] Project Contributors section content overflows on smaller screen sizes (#966) - Ticket: https://openscience.atlassian.net/browse/ENG-10869 - Feature flag: n/a ## Summary of Changes 1. Fixed project contributors table overflow in add to collection. --- src/styles/overrides/stepper.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/styles/overrides/stepper.scss b/src/styles/overrides/stepper.scss index ab10669ac..fb7b7d720 100644 --- a/src/styles/overrides/stepper.scss +++ b/src/styles/overrides/stepper.scss @@ -25,6 +25,11 @@ border: 1px solid var(--grey-2); border-radius: 0.5rem; + .p-steppanel, + .p-steppanel .p-motion { + grid-template-columns: 100%; + } + .p-steppanel-content { margin-inline-start: 0; } From c1718672734a16d7621e76461b4877b6eab1e5fa Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 30 Apr 2026 21:28:33 +0300 Subject: [PATCH 2/9] [ENG-10866] Duplicate text displayed on the My Registrations page (#964) - Ticket: https://openscience.atlassian.net/browse/ENG-10866 - Feature flag: n/a ## Summary of Changes 1. Fixed bug with tabs in the my registrations. --- .../pages/my-registrations/my-registrations.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.html b/src/app/features/registries/pages/my-registrations/my-registrations.component.html index 234d32b0d..b389c5af8 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.html +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.html @@ -58,7 +58,7 @@ - +
@if (isSubmittedRegistrationsLoading()) { @for (item of skeletons; track $index) { From 012ec5b1927bbcfd4b76dd61ce37414d32c877b3 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 30 Apr 2026 21:48:13 +0300 Subject: [PATCH 3/9] [ENG-10770] "See more" button shows error when only one admin contributor exists (#945) - Ticket: https://openscience.atlassian.net/browse/ENG-10770 - Feature flag: n/a ## Summary of Changes 1. Updated show load more contributors logic. --- .../project-contributors-step.component.html | 1 + .../project-contributors-step.component.ts | 7 +++--- .../contributors/contributors.component.html | 1 + .../contributors/contributors.component.ts | 5 +++-- .../contributors-dialog.component.html | 1 + .../contributors-dialog.component.ts | 2 ++ .../preprints-contributors.component.html | 1 + .../preprints-contributors.component.ts | 1 + .../registries-contributors.component.html | 1 + .../registries-contributors.component.ts | 1 + .../contributors-table.component.html | 22 +++++++++---------- .../contributors-table.component.ts | 7 +----- 12 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.html b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.html index bc0195260..43d53e657 100644 --- a/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.html +++ b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.html @@ -42,6 +42,7 @@

{{ 'collections.addToCollection.projectContributors' | translate }}

[(contributors)]="projectContributors" [tableParams]="tableParams()" [isLoading]="isContributorsLoading()" + [showLoadMore]="hasMoreContributors()" [isLoadingMore]="isLoadingMore()" (remove)="handleRemoveContributor($event)" (loadMore)="loadMoreContributors()" diff --git a/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts index f8b32192c..8a41cde7a 100644 --- a/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts @@ -60,15 +60,16 @@ export class ProjectContributorsStepComponent { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly router = inject(Router); + readonly currentUser = select(UserSelectors.getCurrentUser); readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); readonly selectedProject = select(ProjectsSelectors.getSelectedProject); - readonly currentUser = select(UserSelectors.getCurrentUser); - isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); + readonly pageSize = select(ContributorsSelectors.getContributorsPageSize); private initialContributors = select(ContributorsSelectors.getContributors); readonly projectContributors = signal([]); - pageSize = select(ContributorsSelectors.getContributorsPageSize); readonly tableParams = computed(() => ({ ...DEFAULT_TABLE_PARAMS, diff --git a/src/app/features/contributors/contributors.component.html b/src/app/features/contributors/contributors.component.html index 2f43f5b60..fbcd27799 100644 --- a/src/app/features/contributors/contributors.component.html +++ b/src/app/features/contributors/contributors.component.html @@ -67,6 +67,7 @@

{{ 'navigation.contributors' | translate } [tableParams]="tableParams()" [hasAdminAccess]="hasAdminAccess()" [currentUserId]="currentUser()?.id" + [showLoadMore]="hasMoreContributors()" [showCurator]="true" [showInfo]="true" [resourceType]="resourceType()" diff --git a/src/app/features/contributors/contributors.component.ts b/src/app/features/contributors/contributors.component.ts index 1cf411bbc..2fab14f2c 100644 --- a/src/app/features/contributors/contributors.component.ts +++ b/src/app/features/contributors/contributors.component.ts @@ -132,11 +132,12 @@ export class ContributorsComponent implements OnInit, OnDestroy { readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); readonly isViewOnlyLinksLoading = select(ViewOnlyLinkSelectors.isViewOnlyLinksLoading); + readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + readonly pageSize = select(ContributorsSelectors.getContributorsPageSize); + readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); readonly hasAdminAccess = select(CurrentResourceSelectors.hasResourceAdminAccess); readonly resourceAccessRequestEnabled = select(CurrentResourceSelectors.resourceAccessRequestEnabled); readonly currentUser = select(UserSelectors.getCurrentUser); - pageSize = select(ContributorsSelectors.getContributorsPageSize); - isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); readonly tableParams = computed(() => ({ ...DEFAULT_TABLE_PARAMS, diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html index 4ff339220..787fa2208 100644 --- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html +++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html @@ -20,6 +20,7 @@ [tableParams]="tableParams()" [isLoading]="isLoading()" [isLoadingMore]="isLoadingMore()" + [showLoadMore]="hasMoreContributors()" [showEmployment]="false" [showEducation]="false" [hasAdminAccess]="hasAdminAccess()" diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts index 46e3a82f6..c259bee48 100644 --- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts +++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts @@ -73,7 +73,9 @@ export class ContributorsDialogComponent implements OnInit { hasAdminAccess = select(MetadataSelectors.hasAdminAccess); contributors = signal([]); isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); pageSize = select(ContributorsSelectors.getContributorsPageSize); + changesMade = signal(false); currentUser = select(UserSelectors.getCurrentUser); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html index 76023ffe3..cee666a6f 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html @@ -12,6 +12,7 @@

{{ 'common.labels.contributors' | translate }}

[(contributors)]="contributors" [tableParams]="tableParams()" [isLoading]="isContributorsLoading()" + [showLoadMore]="hasMoreContributors()" [isLoadingMore]="isLoadingMore()" (remove)="removeContributor($event)" (loadMore)="loadMoreContributors()" diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts index 5adbe6301..e36795893 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts @@ -62,6 +62,7 @@ export class PreprintsContributorsComponent implements OnInit { readonly contributors = signal([]); readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); readonly pageSize = select(ContributorsSelectors.getContributorsPageSize); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html index 6006754c0..725d9005b 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html @@ -6,6 +6,7 @@

{{ 'common.labels.contributors' | translate }}

[(contributors)]="contributors" [tableParams]="tableParams()" [isLoading]="isContributorsLoading()" + [showLoadMore]="hasMoreContributors()" [isLoadingMore]="isLoadingMore()" (remove)="removeContributor($event)" (loadMore)="loadMoreContributors()" diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts index a6fce3850..833e2dea5 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts @@ -66,6 +66,7 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); + hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); pageSize = select(ContributorsSelectors.getContributorsPageSize); diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.html b/src/app/shared/components/contributors/contributors-table/contributors-table.component.html index daaa9ba85..36b516a90 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.html +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.html @@ -174,19 +174,19 @@ } + - @if (showLoadMore() && index === contributors().length - 1) { + + @if (showLoadMore()) { - -
- -
+ + } diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts index 80fcb5528..d41244e28 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts @@ -50,6 +50,7 @@ export class ContributorsTableComponent { showEducation = input(true); showEmployment = input(true); showInfo = input(false); + showLoadMore = input(false); resourceType = input(ResourceType.Project); currentUserId = input(undefined); @@ -69,12 +70,6 @@ export class ContributorsTableComponent { deactivatedContributors = computed(() => this.contributors().some((contributor) => contributor.deactivated)); - showLoadMore = computed(() => { - const currentLoadedItems = this.contributors().length; - const totalRecords = this.tableParams().totalRecords; - return currentLoadedItems > 0 && currentLoadedItems < totalRecords; - }); - removeContributor(contributor: ContributorModel) { this.remove.emit(contributor); } From cdbfda575d015aeb431222d4ca76ca8ee1c4799a Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 30 Apr 2026 21:49:26 +0300 Subject: [PATCH 4/9] [ENG-10626] User unable to Update embargoed registration (#959) - Ticket: https://openscience.atlassian.net/browse/ENG-10626 - Feature flag: n/a ## Summary of Changes 1. Added logic with `allowUpdates`. 2. Fixed freeze in registry update. --- .../metadata-registry-info.component.spec.ts | 1 + .../registry-provider-hero.component.spec.ts | 1 + .../justification/justification.component.ts | 15 ++--- ...gistries-provider-search.component.spec.ts | 1 + .../registry-revisions.component.html | 2 +- .../registry-revisions.component.spec.ts | 22 ++++++++ .../registry-revisions.component.ts | 10 ++++ .../registry-overview.component.html | 2 +- .../registry-overview.component.spec.ts | 55 ++++++++++++++----- .../registry-overview.component.ts | 4 ++ .../mappers/registration-provider.mapper.ts | 1 + .../provider/registry-provider.model.ts | 1 + .../registration-provider.selectors.ts | 5 ++ 13 files changed, 95 insertions(+), 25 deletions(-) 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; + } } From 100d23f1702660d34f0af313d27361adef1c620e Mon Sep 17 00:00:00 2001 From: mkovalua Date: Thu, 30 Apr 2026 21:50:41 +0300 Subject: [PATCH 5/9] [ENG-9958] Files in draft registrations cannot be previewed (#957) - Ticket: https://openscience.atlassian.net/browse/ENG-9958 - Feature flag: n/a ## Purpose Prior to the Angular release, files attached to draft registrations could be previewed directly within the registration. This functionality no longer exists in Angular. ## Summary of Changes open preview url on draft registry file click --- src/app/app.routes.ts | 5 + .../file-preview/file-preview.component.html | 31 ++++ .../file-preview/file-preview.component.scss | 9 + .../file-preview.component.spec.ts | 166 ++++++++++++++++++ .../file-preview/file-preview.component.ts | 79 +++++++++ .../custom-step/custom-step.component.html | 1 + .../custom-step/custom-step.component.ts | 8 + .../files-control.component.html | 2 +- .../files-control/files-control.component.ts | 6 + src/assets/i18n/en.json | 3 +- 10 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 src/app/features/files/pages/file-preview/file-preview.component.html create mode 100644 src/app/features/files/pages/file-preview/file-preview.component.scss create mode 100644 src/app/features/files/pages/file-preview/file-preview.component.spec.ts create mode 100644 src/app/features/files/pages/file-preview/file-preview.component.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 711ff4387..a227f8e5a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -182,6 +182,11 @@ export const routes: Routes = [ import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent), data: { skipBreadcrumbs: true }, }, + { + path: ':id/files/:fileGuid/preview', + loadComponent: () => + import('./features/files/pages/file-preview/file-preview.component').then((m) => m.FilePreviewComponent), + }, { path: 'spam-content', loadComponent: () => diff --git a/src/app/features/files/pages/file-preview/file-preview.component.html b/src/app/features/files/pages/file-preview/file-preview.component.html new file mode 100644 index 000000000..2548a7650 --- /dev/null +++ b/src/app/features/files/pages/file-preview/file-preview.component.html @@ -0,0 +1,31 @@ + +
+
+
+ @if (safeLink) { + + } + @if (isIframeLoading) { + + } +
+ +
+ +
+
+
diff --git a/src/app/features/files/pages/file-preview/file-preview.component.scss b/src/app/features/files/pages/file-preview/file-preview.component.scss new file mode 100644 index 000000000..4bd4ee39e --- /dev/null +++ b/src/app/features/files/pages/file-preview/file-preview.component.scss @@ -0,0 +1,9 @@ +.metadata { + border: 1px solid var(--grey-2); + border-radius: 0.75rem; +} + +.full-image { + min-height: 100vh; + min-width: 100%; +} diff --git a/src/app/features/files/pages/file-preview/file-preview.component.spec.ts b/src/app/features/files/pages/file-preview/file-preview.component.spec.ts new file mode 100644 index 000000000..2ef809426 --- /dev/null +++ b/src/app/features/files/pages/file-preview/file-preview.component.spec.ts @@ -0,0 +1,166 @@ +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { Mock } from 'vitest'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileDetailsModel } from '@osf/shared/models/files/file.model'; +import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; +import { ViewOnlyLinkHelperMock, ViewOnlyLinkHelperMockType } from '@testing/providers/view-only-link-helper.mock'; + +import { FilesSelectors, GetFile } from '../../store'; + +import { FilePreviewComponent } from './file-preview.component'; + +interface SetupOverrides extends BaseSetupOverrides { + hasViewOnlyParam?: boolean; + viewOnlyParam?: string | null; + renderLink?: string; +} + +describe('FilePreviewComponent', () => { + let component: FilePreviewComponent; + let fixture: ComponentFixture; + let store: Store; + let mockRouter: RouterMockType; + let viewOnlyService: ViewOnlyLinkHelperMockType; + + const encodedDownloadUrl = 'https://files.osf.io/v1/resources/abc/providers/osfstorage/file.txt'; + const defaultRenderLink = `https://mfr.osf.io/render?url=${encodeURIComponent(encodedDownloadUrl)}`; + + function buildFileDetailsModel(renderLink: string): FileDetailsModel { + return { + id: 'file-1', + guid: 'file-guid-1', + name: 'file.txt', + kind: FileKind.File, + path: '/file.txt', + size: 128, + materializedPath: '/file.txt', + dateModified: '2026-01-01T00:00:00.000Z', + dateCreated: '2026-01-01T00:00:00.000Z', + lastTouched: null, + tags: [], + currentVersion: 1, + showAsUnviewed: false, + extra: { + hashes: { + md5: 'md5', + sha256: 'sha256', + }, + downloads: 1, + }, + links: { + info: '', + move: '', + upload: '', + delete: '', + download: '', + render: renderLink, + html: '', + self: '', + }, + target: {} as unknown as BaseNodeModel, + }; + } + + const defaultSignals: SignalOverride[] = [ + { selector: FilesSelectors.isOpenedFileLoading, value: false }, + { selector: FilesSelectors.getOpenedFile, value: buildFileDetailsModel(defaultRenderLink) }, + ]; + + function setup(overrides: SetupOverrides = {}) { + const route = ActivatedRouteMockBuilder.create() + .withParams(overrides.routeParams ?? { fileGuid: 'file-1' }) + .build(); + mockRouter = RouterMockBuilder.create().withUrl('/files/file-1/preview').build(); + viewOnlyService = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnlyParam ?? false); + viewOnlyService.getViewOnlyParam = vi.fn().mockReturnValue(overrides.viewOnlyParam ?? null); + + const signals = mergeSignalOverrides(defaultSignals, [ + { + selector: FilesSelectors.getOpenedFile, + value: buildFileDetailsModel(overrides.renderLink ?? defaultRenderLink), + }, + ...(overrides.selectorOverrides ?? []), + ]); + + TestBed.configureTestingModule({ + imports: [FilePreviewComponent], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, route), + MockProvider(Router, mockRouter), + MockProvider(ViewOnlyLinkHelperService, viewOnlyService), + provideMockStore({ signals }), + ], + }); + + store = TestBed.inject(Store); + fixture = TestBed.createComponent(FilePreviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + it('should create', () => { + setup(); + + expect(component).toBeTruthy(); + }); + + it('should dispatch get file action with route file guid on init', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetFile('file-1')); + }); + + it('should keep mfr url unchanged when render link has no nested url param', () => { + setup({ renderLink: 'https://mfr.osf.io/render' }); + (store.dispatch as Mock).mockClear(); + + const result = component.getMfrUrlWithVersion('2'); + + expect(result).toBe('https://mfr.osf.io/render'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should append version param to nested download url', () => { + setup(); + + const result = component.getMfrUrlWithVersion('3'); + + expect(result).toContain('https://mfr.osf.io/render?'); + expect(result).toContain(encodeURIComponent('version=3')); + }); + + it('should append view only param when present', () => { + setup({ hasViewOnlyParam: true, viewOnlyParam: 'view-token-1' }); + + const result = component.getMfrUrlWithVersion(); + + expect(result).toContain(encodeURIComponent('view_only=view-token-1')); + }); + + it('should return null for empty render link', () => { + setup({ renderLink: '' }); + + const result = component.getMfrUrlWithVersion(); + + expect(result).toBeNull(); + }); +}); diff --git a/src/app/features/files/pages/file-preview/file-preview.component.ts b/src/app/features/files/pages/file-preview/file-preview.component.ts new file mode 100644 index 000000000..ab9bbd904 --- /dev/null +++ b/src/app/features/files/pages/file-preview/file-preview.component.ts @@ -0,0 +1,79 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { switchMap } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, HostBinding, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { FilesSelectors, GetFile } from '@osf/features/files/store'; +import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; + +@Component({ + selector: 'osf-draft-file-detail', + imports: [SubHeaderComponent, LoadingSpinnerComponent, TranslatePipe], + templateUrl: './file-preview.component.html', + styleUrl: './file-preview.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilePreviewComponent { + @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; + + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly sanitizer = inject(DomSanitizer); + private readonly destroyRef = inject(DestroyRef); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); + + private readonly actions = createDispatchMap({ getFile: GetFile }); + + file = select(FilesSelectors.getOpenedFile); + isFileLoading = select(FilesSelectors.isOpenedFileLoading); + + isIframeLoading = true; + safeLink: SafeResourceUrl | null = null; + + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); + + constructor() { + this.route.params + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((params) => this.actions.getFile(params['fileGuid'])) + ) + .subscribe(() => this.getIframeLink('')); + } + + getIframeLink(version: string) { + const url = this.getMfrUrlWithVersion(version); + if (url) { + this.safeLink = this.sanitizer.bypassSecurityTrustResourceUrl(url); + } + } + + getMfrUrlWithVersion(version?: string): string | null { + const mfrUrl = this.file()?.links.render; + if (!mfrUrl) return null; + const mfrUrlObj = new URL(mfrUrl); + const encodedDownloadUrl = mfrUrlObj.searchParams.get('url'); + if (!encodedDownloadUrl) return mfrUrl; + + const downloadUrlObj = new URL(decodeURIComponent(encodedDownloadUrl)); + + if (version) downloadUrlObj.searchParams.set('version', version); + + if (this.hasViewOnly()) { + const viewOnlyParam = this.viewOnlyService.getViewOnlyParam(); + if (viewOnlyParam) downloadUrlObj.searchParams.set('view_only', viewOnlyParam); + } + + mfrUrlObj.searchParams.set('url', downloadUrlObj.toString()); + + return mfrUrlObj.toString(); + } +} diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index 3424906dd..6bdc77c6e 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.html +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -179,6 +179,7 @@

{{ 'files.actions.uploadFile' | translate }}

[projectId]="projectId()" [provider]="provider()" (attachFile)="onAttachFile($event, q.responseKey!)" + (openFile)="onOpenFile($event)" [filesViewOnly]="filesViewOnly()" > diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 357bc71b5..7b5f066e6 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -98,6 +98,7 @@ export class CustomStepComponent implements OnDestroy { readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; step = signal(this.route.snapshot.params['step']); + draftId = signal(this.route.snapshot.params['id']); currentPage = computed(() => this.pages()[this.step() - 1]); stepForm: FormGroup = this.fb.group({}); @@ -135,6 +136,13 @@ export class CustomStepComponent implements OnDestroy { }); } + onOpenFile(file: FileModel): void { + if (this.draftId() && file.guid) { + const url = this.router.serializeUrl(this.router.createUrlTree([this.draftId(), 'files', file.guid, 'preview'])); + window.open(url, '_blank'); + } + } + removeFromAttachedFiles(file: AttachedFile, questionKey: string): void { if (!this.attachedFiles[questionKey]) { return; diff --git a/src/app/features/registries/components/files-control/files-control.component.html b/src/app/features/registries/components/files-control/files-control.component.html index 8d3350ae2..78c18d21c 100644 --- a/src/app/features/registries/components/files-control/files-control.component.html +++ b/src/app/features/registries/components/files-control/files-control.component.html @@ -52,7 +52,7 @@ [provider]="provider()" [selectedFiles]="filesSelection" (selectFile)="onFileTreeSelected($event)" - (entryFileClicked)="selectFile($event)" + (entryFileClicked)="onEntryFileClicked($event)" (uploadFilesConfirmed)="uploadFiles($event)" (loadFiles)="onLoadFiles($event)" (setCurrentFolder)="setCurrentFolder($event)" diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index 423a65d45..604141db5 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -54,6 +54,7 @@ export class FilesControlComponent { provider = input.required(); filesViewOnly = input(false); attachFile = output(); + openFile = output(); private readonly filesService = inject(FilesService); private readonly customDialogService = inject(CustomDialogService); @@ -153,6 +154,11 @@ export class FilesControlComponent { }); } + onEntryFileClicked(file: FileModel): void { + this.selectFile(file); + this.openFile.emit(file); + } + selectFile(file: FileModel): void { if (this.filesViewOnly()) return; this.attachFile.emit(file); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2887a045d..bcec6b3a1 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -615,7 +615,8 @@ "resourceLanguage": "Resource Language", "resourceType": "Resource Type" }, - "title": "File Metadata" + "title": "File Metadata", + "previewNotAvailable": "File or Registration metadata not available in preview mode." }, "keywords": { "title": "Keywords" From a933d9d95daba584a87831718c9db0f233508107 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 30 Apr 2026 21:53:03 +0300 Subject: [PATCH 6/9] [ENG-7471] Have a display of some form when the OSF is down for planned maintenance [FE] (#969) - Ticket: https://openscience.atlassian.net/browse/ENG-7471 - Feature flag: n/a ## Summary of Changes 1. Added logic for maintenance. --- .../components/layout/layout.component.html | 7 ++ .../components/layout/layout.component.scss | 7 ++ .../layout/layout.component.spec.ts | 5 +- .../components/layout/layout.component.ts | 4 ++ .../maintenance-banner.component.html | 2 +- .../interceptors/error.interceptor.spec.ts | 25 +++++++ .../core/interceptors/error.interceptor.ts | 14 ++++ .../core/models/maintenance-response.model.ts | 5 ++ .../core/services/maintenance-mode.service.ts | 66 +++++++++++++++++++ src/assets/i18n/en.json | 4 ++ .../maintenance-mode.service.mock.ts | 21 ++++++ 11 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 src/app/core/models/maintenance-response.model.ts create mode 100644 src/app/core/services/maintenance-mode.service.ts create mode 100644 src/testing/providers/maintenance-mode.service.mock.ts diff --git a/src/app/core/components/layout/layout.component.html b/src/app/core/components/layout/layout.component.html index c587f4ef4..5f872d40f 100644 --- a/src/app/core/components/layout/layout.component.html +++ b/src/app/core/components/layout/layout.component.html @@ -19,6 +19,13 @@ + + @if (isMaintenanceMode()) { +
+

{{ 'maintenance.title' | translate }}

+

{{ 'maintenance.message' | translate }}

+
+ } { provideOSFCore(), MockProvider(IS_WEB, isWebSubject), MockProvider(IS_MEDIUM, isMediumSubject), - MockProvider(ConfirmationService), + MockProvider(MaintenanceModeService, MaintenanceModeServiceMock.simple()), ], }); diff --git a/src/app/core/components/layout/layout.component.ts b/src/app/core/components/layout/layout.component.ts index 63601d382..62befef60 100644 --- a/src/app/core/components/layout/layout.component.ts +++ b/src/app/core/components/layout/layout.component.ts @@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { RouterOutlet } from '@angular/router'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { ScrollTopOnRouteChangeDirective } from '@osf/shared/directives/scroll-top.directive'; import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; @@ -35,6 +36,9 @@ import { TopnavComponent } from '../topnav/topnav.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class LayoutComponent { + private readonly maintenanceModeService = inject(MaintenanceModeService); + isWeb = toSignal(inject(IS_WEB)); isMedium = toSignal(inject(IS_MEDIUM)); + isMaintenanceMode = this.maintenanceModeService.isActive; } diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html index 9dd9ed582..f3687af5c 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html @@ -4,9 +4,9 @@ styleClass="w-full" icon="pi pi-info-circle" [severity]="maintenance()?.severity" - [text]="maintenance()?.message" [closable]="true" (onClose)="dismiss()" > + {{ maintenance()?.message }} } diff --git a/src/app/core/interceptors/error.interceptor.spec.ts b/src/app/core/interceptors/error.interceptor.spec.ts index 8b9ef1e55..d8209e666 100644 --- a/src/app/core/interceptors/error.interceptor.spec.ts +++ b/src/app/core/interceptors/error.interceptor.spec.ts @@ -9,12 +9,17 @@ import { Router } from '@angular/router'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { AuthService } from '@core/services/auth.service'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock'; import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { + MaintenanceModeServiceMock, + MaintenanceModeServiceMockType, +} from '@testing/providers/maintenance-mode.service.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { SentryMock, SentryMockType } from '@testing/providers/sentry-provider.mock'; import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; @@ -28,6 +33,7 @@ describe('errorInterceptor', () => { let toastServiceMock: ToastServiceMockType; let loaderServiceMock: LoaderServiceMock; let authServiceMock: AuthServiceMockType; + let maintenanceModeServiceMock: MaintenanceModeServiceMockType; let viewOnlyHelperMock: ViewOnlyLinkHelperMockType; let sentryMock: SentryMockType; @@ -36,6 +42,7 @@ describe('errorInterceptor', () => { toastServiceMock = ToastServiceMock.simple(); loaderServiceMock = new LoaderServiceMock(); authServiceMock = AuthServiceMock.simple(); + maintenanceModeServiceMock = MaintenanceModeServiceMock.simple(); viewOnlyHelperMock = ViewOnlyLinkHelperMock.simple(viewOnly); sentryMock = SentryMock.simple(); @@ -46,6 +53,7 @@ describe('errorInterceptor', () => { MockProvider(Router, router), MockProvider(ToastService, toastServiceMock), MockProvider(AuthService, authServiceMock), + MockProvider(MaintenanceModeService, maintenanceModeServiceMock), MockProvider(ViewOnlyLinkHelperService, viewOnlyHelperMock), MockProvider(PLATFORM_ID, platformId), { provide: SENTRY_TOKEN, useValue: sentryMock }, @@ -156,4 +164,21 @@ describe('errorInterceptor', () => { expect(loaderServiceMock.hide).toHaveBeenCalled(); expect(toastServiceMock.showError).not.toHaveBeenCalled(); }); + + it('should activate maintenance mode on 503 maintenance response', async () => { + setup('browser', false); + const request = createRequest('/api/v2/'); + const error = new HttpErrorResponse({ + status: 503, + error: { meta: { maintenance_mode: true } }, + url: request.url, + }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(503); + expect(maintenanceModeServiceMock.activate).toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/core/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts index 99848cc4b..7adb6a7c2 100644 --- a/src/app/core/interceptors/error.interceptor.ts +++ b/src/app/core/interceptors/error.interceptor.ts @@ -7,8 +7,10 @@ import { inject, PLATFORM_ID } from '@angular/core'; import { Router } from '@angular/router'; import { ERROR_MESSAGES } from '@core/constants/error-messages'; +import { MaintenanceResponse } from '@core/models/maintenance-response.model'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { AuthService } from '@core/services/auth.service'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; @@ -20,6 +22,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { const loaderService = inject(LoaderService); const router = inject(Router); const authService = inject(AuthService); + const maintenanceModeService = inject(MaintenanceModeService); const sentry = inject(SENTRY_TOKEN); const platformId = inject(PLATFORM_ID); const viewOnlyHelper = inject(ViewOnlyLinkHelperService); @@ -43,6 +46,17 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { } const serverErrorRegex = /5\d{2}/; + const maintenanceResponse = error.error as MaintenanceResponse | null; + + const maintenanceMode = error.status === 503 && maintenanceResponse?.meta?.maintenance_mode === true; + + if (maintenanceMode) { + loaderService.hide(); + if (isPlatformBrowser(platformId)) { + maintenanceModeService.activate(); + } + return throwError(() => error); + } if (serverErrorRegex.test(error.status.toString())) { errorMessage = error.error.message || 'common.errorMessages.serverError'; diff --git a/src/app/core/models/maintenance-response.model.ts b/src/app/core/models/maintenance-response.model.ts new file mode 100644 index 000000000..88c5fea94 --- /dev/null +++ b/src/app/core/models/maintenance-response.model.ts @@ -0,0 +1,5 @@ +export interface MaintenanceResponse { + meta?: { + maintenance_mode?: boolean; + }; +} diff --git a/src/app/core/services/maintenance-mode.service.ts b/src/app/core/services/maintenance-mode.service.ts new file mode 100644 index 000000000..2059b710f --- /dev/null +++ b/src/app/core/services/maintenance-mode.service.ts @@ -0,0 +1,66 @@ +import { catchError, map, Observable, of, Subscription, switchMap, timer } from 'rxjs'; + +import { HttpClient, HttpContext } from '@angular/common/http'; +import { inject, Injectable, OnDestroy, signal } from '@angular/core'; + +import { MaintenanceResponse } from '@core/models/maintenance-response.model'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { BYPASS_ERROR_INTERCEPTOR } from '../interceptors/error-interceptor.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class MaintenanceModeService implements OnDestroy { + private readonly http = inject(HttpClient); + private readonly environment = inject(ENVIRONMENT); + + private readonly POLL_INTERVAL_MS = 5 * 60 * 1_000; + private readonly _isActive = signal(false); + private readonly bypassContext = new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true); + + private pollingSubscription: Subscription | null = null; + + readonly isActive = this._isActive.asReadonly(); + + activate(): void { + this._isActive.set(true); + if (this.pollingSubscription) { + return; + } + this.startPolling(); + } + + deactivate(): void { + this._isActive.set(false); + this.stopPolling(); + } + + ngOnDestroy(): void { + this.stopPolling(); + } + + private startPolling(): void { + this.pollingSubscription = timer(0, this.POLL_INTERVAL_MS) + .pipe(switchMap(() => this.checkMaintenanceStatus())) + .subscribe((isMaintenance) => { + if (!isMaintenance) { + this.deactivate(); + } + }); + } + + private stopPolling(): void { + this.pollingSubscription?.unsubscribe(); + this.pollingSubscription = null; + } + + private checkMaintenanceStatus(): Observable { + return this.http + .get(`${this.environment.apiDomainUrl}/v2/`, { context: this.bypassContext }) + .pipe( + map((response) => response.meta?.maintenance_mode === true), + catchError(() => of(true)) + ); + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index bcec6b3a1..6ad050f6c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2824,6 +2824,10 @@ } } }, + "maintenance": { + "message": "Please come back later.", + "title": "The OSF is currently down for scheduled maintenance." + }, "shared": { "affiliatedInstitutions": { "description": "This is a service provided by the OSF and is automatically applied to your registration. If you are not sure if your institution has signed up for this service, you can look for their name in this list." diff --git a/src/testing/providers/maintenance-mode.service.mock.ts b/src/testing/providers/maintenance-mode.service.mock.ts new file mode 100644 index 000000000..7db5cdb40 --- /dev/null +++ b/src/testing/providers/maintenance-mode.service.mock.ts @@ -0,0 +1,21 @@ +import { Mock } from 'vitest'; + +import { Signal, signal } from '@angular/core'; + +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; + +export type MaintenanceModeServiceMockType = Partial & { + activate: Mock<() => void>; + deactivate: Mock<() => void>; + isActive: Signal; +}; + +export const MaintenanceModeServiceMock = { + simple() { + return { + activate: vi.fn(), + deactivate: vi.fn(), + isActive: signal(false).asReadonly(), + } as MaintenanceModeServiceMockType; + }, +}; From 5480900504a5649ccdf00de024a6111189b72fc7 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 1 May 2026 16:32:53 +0300 Subject: [PATCH 7/9] fix(contributors-table): fixed failed tests (#977) ## Summary of Changes 1. Fixed failed tests for contributors table --- .../contributors-table.component.spec.ts | 133 ++++++++++++------ .../contributors-table.component.ts | 5 +- 2 files changed, 93 insertions(+), 45 deletions(-) diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts b/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts index f83741f0a..b3a4ccac0 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts @@ -1,4 +1,4 @@ -import { MockProvider } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -13,36 +13,40 @@ import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog import { EducationHistoryDialogComponent } from '../../education-history-dialog/education-history-dialog.component'; import { EmploymentHistoryDialogComponent } from '../../employment-history-dialog/employment-history-dialog.component'; +import { IconComponent } from '../../icon/icon.component'; +import { InfoIconComponent } from '../../info-icon/info-icon.component'; +import { SelectComponent } from '../../select/select.component'; import { ContributorsTableComponent } from './contributors-table.component'; +const makeTableParams = (overrides: Partial = {}): TableParameters => ({ + rows: 10, + paginator: true, + scrollable: false, + rowsPerPageOptions: [10, 25, 50], + totalRecords: 4, + firstRowIndex: 10, + defaultSortOrder: null, + defaultSortColumn: null, + ...overrides, +}); + describe('ContributorsTableComponent', () => { let component: ContributorsTableComponent; let fixture: ComponentFixture; let mockCustomDialogService: ReturnType; - const tableParams: TableParameters = { - rows: 10, - paginator: true, - scrollable: false, - rowsPerPageOptions: [10, 25, 50], - totalRecords: 4, - firstRowIndex: 10, - defaultSortOrder: null, - defaultSortColumn: null, - }; - beforeEach(() => { mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); TestBed.configureTestingModule({ - imports: [ContributorsTableComponent], + imports: [ContributorsTableComponent, ...MockComponents(SelectComponent, IconComponent, InfoIconComponent)], providers: [provideOSFCore(), MockProvider(CustomDialogService, mockCustomDialogService)], }); fixture = TestBed.createComponent(ContributorsTableComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('tableParams', tableParams); + fixture.componentRef.setInput('tableParams', makeTableParams()); fixture.detectChanges(); }); @@ -50,47 +54,54 @@ describe('ContributorsTableComponent', () => { expect(component).toBeTruthy(); }); - it('should compute isProject based on resourceType', () => { + it('should return true from isProject when resourceType is Project', () => { fixture.componentRef.setInput('resourceType', ResourceType.Project); fixture.detectChanges(); expect(component.isProject()).toBe(true); + }); + it('should return false from isProject when resourceType is Registration', () => { fixture.componentRef.setInput('resourceType', ResourceType.Registration); fixture.detectChanges(); expect(component.isProject()).toBe(false); }); - it('should compute deactivatedContributors when list contains deactivated contributor', () => { - const contributors: ContributorModel[] = [ + it('should return true from deactivatedContributors when at least one contributor is deactivated', () => { + fixture.componentRef.setInput('contributors', [ { ...MOCK_CONTRIBUTOR, id: '1', deactivated: false }, { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', deactivated: true }, - ]; - - component.contributors.set(contributors); - + ]); + fixture.detectChanges(); expect(component.deactivatedContributors()).toBe(true); }); - it('should compute showLoadMore when loaded contributors are below total records', () => { - component.contributors.set([{ ...MOCK_CONTRIBUTOR, id: '1' }]); - - expect(component.showLoadMore()).toBe(true); + it('should return false from deactivatedContributors when all contributors are active', () => { + fixture.componentRef.setInput('contributors', [ + { ...MOCK_CONTRIBUTOR, id: '1', deactivated: false }, + { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', deactivated: false }, + ]); + fixture.detectChanges(); + expect(component.deactivatedContributors()).toBe(false); }); - it('should compute showLoadMore as false when contributors length matches total records', () => { - const contributors: ContributorModel[] = [ - { ...MOCK_CONTRIBUTOR, id: '1' }, - { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2' }, - { ...MOCK_CONTRIBUTOR, id: '3' }, - { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '4' }, - ]; - component.contributors.set(contributors); + it('should return false from deactivatedContributors when contributor list is empty', () => { + fixture.componentRef.setInput('contributors', []); + fixture.detectChanges(); + expect(component.deactivatedContributors()).toBe(false); + }); + it('should default showLoadMore to false', () => { expect(component.showLoadMore()).toBe(false); }); - it('should emit remove event when removeContributor is called', () => { - const contributor = { ...MOCK_CONTRIBUTOR, id: 'remove-id' }; + it('should reflect showLoadMore as true when set by parent', () => { + fixture.componentRef.setInput('showLoadMore', true); + fixture.detectChanges(); + expect(component.showLoadMore()).toBe(true); + }); + + it('should emit remove event with the given contributor when removeContributor is called', () => { + const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'remove-id' }; vi.spyOn(component.remove, 'emit'); component.removeContributor(contributor); @@ -106,7 +117,7 @@ describe('ContributorsTableComponent', () => { expect(component.loadMore.emit).toHaveBeenCalled(); }); - it('should open education history dialog with contributor education data', () => { + it('should open EducationHistoryDialogComponent with contributor education data', () => { const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'education-id', @@ -133,7 +144,19 @@ describe('ContributorsTableComponent', () => { }); }); - it('should open employment history dialog with contributor employment data', () => { + it('should open EducationHistoryDialogComponent with an empty education array', () => { + const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'no-education-id', education: [] }; + + component.openEducationHistory(contributor); + + expect(mockCustomDialogService.open).toHaveBeenCalledWith(EducationHistoryDialogComponent, { + header: 'project.contributors.table.headers.education', + width: '552px', + data: [], + }); + }); + + it('should open EmploymentHistoryDialogComponent with contributor employment data', () => { const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'employment-id', @@ -160,16 +183,42 @@ describe('ContributorsTableComponent', () => { }); }); - it('should reorder contributors indices using table firstRowIndex', () => { - const contributors: ContributorModel[] = [ + it('should open EmploymentHistoryDialogComponent with an empty employment array', () => { + const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'no-employment-id', employment: [] }; + + component.openEmploymentHistory(contributor); + + expect(mockCustomDialogService.open).toHaveBeenCalledWith(EmploymentHistoryDialogComponent, { + header: 'project.contributors.table.headers.employment', + width: '552px', + data: [], + }); + }); + + it('should reindex contributors starting from tableParams.firstRowIndex on row reorder', () => { + fixture.componentRef.setInput('tableParams', makeTableParams({ firstRowIndex: 10 })); + fixture.componentRef.setInput('contributors', [ { ...MOCK_CONTRIBUTOR, id: '1', index: 0 }, { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', index: 1 }, { ...MOCK_CONTRIBUTOR, id: '3', index: 2 }, - ]; - component.contributors.set(contributors); + ]); + fixture.detectChanges(); + + component.onRowReorder(); + + expect(component.contributors().map((c) => c.index)).toEqual([10, 11, 12]); + }); + + it('should reindex contributors from 0 when firstRowIndex is 0 on row reorder', () => { + fixture.componentRef.setInput('tableParams', makeTableParams({ firstRowIndex: 0 })); + fixture.componentRef.setInput('contributors', [ + { ...MOCK_CONTRIBUTOR, id: '1', index: 5 }, + { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', index: 6 }, + ]); + fixture.detectChanges(); component.onRowReorder(); - expect(component.contributors().map((item) => item.index)).toEqual([10, 11, 12]); + expect(component.contributors().map((c) => c.index)).toEqual([0, 1]); }); }); diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts index d41244e28..800c35066 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts @@ -15,7 +15,6 @@ import { ContributorPermission } from '@osf/shared/enums/contributors/contributo import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ContributorModel } from '@shared/models/contributors/contributor.model'; -import { SelectOption } from '@shared/models/select-option.model'; import { TableParameters } from '@shared/models/table-parameters.model'; import { EducationHistoryDialogComponent } from '../../education-history-dialog/education-history-dialog.component'; @@ -61,10 +60,10 @@ export class ContributorsTableComponent { customDialogService = inject(CustomDialogService); - readonly permissionsOptions: SelectOption[] = PERMISSION_OPTIONS; + readonly permissionsOptions = PERMISSION_OPTIONS; readonly ContributorPermission = ContributorPermission; - skeletonData: ContributorModel[] = Array.from({ length: 3 }, () => ({}) as ContributorModel); + skeletonData = Array.from({ length: 3 }, () => ({}) as ContributorModel); isProject = computed(() => this.resourceType() === ResourceType.Project); From d8f827c3fb2a4b8bfe9496bbabab40cd20952828 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 1 May 2026 17:07:42 +0300 Subject: [PATCH 8/9] [ENG-9957] Users unable to delete/remove files from draft registrations when starting from scratch (#972) - Ticket: https://openscience.atlassian.net/browse/ENG-9957 - Feature flag: n/a ## Purpose Users should be able to remove or delete files from a registration draft. ## Summary of Changes created from the following one not merged yet PR to keep related files changings https://github.com/CenterForOpenScience/angular-osf/pull/957 add delete file option for draft registration --- .../files/pages/files/files.component.html | 2 +- .../features/files/pages/files/files.component.ts | 4 ++-- .../custom-step/custom-step.component.html | 3 ++- .../custom-step/custom-step.component.spec.ts | 4 ++-- .../custom-step/custom-step.component.ts | 4 ++-- .../files-control/files-control.component.html | 2 ++ .../files-control/files-control.component.spec.ts | 15 +++++++++++++++ .../files-control/files-control.component.ts | 11 +++++++++++ .../registries/store/handlers/files.handlers.ts | 11 ++++++++++- .../registries/store/registries.actions.ts | 6 ++++++ .../features/registries/store/registries.state.ts | 6 ++++++ .../files-tree/files-tree.component.html | 12 ++++++++++++ .../components/files-tree/files-tree.component.ts | 13 +++++++++---- 13 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 43d076f6b..10a2f47bb 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -144,8 +144,8 @@ (selectFile)="onFileTreeSelected($event)" (unselectFile)="onFileTreeUnselected($event)" (clearSelection)="onClearSelection()" - (entryFileClicked)="navigateToFile($event)" (deleteEntryAction)="deleteEntry($event)" + (entryFileClicked)="navigateToFile($event)" (renameEntryAction)="renameEntry($event)" (uploadFilesConfirmed)="uploadFiles($event)" (loadFiles)="onLoadFiles($event)" diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index a264e749a..00ab2a2ef 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -619,8 +619,8 @@ export class FilesComponent { this.actions.setMoveDialogCurrentFolder(folder); } - deleteEntry(link: string) { - this.actions.deleteEntry(link).subscribe(() => { + deleteEntry(file: FileModel): void { + this.actions.deleteEntry(file?.links.delete).subscribe(() => { this.toastService.showSuccess('files.dialogs.deleteFile.success'); this.updateFilesList(); }); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index 6bdc77c6e..e3d0540a6 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.html +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -168,7 +168,7 @@

{{ 'files.actions.uploadFile' | translate }}

[label]="file.name" severity="info" removable="true" - (onRemove)="removeFromAttachedFiles(file, q.responseKey!)" + (onRemove)="removeFromAttachedFiles(file.file_id, q.responseKey!)" /> } @@ -181,6 +181,7 @@

{{ 'files.actions.uploadFile' | translate }}

(attachFile)="onAttachFile($event, q.responseKey!)" (openFile)="onOpenFile($event)" [filesViewOnly]="filesViewOnly()" + (removeFromAttachedFiles)="removeFromAttachedFiles($event, q.responseKey!)" > } diff --git a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts index b17f69381..68cca32a7 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts @@ -213,7 +213,7 @@ describe('CustomStepComponent', () => { { file_id: 'f2', name: 'b' }, ]; - component.removeFromAttachedFiles({ file_id: 'f1', name: 'a' }, 'field1'); + component.removeFromAttachedFiles('f1', 'field1'); expect(component.attachedFiles['field1'].length).toBe(1); expect(component.attachedFiles['field1'][0].file_id).toBe('f2'); @@ -223,7 +223,7 @@ describe('CustomStepComponent', () => { it('should skip non-existent questionKey', () => { const { component } = setup(); const emitSpy = vi.spyOn(component.updateAction, 'emit'); - component.removeFromAttachedFiles({ file_id: 'f1' }, 'nonexistent'); + component.removeFromAttachedFiles('f1', 'nonexistent'); expect(emitSpy).not.toHaveBeenCalled(); }); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 7b5f066e6..484b5e4a9 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -143,12 +143,12 @@ export class CustomStepComponent implements OnDestroy { } } - removeFromAttachedFiles(file: AttachedFile, questionKey: string): void { + removeFromAttachedFiles(fileId: string | undefined, questionKey: string): void { if (!this.attachedFiles[questionKey]) { return; } - this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== file.file_id); + this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== fileId); this.stepForm.patchValue({ [questionKey]: this.attachedFiles[questionKey] }); this.updateAction.emit({ [questionKey]: this.mapFilesToPayload(this.attachedFiles[questionKey]), diff --git a/src/app/features/registries/components/files-control/files-control.component.html b/src/app/features/registries/components/files-control/files-control.component.html index 78c18d21c..951fcac20 100644 --- a/src/app/features/registries/components/files-control/files-control.component.html +++ b/src/app/features/registries/components/files-control/files-control.component.html @@ -51,6 +51,8 @@ [resourceId]="projectId()" [provider]="provider()" [selectedFiles]="filesSelection" + [isDraftResource]="true" + (deleteEntryAction)="deleteEntry($event)" (selectFile)="onFileTreeSelected($event)" (entryFileClicked)="onEntryFileClicked($event)" (uploadFilesConfirmed)="uploadFiles($event)" diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts index 874ea26d7..1557f575a 100644 --- a/src/app/features/registries/components/files-control/files-control.component.spec.ts +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -228,4 +228,19 @@ describe('FilesControlComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetFilesIsLoading)); expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetFiles)); }); + + it('should delete entry, show success toast, refresh files, and emit removal', () => { + const file = { id: 'file-1', links: { delete: '/delete-link' } } as FileModel; + const deleteSpy = vi.spyOn(component['actions'], 'deleteDraftRegistrationFiles').mockReturnValue(of(void 0)); + const refreshSpy = vi.spyOn(component as any, 'refreshFilesList'); + const emitSpy = vi.spyOn(component.removeFromAttachedFiles, 'emit'); + const toastSpy = vi.spyOn(toastService, 'showSuccess'); + + component.deleteEntry(file); + + expect(deleteSpy).toHaveBeenCalledWith('/delete-link'); + expect(toastSpy).toHaveBeenCalledWith('files.dialogs.deleteFile.success'); + expect(refreshSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith('file-1'); + }); }); diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index 604141db5..fd367abbc 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -25,6 +25,7 @@ import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { CreateFolder, + DeleteDraftRegistrationFiles, GetFiles, GetRootFolders, RegistriesSelectors, @@ -54,6 +55,7 @@ export class FilesControlComponent { provider = input.required(); filesViewOnly = input(false); attachFile = output(); + removeFromAttachedFiles = output(); openFile = output(); private readonly filesService = inject(FilesService); @@ -79,6 +81,7 @@ export class FilesControlComponent { setFilesIsLoading: SetFilesIsLoading, setCurrentFolder: SetRegistriesCurrentFolder, getRootFolders: GetRootFolders, + deleteDraftRegistrationFiles: DeleteDraftRegistrationFiles, }); constructor() { @@ -86,6 +89,14 @@ export class FilesControlComponent { this.setupCurrentFolderWatcher(); } + deleteEntry(file: FileModel): void { + this.actions.deleteDraftRegistrationFiles(file?.links.delete).subscribe(() => { + this.toastService.showSuccess('files.dialogs.deleteFile.success'); + this.refreshFilesList(); + this.removeFromAttachedFiles.emit(file.id); + }); + } + onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; diff --git a/src/app/features/registries/store/handlers/files.handlers.ts b/src/app/features/registries/store/handlers/files.handlers.ts index e6b5d4f38..615386a72 100644 --- a/src/app/features/registries/store/handlers/files.handlers.ts +++ b/src/app/features/registries/store/handlers/files.handlers.ts @@ -7,7 +7,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { FilesService } from '@osf/shared/services/files.service'; -import { CreateFolder, GetFiles, GetRootFolders } from '../registries.actions'; +import { CreateFolder, DeleteDraftRegistrationFiles, GetFiles, GetRootFolders } from '../registries.actions'; import { RegistriesStateModel } from '../registries.model'; @Injectable() @@ -70,4 +70,13 @@ export class FilesHandlers { .createFolder(action.newFolderLink, action.folderName) .pipe(finalize(() => ctx.patchState({ files: { ...state.files, isLoading: false, error: null } }))); } + + deleteDraftRegistrationFiles(ctx: StateContext, action: DeleteDraftRegistrationFiles) { + const state = ctx.getState(); + ctx.patchState({ files: { ...state.files, isLoading: true, error: null } }); + + return this.filesService + .deleteEntry(action.link) + .pipe(finalize(() => ctx.patchState({ files: { ...state.files, isLoading: false, error: null } }))); + } } diff --git a/src/app/features/registries/store/registries.actions.ts b/src/app/features/registries/store/registries.actions.ts index 45db0f8f9..25a553773 100644 --- a/src/app/features/registries/store/registries.actions.ts +++ b/src/app/features/registries/store/registries.actions.ts @@ -135,6 +135,12 @@ export class GetFiles { ) {} } +export class DeleteDraftRegistrationFiles { + static readonly type = '[Registries] Delete Draft Registration Files'; + + constructor(public link: string) {} +} + export class SetFilesIsLoading { static readonly type = '[Registries] Set Files Loading'; diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index d24602bbf..c2373159c 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -23,6 +23,7 @@ import { CreateFolder, CreateSchemaResponse, DeleteDraft, + DeleteDraftRegistrationFiles, DeleteSchemaResponse, FetchAllSchemaResponses, FetchDraft, @@ -351,6 +352,11 @@ export class RegistriesState { return this.filesHandlers.getProjectFiles(ctx, { filesLink, page }); } + @Action(DeleteDraftRegistrationFiles) + deleteDraftRegistrationFiles(ctx: StateContext, action: DeleteDraftRegistrationFiles) { + return this.filesHandlers.deleteDraftRegistrationFiles(ctx, action); + } + @Action(GetRootFolders) getRootFolders(ctx: StateContext, action: GetRootFolders) { return this.filesHandlers.getRootFolders(ctx, action); diff --git a/src/app/shared/components/files-tree/files-tree.component.html b/src/app/shared/components/files-tree/files-tree.component.html index fa2954b12..ba61a2c22 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -95,6 +95,18 @@ } + @if (isDraftResource()) { + + } } diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index f08df941e..efe638512 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -3,6 +3,8 @@ import { select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { PrimeTemplate, TreeNode } from 'primeng/api'; +import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; import { Tree, TreeLazyLoadEvent, TreeNodeDropEvent, TreeNodeSelectEvent } from 'primeng/tree'; import { Clipboard } from '@angular/cdk/clipboard'; @@ -66,6 +68,8 @@ type FileTreeNode = FileModel & TreeNode; LoadingSpinnerComponent, FileMenuComponent, StopPropagationDirective, + Button, + Tooltip, ], templateUrl: './files-tree.component.html', styleUrl: './files-tree.component.scss', @@ -101,12 +105,13 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { selectedFiles = input([]); scrollHeight = input('300px'); selectionMode = input<'multiple' | null>('multiple'); + isDraftResource = input(false); entryFileClicked = output(); uploadFilesConfirmed = output(); setCurrentFolder = output(); setMoveDialogCurrentFolder = output(); - deleteEntryAction = output(); + deleteEntryAction = output(); renameEntryAction = output<{ newName: string; link: string }>(); loadFiles = output<{ link: string; page: number }>(); selectFile = output(); @@ -344,12 +349,12 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { messageKey: file.kind === FileKind.Folder ? 'files.dialogs.deleteFolder.message' : 'files.dialogs.deleteFile.message', acceptLabelKey: 'common.buttons.remove', - onConfirm: () => this.confirmDeleteEntry(file.links.delete), + onConfirm: () => this.confirmDeleteEntry(file), }); } - confirmDeleteEntry(link: string): void { - this.deleteEntryAction.emit(link); + confirmDeleteEntry(file: FileModel): void { + this.deleteEntryAction.emit(file); } confirmRename(file: FileModel): void { From 3803e728beebb7a961e508fbe9c969fca003aac9 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 1 May 2026 19:03:08 +0300 Subject: [PATCH 9/9] fix(preprint-details): added create new version for rejected status --- .../pages/preprint-details/preprint-details.component.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index ae0b35450..760b76ce1 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -186,7 +186,9 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { const preprint = this.preprint(); if (!preprint) return false; - return this.hasAdminAccess() && preprint.datePublished && preprint.isLatestVersion; + const preprintIsRejected = preprint.reviewsState === ReviewsState.Rejected; + + return this.hasAdminAccess() && (preprint.datePublished || preprintIsRejected) && preprint.isLatestVersion; }); editButtonVisible = computed(() => {