diff --git a/src/app/features/collections/collections.routes.ts b/src/app/features/collections/collections.routes.ts index d7743827c..1dbd7bc85 100644 --- a/src/app/features/collections/collections.routes.ts +++ b/src/app/features/collections/collections.routes.ts @@ -3,15 +3,20 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { authGuard } from '@core/guards/auth.guard'; -import { AddToCollectionState } from '@osf/features/collections/store/add-to-collection'; -import { CollectionsModerationState } from '@osf/features/moderation/store/collections-moderation'; -import { ConfirmLeavingGuard } from '@shared/guards'; -import { BookmarksState } from '@shared/stores/bookmarks'; -import { CitationsState } from '@shared/stores/citations'; -import { CollectionsState } from '@shared/stores/collections'; -import { NodeLinksState } from '@shared/stores/node-links'; -import { ProjectsState } from '@shared/stores/projects'; -import { SubjectsState } from '@shared/stores/subjects'; +import { ConfirmLeavingGuard } from '@osf/shared/guards'; +import { ActivityLogsState } from '@osf/shared/stores/activity-logs'; +import { BookmarksState } from '@osf/shared/stores/bookmarks'; +import { CitationsState } from '@osf/shared/stores/citations'; +import { CollectionsState } from '@osf/shared/stores/collections'; +import { NodeLinksState } from '@osf/shared/stores/node-links'; +import { ProjectsState } from '@osf/shared/stores/projects'; +import { SubjectsState } from '@osf/shared/stores/subjects'; + +import { CollectionsModerationState } from '../moderation/store/collections-moderation'; +import { ProjectOverviewState } from '../project/overview/store'; +import { SettingsState } from '../project/settings/store'; + +import { AddToCollectionState } from './store/add-to-collection'; export const collectionsRoutes: Routes = [ { @@ -48,6 +53,16 @@ export const collectionsRoutes: Routes = [ canActivate: [authGuard], canDeactivate: [ConfirmLeavingGuard], }, + { + path: ':providerId/:id/edit', + loadComponent: () => + import('@osf/features/collections/components/add-to-collection/add-to-collection.component').then( + (mod) => mod.AddToCollectionComponent + ), + providers: [provideStates([ProjectsState, CollectionsState, AddToCollectionState])], + canActivate: [authGuard], + canDeactivate: [ConfirmLeavingGuard], + }, { path: ':providerId/moderation', canActivate: [authGuard], @@ -63,12 +78,15 @@ export const collectionsRoutes: Routes = [ ).then((mod) => mod.CollectionSubmissionOverviewComponent), providers: [ provideStates([ + ProjectOverviewState, NodeLinksState, CitationsState, - CollectionsModerationState, CollectionsState, + CollectionsModerationState, + ActivityLogsState, BookmarksState, SubjectsState, + SettingsState, ]), ], }, diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.spec.ts index c5acea141..3845970db 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.spec.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.spec.ts @@ -1,7 +1,7 @@ -import { MockProvider } from 'ng-mocks'; - import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { of, throwError } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -11,49 +11,47 @@ import { AddToCollectionConfirmationDialogComponent } from './add-to-collection- import { MOCK_PROJECT } from '@testing/mocks/project.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; describe('AddToCollectionConfirmationDialogComponent', () => { let component: AddToCollectionConfirmationDialogComponent; let fixture: ComponentFixture; - let mockDialogRef: DynamicDialogRef; - let toastServiceMock: ReturnType; - - const mockPayload = { - collectionId: 'collection-1', - projectId: 'project-1', - collectionMetadata: { title: 'Test Collection' }, - userId: 'user-1', - }; - - const mockProject = MOCK_PROJECT; + let dialogRef: DynamicDialogRef; + let toastService: jest.Mocked; + let configData: { payload?: any; project?: any }; + let updateProjectPublicStatus: jest.Mock; + let createCollectionSubmission: jest.Mock; beforeEach(async () => { - mockDialogRef = { - close: jest.fn(), - } as any; - - toastServiceMock = ToastServiceMockBuilder.create().build(); + dialogRef = { close: jest.fn() } as any; + toastService = { showSuccess: jest.fn() } as any; + configData = { + payload: { + collectionId: 'collection-1', + projectId: 'project-1', + collectionMetadata: { title: 'Test Collection' }, + userId: 'user-1', + }, + project: { ...MOCK_PROJECT, isPublic: false, id: 'project-1' }, + }; + updateProjectPublicStatus = jest.fn().mockReturnValue(of(null)); + createCollectionSubmission = jest.fn().mockReturnValue(of(null)); await TestBed.configureTestingModule({ imports: [AddToCollectionConfirmationDialogComponent, OSFTestingModule], providers: [ - MockProvider(DynamicDialogRef, mockDialogRef), - MockProvider(ToastService, toastServiceMock), - MockProvider(DynamicDialogConfig, { - data: { - payload: mockPayload, - project: mockProject, - }, - }), - provideMockStore({ - signals: [], - }), + { provide: DynamicDialogRef, useValue: dialogRef }, + { provide: ToastService, useValue: toastService }, + { provide: DynamicDialogConfig, useValue: { data: configData } }, + provideMockStore({ signals: [] }), ], }).compileComponents(); fixture = TestBed.createComponent(AddToCollectionConfirmationDialogComponent); component = fixture.componentInstance; + component.actions = { + updateProjectPublicStatus, + createCollectionSubmission, + } as any; fixture.detectChanges(); }); @@ -61,38 +59,43 @@ describe('AddToCollectionConfirmationDialogComponent', () => { expect(component).toBeTruthy(); }); - it('should initialize with dialog data', () => { - expect(component.config.data.payload).toEqual(mockPayload); - expect(component.config.data.project).toEqual(mockProject); + it('should dispatch updates and close on confirm when project is private', () => { + component.handleAddToCollectionConfirm(); + + expect(updateProjectPublicStatus).toHaveBeenCalledWith([{ id: 'project-1', public: true }]); + expect(createCollectionSubmission).toHaveBeenCalledWith(configData.payload); + expect(dialogRef.close).toHaveBeenCalledWith(true); + expect(toastService.showSuccess).toHaveBeenCalledWith('collections.addToCollection.confirmationDialogToastMessage'); + expect(component.isSubmitting()).toBe(false); }); - it('should handle add to collection confirmation', () => { + it('should skip public status update when project already public', () => { + configData.project.isPublic = true; + updateProjectPublicStatus.mockClear(); + component.handleAddToCollectionConfirm(); - expect(mockDialogRef.close).toHaveBeenCalledWith(true); + expect(updateProjectPublicStatus).not.toHaveBeenCalled(); + expect(createCollectionSubmission).toHaveBeenCalledWith(configData.payload); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); - it('should have config data', () => { - expect(component.config.data.payload).toBeDefined(); - expect(component.config.data.payload.collectionId).toBe('collection-1'); - expect(component.config.data.payload.projectId).toBe('project-1'); - expect(component.config.data.payload.userId).toBe('user-1'); - }); + it('should do nothing when payload or project is missing', () => { + configData.payload = undefined; + component.handleAddToCollectionConfirm(); - it('should have project data in config', () => { - expect(component.config.data.project).toBeDefined(); - expect(component.config.data.project.id).toBe('project-1'); - expect(component.config.data.project.title).toBe('Test Project'); - expect(component.config.data.project.isPublic).toBe(true); + expect(updateProjectPublicStatus).not.toHaveBeenCalled(); + expect(createCollectionSubmission).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); }); - it('should have actions defined', () => { - expect(component.actions).toBeDefined(); - expect(component.actions.createCollectionSubmission).toBeDefined(); - expect(component.actions.updateProjectPublicStatus).toBeDefined(); - }); + it('should reset submitting state and not close on error', () => { + createCollectionSubmission.mockReturnValue(throwError(() => new Error('fail'))); + + component.handleAddToCollectionConfirm(); - it('should have isSubmitting signal', () => { expect(component.isSubmitting()).toBe(false); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html index e60b9c227..41cf077d5 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html @@ -14,13 +14,15 @@

{{ collectionProvider()?
- + @if (!isEditMode()) { + + } {{ collectionProvider()? /> -
- - +
+ @if (showRemoveButton()) { + + } + +
+ + + +
diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts index cb2b5999a..499939be5 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts @@ -37,7 +37,7 @@ describe('AddToCollectionComponent', () => { beforeEach(async () => { mockRouter = RouterMockBuilder.create().build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: '1' }).build(); + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: null }).build(); mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); await TestBed.configureTestingModule({ diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts index 60378f381..ff55e38f1 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Stepper } from 'primeng/stepper'; -import { Observable } from 'rxjs'; +import { filter, map, Observable, of, switchMap } from 'rxjs'; import { ChangeDetectionStrategy, @@ -17,25 +17,39 @@ import { inject, signal, } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { UserSelectors } from '@core/store/user'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { CanDeactivateComponent } from '@osf/shared/models/can-deactivate.interface'; import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { HeaderStyleService } from '@osf/shared/services/header-style.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; import { CollectionsSelectors, GetCollectionProvider } from '@osf/shared/stores/collections'; -import { ProjectsSelectors } from '@osf/shared/stores/projects'; +import { ProjectsSelectors, SetSelectedProject } from '@osf/shared/stores/projects'; import { AddToCollectionSteps } from '../../enums'; -import { ClearAddToCollectionState, CreateCollectionSubmission } from '../../store/add-to-collection'; +import { RemoveCollectionSubmissionPayload } from '../../models/remove-collection-submission-payload.model'; +import { RemoveFromCollectionDialogResult } from '../../models/remove-from-collection-dialog-result.model'; +import { + AddToCollectionSelectors, + ClearAddToCollectionState, + CreateCollectionSubmission, + GetCurrentCollectionSubmission, + RemoveCollectionSubmission, + UpdateCollectionSubmission, +} from '../../store/add-to-collection'; import { AddToCollectionConfirmationDialogComponent } from './add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component'; import { CollectionMetadataStepComponent } from './collection-metadata-step/collection-metadata-step.component'; import { ProjectContributorsStepComponent } from './project-contributors-step/project-contributors-step.component'; import { ProjectMetadataStepComponent } from './project-metadata-step/project-metadata-step.component'; +import { RemoveFromCollectionDialogComponent } from './remove-from-collection-dialog/remove-from-collection-dialog.component'; import { SelectProjectStepComponent } from './select-project-step/select-project-step.component'; @Component({ @@ -60,6 +74,12 @@ export class AddToCollectionComponent implements CanDeactivateComponent { private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); private readonly customDialogService = inject(CustomDialogService); + private readonly toastService = inject(ToastService); + private readonly loaderService = inject(LoaderService); + + readonly selectedProjectId = toSignal( + this.route.params.pipe(map((params) => params['id'])) ?? of(null) + ); private readonly brandService = inject(BrandService); private readonly headerStyleHelper = inject(HeaderStyleService); @@ -70,6 +90,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { collectionProvider = select(CollectionsSelectors.getCollectionProvider); selectedProject = select(ProjectsSelectors.getSelectedProject); currentUser = select(UserSelectors.getCurrentUser); + currentCollectionSubmission = select(AddToCollectionSelectors.getCurrentCollectionSubmission); providerId = signal(''); allowNavigation = signal(false); projectMetadataSaved = signal(false); @@ -77,6 +98,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { collectionMetadataSaved = signal(false); stepperActiveValue = signal(AddToCollectionSteps.SelectProject); primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id); + isEditMode = computed(() => !!this.selectedProjectId()); isProjectMetadataDisabled = computed(() => !this.selectedProject()); isProjectContributorsDisabled = computed(() => !this.selectedProject() || !this.projectMetadataSaved()); isCollectionMetadataDisabled = computed( @@ -87,14 +109,38 @@ export class AddToCollectionComponent implements CanDeactivateComponent { getCollectionProvider: GetCollectionProvider, clearAddToCollectionState: ClearAddToCollectionState, createCollectionSubmission: CreateCollectionSubmission, + updateCollectionSubmission: UpdateCollectionSubmission, + deleteCollectionSubmission: RemoveCollectionSubmission, + setSelectedProject: SetSelectedProject, + getCurrentCollectionSubmission: GetCurrentCollectionSubmission, }); + showRemoveButton = computed( + () => + this.isEditMode() && + this.currentCollectionSubmission()?.submission.reviewsState === CollectionSubmissionReviewState.Accepted + ); + constructor() { this.initializeProvider(); this.setupEffects(); this.setupCleanup(); } + @HostListener('window:beforeunload', ['$event']) + onBeforeUnload($event: BeforeUnloadEvent): boolean { + $event.preventDefault(); + return false; + } + + canDeactivate(): Observable | boolean { + if (this.allowNavigation()) { + return true; + } + + return !this.hasUnsavedChanges(); + } + handleProjectSelected(): void { this.projectContributorsSaved.set(false); this.projectMetadataSaved.set(false); @@ -128,17 +174,72 @@ export class AddToCollectionComponent implements CanDeactivateComponent { userId: this.currentUser()?.id || '', }; + const isEditMode = this.isEditMode(); + + if (isEditMode) { + this.loaderService.show(); + + this.actions + .updateCollectionSubmission(payload) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showSuccess('collections.addToCollection.confirmationDialogToastMessage'); + this.allowNavigation.set(true); + this.router.navigate([this.selectedProject()?.id, 'overview']); + }, + }); + } else { + this.customDialogService + .open(AddToCollectionConfirmationDialogComponent, { + header: 'collections.addToCollection.confirmationDialogHeader', + width: '500px', + data: { payload, project: this.selectedProject() }, + }) + .onClose.pipe( + filter((res) => !!res), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.allowNavigation.set(true); + this.router.navigate([this.selectedProject()?.id, 'overview']); + }); + } + } + + handleRemoveFromCollection() { + const projectId = this.selectedProject()?.id; + const collectionId = this.primaryCollectionId(); + const project = this.selectedProject(); + + if (!projectId || !collectionId || !project) return; + this.customDialogService - .open(AddToCollectionConfirmationDialogComponent, { - header: 'collections.addToCollection.confirmationDialogHeader', + .open(RemoveFromCollectionDialogComponent, { + header: 'collections.removeDialog.header', width: '500px', - data: { payload, project: this.selectedProject() }, + data: { projectTitle: project.title }, }) - .onClose.subscribe((result) => { - if (result) { + .onClose.pipe( + filter((res: RemoveFromCollectionDialogResult) => res?.confirmed), + switchMap((res) => { + const payload: RemoveCollectionSubmissionPayload = { + projectId, + collectionId, + comment: res?.comment || '', + }; + + return this.actions.deleteCollectionSubmission(payload); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.toastService.showSuccess('collections.removeDialog.success'); + this.loaderService.show(); this.allowNavigation.set(true); - this.router.navigate([this.selectedProject()?.id, 'overview']); - } + this.router.navigate([projectId, 'overview']); + }, }); } @@ -162,6 +263,24 @@ export class AddToCollectionComponent implements CanDeactivateComponent { this.headerStyleHelper.applyHeaderStyles(provider.brand.secondaryColor, provider.brand.backgroundColor || ''); } }); + + effect(() => { + const projectIdFromRoute = this.selectedProjectId(); + const collectionId = this.primaryCollectionId(); + + if (projectIdFromRoute && collectionId) { + this.stepperActiveValue.set(AddToCollectionSteps.ProjectMetadata); + this.actions.getCurrentCollectionSubmission(collectionId, projectIdFromRoute); + } + }); + + effect(() => { + const submission = this.currentCollectionSubmission(); + + if (submission?.project && !this.selectedProject()) { + this.actions.setSelectedProject(submission.project); + } + }); } private setupCleanup() { @@ -174,20 +293,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { }); } - @HostListener('window:beforeunload', ['$event']) - onBeforeUnload($event: BeforeUnloadEvent): boolean { - $event.preventDefault(); - return false; - } - - canDeactivate(): Observable | boolean { - if (this.allowNavigation()) { - return true; - } - - return !this.hasUnsavedChanges(); - } - private hasUnsavedChanges(): boolean { return ( !!this.selectedProject() || diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html index 262b0cd7b..f10094962 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html @@ -8,22 +8,25 @@

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

+ @if (!isDisabled() && stepperActiveValue() !== targetStepValue()) { @if (collectionMetadataSaved()) { @for (filterEntry of availableFilterEntries(); track filterEntry.key) {

{{ filterEntry.labelKey | translate }}

+

{{ collectionMetadataForm().get(filterEntry.key)?.value }}

} } + }
@@ -46,16 +49,17 @@

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

} -
+ +
diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts index 482bc76fa..8b17c2989 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts @@ -10,10 +10,12 @@ import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { collectionFilterTypes } from '@osf/features/collections/constants/filter-types.const'; -import { AddToCollectionSteps } from '@osf/features/collections/enums'; -import { CollectionFilterEntry } from '@osf/features/collections/models'; -import { CollectionsSelectors, GetCollectionDetails } from '@shared/stores/collections'; +import { collectionFilterTypes } from '@osf/features/collections/constants'; +import { AddToCollectionSteps, CollectionFilterType } from '@osf/features/collections/enums'; +import { CollectionFilterEntry } from '@osf/features/collections/models/collection-filter-entry.model'; +import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models'; +import { CollectionsSelectors, GetCollectionDetails } from '@osf/shared/stores/collections'; @Component({ selector: 'osf-collection-metadata-step', @@ -25,6 +27,7 @@ import { CollectionsSelectors, GetCollectionDetails } from '@shared/stores/colle export class CollectionMetadataStepComponent { private readonly filterTypes = collectionFilterTypes; readonly collectionFilterOptions = select(CollectionsSelectors.getAllFiltersOptions); + readonly currentCollectionSubmission = select(AddToCollectionSelectors.getCurrentCollectionSubmission); readonly availableFilterEntries = computed(() => { const options = this.collectionFilterOptions(); @@ -49,10 +52,9 @@ export class CollectionMetadataStepComponent { collectionMetadataForm = signal(new FormGroup({})); collectionMetadataSaved = signal(false); originalFormValues = signal>({}); + formPopulatedFromSubmission = signal(false); - actions = createDispatchMap({ - getCollectionDetails: GetCollectionDetails, - }); + actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); constructor() { this.setupEffects(); @@ -93,7 +95,16 @@ export class CollectionMetadataStepComponent { const newForm = new FormGroup(formControls); this.collectionMetadataForm.set(newForm); - this.updateOriginalValues(newForm); + this.formPopulatedFromSubmission.set(false); + + const submission = this.currentCollectionSubmission(); + + if (submission) { + this.populateFormFromSubmission(submission.submission); + this.formPopulatedFromSubmission.set(true); + } else { + this.updateOriginalValues(newForm); + } } private setupEffects(): void { @@ -111,9 +122,28 @@ export class CollectionMetadataStepComponent { } }); + effect(() => { + const submission = this.currentCollectionSubmission(); + const form = this.collectionMetadataForm(); + const filterEntries = this.availableFilterEntries(); + const alreadyPopulated = this.formPopulatedFromSubmission(); + + if ( + submission && + form.controls && + Object.keys(form.controls).length > 0 && + filterEntries.length > 0 && + !alreadyPopulated + ) { + this.populateFormFromSubmission(submission.submission); + this.formPopulatedFromSubmission.set(true); + } + }); + effect(() => { if (!this.collectionMetadataSaved() && this.stepperActiveValue() !== AddToCollectionSteps.CollectionMetadata) { this.collectionMetadataForm().reset(); + this.formPopulatedFromSubmission.set(false); } }); } @@ -139,4 +169,21 @@ export class CollectionMetadataStepComponent { }); this.originalFormValues.set(currentValues); } + + private populateFormFromSubmission(submission: CollectionSubmissionWithGuid): void { + const form = this.collectionMetadataForm(); + if (!form || !form.controls) return; + + Object.values(CollectionFilterType).forEach((filterType) => { + const control = form.get(filterType); + if (control) { + const value = submission[filterType as keyof CollectionSubmissionWithGuid] as string; + if (value) { + control.setValue(value, { emitEvent: false }); + } + } + }); + + this.updateOriginalValues(form); + } } diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts index 343c0db89..5120ddc96 100644 --- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts @@ -29,7 +29,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { AddToCollectionSteps, ProjectMetadataFormControls } from '@osf/features/collections/enums'; -import { ProjectMetadataForm } from '@osf/features/collections/models'; +import { ProjectMetadataForm } from '@osf/features/collections/models/project-metadata-form.model'; import { ProjectMetadataFormService } from '@osf/features/collections/services'; import { AddToCollectionSelectors, GetCollectionLicenses } from '@osf/features/collections/store/add-to-collection'; import { TagsInputComponent } from '@osf/shared/components/tags-input/tags-input.component'; diff --git a/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.html b/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.html new file mode 100644 index 000000000..fb7a47747 --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.html @@ -0,0 +1,15 @@ +

{{ 'collections.removeDialog.message' | translate: { projectTitle: projectTitle } }}

+ + + +
+ + +
diff --git a/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.scss b/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.spec.ts b/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.spec.ts new file mode 100644 index 000000000..9cff233f8 --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.spec.ts @@ -0,0 +1,56 @@ +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RemoveFromCollectionDialogComponent } from './remove-from-collection-dialog.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('RemoveFromCollectionDialogComponent', () => { + let component: RemoveFromCollectionDialogComponent; + let fixture: ComponentFixture; + let dialogRef: DynamicDialogRef; + + beforeEach(async () => { + dialogRef = { close: jest.fn() } as any; + + await TestBed.configureTestingModule({ + imports: [RemoveFromCollectionDialogComponent, OSFTestingModule], + providers: [ + { provide: DynamicDialogRef, useValue: dialogRef }, + { + provide: DynamicDialogConfig, + useValue: { data: { projectTitle: 'Project Alpha' } }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RemoveFromCollectionDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should read project title from config', () => { + expect(component.projectTitle).toBe('Project Alpha'); + }); + + it('should close dialog with confirm result including comment', () => { + component.comment.set('Reason text'); + component.confirm(); + expect(dialogRef.close).toHaveBeenCalledWith({ confirmed: true, comment: 'Reason text' }); + }); + + it('should close dialog with default comment when none provided', () => { + component.confirm(); + expect(dialogRef.close).toHaveBeenCalledWith({ confirmed: true, comment: '' }); + }); + + it('should close dialog without value on cancel', () => { + component.cancel(); + expect(dialogRef.close).toHaveBeenCalledWith(); + }); +}); diff --git a/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.ts b/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.ts new file mode 100644 index 000000000..b8c600726 --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/remove-from-collection-dialog/remove-from-collection-dialog.component.ts @@ -0,0 +1,40 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Textarea } from 'primeng/textarea'; + +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { RemoveFromCollectionDialogResult } from '@osf/features/collections/models/remove-from-collection-dialog-result.model'; +import { InputLimits } from '@osf/shared/constants/input-limits.const'; + +@Component({ + selector: 'osf-remove-from-collection-dialog', + imports: [Button, Textarea, TranslatePipe, FormsModule], + templateUrl: './remove-from-collection-dialog.component.html', + styleUrl: './remove-from-collection-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RemoveFromCollectionDialogComponent { + readonly config = inject(DynamicDialogConfig); + readonly dialogRef = inject(DynamicDialogRef); + + readonly projectTitle = this.config.data.projectTitle; + readonly comment = signal(''); + readonly commentLimit = InputLimits.decisionComment.maxLength; + + confirm(): void { + const result: RemoveFromCollectionDialogResult = { + confirmed: true, + comment: this.comment(), + }; + + this.dialogRef.close(result); + } + + cancel(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html index 2496b3829..4d9bab0a0 100644 --- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html +++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html @@ -3,13 +3,15 @@

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

+ @if (selectedProject() && stepperActiveValue() !== targetStepValue()) {

{{ 'collections.addToCollection.project' | translate }}: {{ selectedProject()?.title }}

+ { + const collectionId = payload.collectionId; + const metadata = CollectionsMapper.collectionSubmissionUpdateRequest(payload); + + return this.jsonApiService.patch( + `${this.apiUrl}/collections/${collectionId}/collection_submissions/${payload.projectId}/`, + metadata + ); + } + + removeCollectionSubmission(payload: RemoveCollectionSubmissionPayload): Observable { + const reviewActionPayload: ReviewActionPayload = { + action: 'remove', + targetId: `${payload.projectId}-${payload.collectionId}`, + comment: payload.comment, + }; + + const params = ReviewActionsMapper.toReviewActionPayloadJsonApi( + reviewActionPayload, + 'collection_submission_actions', + 'collection-submissions' + ); + + return this.jsonApiService.post(`${this.apiUrl}/collection_submission_actions/`, params); + } } diff --git a/src/app/features/collections/services/collections-query-sync.service.ts b/src/app/features/collections/services/collections-query-sync.service.ts index 7e1e6f5ef..22bb19bb4 100644 --- a/src/app/features/collections/services/collections-query-sync.service.ts +++ b/src/app/features/collections/services/collections-query-sync.service.ts @@ -7,11 +7,12 @@ import { ActivatedRoute, Router } from '@angular/router'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { collectionsSortOptions } from '@osf/features/collections/constants'; import { queryParamsKeys } from '@osf/features/collections/constants/query-params-keys.const'; -import { CollectionQueryParams } from '@osf/features/collections/models'; import { CollectionsFilters } from '@osf/shared/models/collections/collections-filters.model'; import { CollectionsSelectors, SetAllFilters, SetSearchValue, SetSortBy } from '@shared/stores/collections'; import { SetPageNumber } from '@shared/stores/collections/collections.actions'; +import { CollectionQueryParams } from '../models/collections-query-params.model'; + @Injectable() export class CollectionsQuerySyncService { private readonly Sentry = inject(SENTRY_TOKEN); diff --git a/src/app/features/collections/services/project-metadata-form.service.ts b/src/app/features/collections/services/project-metadata-form.service.ts index 02459c2dc..f0204c614 100644 --- a/src/app/features/collections/services/project-metadata-form.service.ts +++ b/src/app/features/collections/services/project-metadata-form.service.ts @@ -2,12 +2,13 @@ import { Injectable } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { ProjectMetadataFormControls } from '@osf/features/collections/enums'; -import { ProjectMetadataForm } from '@osf/features/collections/models'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectMetadataUpdatePayload } from '@shared/models/project-metadata-update-payload.model'; import { ProjectModel } from '@shared/models/projects/projects.models'; +import { ProjectMetadataForm } from '../models/project-metadata-form.model'; + @Injectable({ providedIn: 'root', }) diff --git a/src/app/features/collections/store/add-to-collection/add-to-collection.actions.ts b/src/app/features/collections/store/add-to-collection/add-to-collection.actions.ts index 4868808ed..8d0531014 100644 --- a/src/app/features/collections/store/add-to-collection/add-to-collection.actions.ts +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.actions.ts @@ -1,17 +1,40 @@ import { CollectionSubmissionPayload } from '@osf/shared/models/collections/collection-submission-payload.model'; +import { RemoveCollectionSubmissionPayload } from '../../models/remove-collection-submission-payload.model'; + export class GetCollectionLicenses { static readonly type = '[Add To Collection] Get Collection Licenses'; constructor(public providerId: string) {} } +export class GetCurrentCollectionSubmission { + static readonly type = '[Add To Collection] Get Current Collection Submission'; + + constructor( + public collectionId: string, + public projectId: string + ) {} +} + export class CreateCollectionSubmission { static readonly type = '[Add To Collection] Create Collection Submission'; constructor(public metadata: CollectionSubmissionPayload) {} } +export class UpdateCollectionSubmission { + static readonly type = '[Add To Collection] Update Collection Submission'; + + constructor(public metadata: CollectionSubmissionPayload) {} +} + +export class RemoveCollectionSubmission { + static readonly type = '[Add To Collection] Delete Collection Submission'; + + constructor(public payload: RemoveCollectionSubmissionPayload) {} +} + export class ClearAddToCollectionState { static readonly type = '[Add To Collection] Clear Add To Collection State'; } diff --git a/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts b/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts index f0b0a0b1f..04ad27492 100644 --- a/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts @@ -1,6 +1,21 @@ +import { CollectionProjectSubmission } from '@osf/shared/models/collections/collections.models'; import { LicenseModel } from '@shared/models/license/license.model'; import { AsyncStateModel } from '@shared/models/store/async-state.model'; export interface AddToCollectionStateModel { collectionLicenses: AsyncStateModel; + currentProjectSubmission: AsyncStateModel; } + +export const ADD_TO_COLLECTION_DEFAULTS: AddToCollectionStateModel = { + collectionLicenses: { + data: [], + isLoading: false, + error: null, + }, + currentProjectSubmission: { + data: null, + isLoading: false, + error: null, + }, +}; diff --git a/src/app/features/collections/store/add-to-collection/add-to-collection.selectors.ts b/src/app/features/collections/store/add-to-collection/add-to-collection.selectors.ts index 6cdf7f3d4..44bdb0e7b 100644 --- a/src/app/features/collections/store/add-to-collection/add-to-collection.selectors.ts +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.selectors.ts @@ -13,4 +13,9 @@ export class AddToCollectionSelectors { static getCollectionLicensesLoading(state: AddToCollectionStateModel) { return state.collectionLicenses.isLoading; } + + @Selector([AddToCollectionState]) + static getCurrentCollectionSubmission(state: AddToCollectionStateModel) { + return state.currentProjectSubmission.data; + } } diff --git a/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts b/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts index a0e843865..04a1848c0 100644 --- a/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts @@ -7,21 +7,17 @@ import { inject, Injectable } from '@angular/core'; import { AddToCollectionService } from '@osf/features/collections/services/add-to-collection.service'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; +import { CollectionsService } from '@osf/shared/services/collections.service'; import { ClearAddToCollectionState, CreateCollectionSubmission, GetCollectionLicenses, + GetCurrentCollectionSubmission, + RemoveCollectionSubmission, + UpdateCollectionSubmission, } from './add-to-collection.actions'; -import { AddToCollectionStateModel } from './add-to-collection.model'; - -const ADD_TO_COLLECTION_DEFAULTS = { - collectionLicenses: { - data: [], - isLoading: false, - error: null, - }, -}; +import { ADD_TO_COLLECTION_DEFAULTS, AddToCollectionStateModel } from './add-to-collection.model'; @State({ name: 'addToCollection', @@ -30,6 +26,7 @@ const ADD_TO_COLLECTION_DEFAULTS = { @Injectable() export class AddToCollectionState { addToCollectionService = inject(AddToCollectionService); + collectionsService = inject(CollectionsService); @Action(GetCollectionLicenses) getCollectionLicenses(ctx: StateContext, action: GetCollectionLicenses) { @@ -55,11 +52,45 @@ export class AddToCollectionState { ); } + @Action(GetCurrentCollectionSubmission) + getCurrentCollectionSubmission(ctx: StateContext, action: GetCurrentCollectionSubmission) { + const state = ctx.getState(); + ctx.patchState({ + collectionLicenses: { + ...state.collectionLicenses, + isLoading: true, + }, + }); + + return this.collectionsService.fetchProjectSubmission(action.collectionId, action.projectId).pipe( + tap((res) => { + ctx.patchState({ + currentProjectSubmission: { + data: res, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'currentProjectSubmission', error)) + ); + } + @Action(CreateCollectionSubmission) createCollectionSubmission(ctx: StateContext, action: CreateCollectionSubmission) { return this.addToCollectionService.createCollectionSubmission(action.metadata); } + @Action(UpdateCollectionSubmission) + updateCollectionSubmission(ctx: StateContext, action: UpdateCollectionSubmission) { + return this.addToCollectionService.updateCollectionSubmission(action.metadata); + } + + @Action(RemoveCollectionSubmission) + removeCollectionSubmission(ctx: StateContext, action: RemoveCollectionSubmission) { + return this.addToCollectionService.removeCollectionSubmission(action.payload); + } + @Action(ClearAddToCollectionState) clearAddToCollection(ctx: StateContext) { ctx.patchState(ADD_TO_COLLECTION_DEFAULTS); diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html new file mode 100644 index 000000000..f851bd77a --- /dev/null +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html @@ -0,0 +1,32 @@ +
+ + + @if (showSubmissionButton()) { + + } +
+ +
+ @let status = submission().reviewsState; + +
+ +@if (showAttributes()) { +
+ @for (attribute of attributes(); track attribute.key) { +

+ {{ attribute.label }}: {{ attribute.value }} +

+ } +
+} diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.scss b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts new file mode 100644 index 000000000..af5c251b3 --- /dev/null +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts @@ -0,0 +1,152 @@ +import { MockComponents } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; + +import { MetadataCollectionItemComponent } from './metadata-collection-item.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('MetadataCollectionItemComponent', () => { + let component: MetadataCollectionItemComponent; + let fixture: ComponentFixture; + + const mockSubmission: CollectionSubmission = { + id: '1', + type: 'collection-submission', + collectionTitle: 'Test Collection', + collectionId: 'collection-123', + reviewsState: CollectionSubmissionReviewState.Pending, + collectedType: 'preprint', + status: 'pending', + volume: '1', + issue: '1', + programArea: 'Science', + schoolType: 'University', + studyDesign: 'Experimental', + dataType: 'Quantitative', + disease: 'Cancer', + gradeLevels: 'Graduate', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MetadataCollectionItemComponent, OSFTestingModule, ...MockComponents()], + }).compileComponents(); + + fixture = TestBed.createComponent(MetadataCollectionItemComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('submission', mockSubmission); + fixture.detectChanges(); + }); + + it('should initialize with submission input', () => { + expect(component.submission()).toEqual(mockSubmission); + }); + + it('should compute attributes from submission', () => { + const attributes = component.attributes(); + expect(attributes.length).toBeGreaterThan(0); + expect(attributes.some((attr) => attr.key === 'programArea' && attr.value === 'Science')).toBe(true); + expect(attributes.some((attr) => attr.key === 'collectedType' && attr.value === 'preprint')).toBe(true); + expect(attributes.some((attr) => attr.key === 'dataType' && attr.value === 'Quantitative')).toBe(true); + }); + + it('should filter out empty attributes', () => { + const submissionWithEmptyFields: CollectionSubmission = { + ...mockSubmission, + programArea: '', + disease: '', + gradeLevels: '', + }; + fixture.componentRef.setInput('submission', submissionWithEmptyFields); + fixture.detectChanges(); + + const attributes = component.attributes(); + expect(attributes.some((attr) => attr.key === 'programArea')).toBe(false); + expect(attributes.some((attr) => attr.key === 'disease')).toBe(false); + expect(attributes.some((attr) => attr.key === 'gradeLevels')).toBe(false); + }); + + it('should toggle submission button visibility based on reviews state', () => { + fixture.componentRef.setInput('submission', { + ...mockSubmission, + reviewsState: CollectionSubmissionReviewState.Accepted, + }); + fixture.detectChanges(); + + const submissionButtonVisible = fixture.nativeElement.querySelector('p-button[severity="secondary"]'); + expect(submissionButtonVisible).toBeTruthy(); + + fixture.componentRef.setInput('submission', { + ...mockSubmission, + reviewsState: CollectionSubmissionReviewState.Pending, + }); + fixture.detectChanges(); + + const submissionButtonHidden = fixture.nativeElement.querySelector('p-button[severity="secondary"]'); + expect(submissionButtonHidden).toBeFalsy(); + }); + + it('should switch submission button label for removed status', () => { + fixture.componentRef.setInput('submission', { + ...mockSubmission, + reviewsState: CollectionSubmissionReviewState.Accepted, + status: CollectionSubmissionReviewState.Removed, + }); + fixture.detectChanges(); + + expect(component.submissionButtonLabel()).toBe('common.buttons.resubmit'); + + fixture.componentRef.setInput('submission', { + ...mockSubmission, + reviewsState: CollectionSubmissionReviewState.Accepted, + status: CollectionSubmissionReviewState.Accepted, + }); + fixture.detectChanges(); + + expect(component.submissionButtonLabel()).toBe('common.buttons.edit'); + }); + + it('should not display attributes section when all fields are empty', () => { + const submissionWithNoAttributes: CollectionSubmission = { + id: '1', + type: 'collection-submission', + collectionTitle: 'Test Collection', + collectionId: 'collection-123', + reviewsState: CollectionSubmissionReviewState.Pending, + collectedType: '', + status: '', + volume: '', + issue: '', + programArea: '', + schoolType: '', + studyDesign: '', + dataType: '', + disease: '', + gradeLevels: '', + }; + fixture.componentRef.setInput('submission', submissionWithNoAttributes); + fixture.detectChanges(); + + const attributes = component.attributes(); + expect(attributes.length).toBe(0); + + const attributesSection = fixture.nativeElement.querySelector('.flex.flex-column.gap-2.mt-2'); + expect(attributesSection).toBeFalsy(); + }); + + it('should hide attributes when reviews state is Removed even with data', () => { + fixture.componentRef.setInput('submission', { + ...mockSubmission, + reviewsState: CollectionSubmissionReviewState.Removed, + }); + fixture.detectChanges(); + + expect(component.attributes().length).toBeGreaterThan(0); + const attributesSection = fixture.nativeElement.querySelector('.flex.flex-column.gap-2.mt-2'); + expect(attributesSection).toBeFalsy(); + }); +}); diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts new file mode 100644 index 000000000..e8ee18b6f --- /dev/null +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts @@ -0,0 +1,56 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Tag } from 'primeng/tag'; + +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; +import { KeyValueModel } from '@osf/shared/models/common/key-value.model'; +import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-status-severity.pipe'; + +@Component({ + selector: 'osf-metadata-collection-item', + imports: [TranslatePipe, Tag, Button, RouterLink, CollectionStatusSeverityPipe], + templateUrl: './metadata-collection-item.component.html', + styleUrl: './metadata-collection-item.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetadataCollectionItemComponent { + readonly CollectionSubmissionReviewState = CollectionSubmissionReviewState; + + submission = input.required(); + + showSubmissionButton = computed(() => this.submission().reviewsState === CollectionSubmissionReviewState.Accepted); + + submissionButtonLabel = computed(() => { + const status = this.submission().status; + return status === CollectionSubmissionReviewState.Removed ? 'common.buttons.resubmit' : 'common.buttons.edit'; + }); + + showAttributes = computed( + () => this.submission().reviewsState !== CollectionSubmissionReviewState.Removed && !!this.attributes().length + ); + + attributes = computed(() => { + const submission = this.submission(); + const attributes: KeyValueModel[] = []; + + for (const filter of collectionFilterNames) { + const value = submission[filter.key as keyof CollectionSubmission]; + + if (value) { + attributes.push({ + key: filter.key, + label: filter.label, + value: String(value), + }); + } + } + + return attributes; + }); +} diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html new file mode 100644 index 000000000..2a135bdee --- /dev/null +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html @@ -0,0 +1,19 @@ +@let submissions = projectSubmissions(); + + +

{{ 'project.overview.metadata.collection' | translate }}

+ + @if (isProjectSubmissionsLoading()) { + + } @else { +
+ @if (submissions?.length) { + @for (submission of submissions; track submission.id) { + + } + } @else { +

{{ 'project.overview.metadata.noCollections' | translate }}

+ } +
+ } +
diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.scss b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts new file mode 100644 index 000000000..81abf7bae --- /dev/null +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts @@ -0,0 +1,51 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { MetadataCollectionsComponent } from './metadata-collections.component'; + +import { MOCK_PROJECT_COLLECTION_SUBMISSIONS } from '@testing/data/collections/collection-submissions.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('MetadataCollectionsComponent', () => { + let component: MetadataCollectionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MetadataCollectionsComponent, OSFTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(MetadataCollectionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show skeleton while loading submissions', () => { + fixture.componentRef.setInput('isProjectSubmissionsLoading', true); + fixture.detectChanges(); + + const skeleton = fixture.debugElement.query(By.css('p-skeleton')); + expect(skeleton).toBeTruthy(); + }); + + it('should render collection items when submissions exist', () => { + fixture.componentRef.setInput('isProjectSubmissionsLoading', false); + fixture.componentRef.setInput('projectSubmissions', MOCK_PROJECT_COLLECTION_SUBMISSIONS); + fixture.detectChanges(); + + const items = fixture.debugElement.queryAll(By.css('osf-metadata-collection-item')); + expect(items.length).toBe(MOCK_PROJECT_COLLECTION_SUBMISSIONS.length); + }); + + it('should show empty state message when there are no submissions', () => { + fixture.componentRef.setInput('projectSubmissions', []); + fixture.detectChanges(); + + const content = fixture.nativeElement.textContent; + expect(content).toContain('project.overview.metadata.noCollections'); + }); +}); diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts new file mode 100644 index 000000000..daa67530d --- /dev/null +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts @@ -0,0 +1,22 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; + +import { MetadataCollectionItemComponent } from '../metadata-collection-item/metadata-collection-item.component'; + +@Component({ + selector: 'osf-metadata-collections', + imports: [TranslatePipe, Skeleton, Card, MetadataCollectionItemComponent], + templateUrl: './metadata-collections.component.html', + styleUrl: './metadata-collections.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetadataCollectionsComponent { + projectSubmissions = input(null); + isProjectSubmissionsLoading = input(false); +} diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html index fe576760d..491ad60dd 100644 --- a/src/app/features/metadata/metadata.component.html +++ b/src/app/features/metadata/metadata.component.html @@ -62,6 +62,13 @@ [affiliatedInstitutions]="affiliatedInstitutions()" [readonly]="!hasWriteAccess()" /> + + @if (isProjectType()) { + + }
diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index df8378733..e20e097f7 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -25,6 +25,7 @@ import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { CollectionsSelectors, GetProjectSubmissions } from '@osf/shared/stores/collections'; import { ContributorsSelectors, GetBibliographicContributors, @@ -46,6 +47,7 @@ import { import { MetadataTabsModel } from '@shared/models/metadata-tabs.model'; import { SubjectModel } from '@shared/models/subject/subject.model'; +import { MetadataCollectionsComponent } from './components/metadata-collections/metadata-collections.component'; import { EditTitleDialogComponent } from './dialogs/edit-title-dialog/edit-title-dialog.component'; import { MetadataAffiliatedInstitutionsComponent, @@ -109,6 +111,7 @@ import { MetadataTagsComponent, MetadataTitleComponent, MetadataRegistrationDoiComponent, + MetadataCollectionsComponent, ], templateUrl: './metadata.component.html', styleUrl: './metadata.component.scss', @@ -131,6 +134,8 @@ export class MetadataComponent implements OnInit { selectedCedarRecord = signal(null); selectedCedarTemplate = signal(null); cedarFormReadonly = signal(true); + resourceType = signal(this.activeRoute.parent?.snapshot.data['resourceType'] || ResourceType.Project); + metadata = select(MetadataSelectors.getResourceMetadata); isMetadataLoading = select(MetadataSelectors.getLoading); customItemMetadata = select(MetadataSelectors.getCustomItemMetadata); @@ -141,12 +146,14 @@ export class MetadataComponent implements OnInit { cedarTemplates = select(MetadataSelectors.getCedarTemplates); selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); - resourceType = signal(this.activeRoute.parent?.snapshot.data['resourceType'] || ResourceType.Project); isSubmitting = select(MetadataSelectors.getSubmitting); affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions); areInstitutionsLoading = select(InstitutionsSelectors.areResourceInstitutionsLoading); areResourceInstitutionsSubmitting = select(InstitutionsSelectors.areResourceInstitutionsSubmitting); + projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions); + isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading); + hasWriteAccess = select(MetadataSelectors.hasWriteAccess); hasAdminAccess = select(MetadataSelectors.hasAdminAccess); @@ -179,6 +186,8 @@ export class MetadataComponent implements OnInit { fetchChildrenSubjects: FetchChildrenSubjects, updateResourceSubjects: UpdateResourceSubjects, updateContributorsSearchValue: UpdateContributorsSearchValue, + + getProjectSubmissions: GetProjectSubmissions, }); isLoading = computed( @@ -195,6 +204,7 @@ export class MetadataComponent implements OnInit { (!!this.metadata()?.identifiers?.length || !this.metadata()?.public) ); + isProjectType = computed(() => this.resourceType() === ResourceType.Project); isRegistrationType = computed(() => this.resourceType() === ResourceType.Registration); constructor() { @@ -253,6 +263,10 @@ export class MetadataComponent implements OnInit { this.actions.getCedarRecords(this.resourceId, this.resourceType()); this.actions.getCedarTemplates(); this.actions.fetchSelectedSubjects(this.resourceId, this.resourceType()); + + if (this.isProjectType()) { + this.actions.getProjectSubmissions(this.resourceId); + } } } diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html index c68984cd4..390df488d 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html @@ -16,34 +16,20 @@

{{ 'project.overview.metadata.collection' | translate }}

@let status = submission.reviewsState; - @switch (status) { - @case (SubmissionReviewStatus.Accepted) { - - } - @case (SubmissionReviewStatus.Rejected) { - - } - @case (SubmissionReviewStatus.Pending) { - - } - @case (SubmissionReviewStatus.InProgress) { - - } - @case (SubmissionReviewStatus.Removed) { - - } - } + +
- @let attributes = submissionAttributes(submission); + @let attributes = getSubmissionAttributes(submission); @if (attributes.length) {
diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts index 9b23e8e42..328898474 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts @@ -5,13 +5,14 @@ import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import { Tag } from 'primeng/tag'; -import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; -import { Router } from '@angular/router'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; -import { SubmissionReviewStatus } from '@osf/features/moderation/enums'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; +import { KeyValueModel } from '@osf/shared/models/common/key-value.model'; +import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-status-severity.pipe'; @Component({ selector: 'osf-overview-collections', @@ -25,28 +26,32 @@ import { CollectionSubmission } from '@osf/shared/models/collections/collections Tag, Button, StopPropagationDirective, + RouterLink, + CollectionStatusSeverityPipe, ], templateUrl: './overview-collections.component.html', styleUrl: './overview-collections.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class OverviewCollectionsComponent { - private readonly router = inject(Router); - readonly SubmissionReviewStatus = SubmissionReviewStatus; - projectSubmissions = input(null); isProjectSubmissionsLoading = input(false); - submissionAttributes(submission: CollectionSubmission) { - return collectionFilterNames - .map((attribute) => ({ - ...attribute, - value: submission[attribute.key as keyof CollectionSubmission] as string, - })) - .filter((attribute) => attribute.value); - } + getSubmissionAttributes(submission: CollectionSubmission): KeyValueModel[] { + const attributes: KeyValueModel[] = []; + + for (const filter of collectionFilterNames) { + const value = submission[filter.key as keyof CollectionSubmission]; + + if (value) { + attributes.push({ + key: filter.key, + label: filter.label, + value: String(value), + }); + } + } - navigateToCollection(submission: CollectionSubmission) { - this.router.navigate([`collections/${submission.collectionId}/`]); + return attributes; } } diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 5f2f09c15..209c33834 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Message } from 'primeng/message'; -import { map, of } from 'rxjs'; +import { combineLatest, map, of } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; import { @@ -172,7 +172,10 @@ export class ProjectOverviewComponent implements OnInit { })); readonly projectId = toSignal( - this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined) + combineLatest([ + this.route.params.pipe(map((params) => params['id'])), + this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined), + ]).pipe(map(([currentId, parentId]) => currentId ?? parentId)) ); constructor() { diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 870ee6e8b..8c2861788 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -51,7 +51,7 @@ export const projectRoutes: Routes = [ { path: 'metadata', loadChildren: () => import('@osf/features/metadata/metadata.routes').then((mod) => mod.metadataRoutes), - providers: [provideStates([SubjectsState])], + providers: [provideStates([SubjectsState, CollectionsState])], data: { resourceType: ResourceType.Project }, canActivate: [viewOnlyGuard], }, diff --git a/src/app/shared/enums/collection-submission-review-state.enum.ts b/src/app/shared/enums/collection-submission-review-state.enum.ts new file mode 100644 index 000000000..e14f00517 --- /dev/null +++ b/src/app/shared/enums/collection-submission-review-state.enum.ts @@ -0,0 +1,7 @@ +export enum CollectionSubmissionReviewState { + Accepted = 'accepted', + InProgress = 'in_progress', + Pending = 'pending', + Removed = 'removed', + Rejected = 'rejected', +} diff --git a/src/app/shared/helpers/collection-submission-status.util.ts b/src/app/shared/helpers/collection-submission-status.util.ts new file mode 100644 index 000000000..27a3ae589 --- /dev/null +++ b/src/app/shared/helpers/collection-submission-status.util.ts @@ -0,0 +1,11 @@ +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; + +import { SeverityType } from '../models/severity.type'; + +export const COLLECTION_SUBMISSION_STATUS_SEVERITY: Record = { + [CollectionSubmissionReviewState.Accepted]: 'success', + [CollectionSubmissionReviewState.Rejected]: 'danger', + [CollectionSubmissionReviewState.Pending]: 'warn', + [CollectionSubmissionReviewState.InProgress]: 'warn', + [CollectionSubmissionReviewState.Removed]: 'secondary', +}; diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index 88aa46f12..d1fd54ca5 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -7,6 +7,7 @@ import { CollectionSubmissionPayload } from '@osf/shared/models/collections/coll import { CollectionSubmissionPayloadJsonApi } from '@osf/shared/models/collections/collection-submission-payload-json-api.model'; import { CollectionDetails, + CollectionProjectSubmission, CollectionProvider, CollectionSubmission, CollectionSubmissionWithGuid, @@ -22,6 +23,7 @@ import { ContributorModel } from '@osf/shared/models/contributors/contributor.mo import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.helper'; +import { ProjectsMapper } from '../projects'; import { UserMapper } from '../user'; export class CollectionsMapper { @@ -205,6 +207,36 @@ export class CollectionsMapper { })); } + static getProjectSubmission(data: CollectionSubmissionWithGuidJsonApi): CollectionProjectSubmission { + const project = ProjectsMapper.fromProjectResponse(data.embeds.guid.data); + const submission: CollectionSubmissionWithGuid = { + id: data.id, + type: data.type, + nodeId: data.embeds.guid.data.id, + nodeUrl: data.embeds.guid.data.links.html, + title: replaceBadEncodedChars(data.embeds.guid.data.attributes.title), + description: replaceBadEncodedChars(data.embeds.guid.data.attributes.description), + category: data.embeds.guid.data.attributes.category, + dateCreated: data.embeds.guid.data.attributes.date_created, + dateModified: data.embeds.guid.data.attributes.date_modified, + public: data.embeds.guid.data.attributes.public, + reviewsState: data.attributes.reviews_state, + collectedType: data.attributes.collected_type, + status: data.attributes.status, + volume: data.attributes.volume, + issue: data.attributes.issue, + programArea: data.attributes.program_area, + schoolType: data.attributes.school_type, + studyDesign: data.attributes.study_design, + dataType: data.attributes.data_type, + disease: data.attributes.disease, + gradeLevels: data.attributes.grade_levels, + contributors: [] as ContributorModel[], + }; + + return { submission, project }; + } + static toCollectionSubmissionRequest(payload: CollectionSubmissionPayload): CollectionSubmissionPayloadJsonApi { const collectionId = payload.collectionId; const collectionsMetadata = convertToSnakeCase(payload.collectionMetadata); @@ -233,4 +265,15 @@ export class CollectionsMapper { }, }; } + + static collectionSubmissionUpdateRequest(payload: CollectionSubmissionPayload) { + return { + data: { + id: `${payload.projectId}-${payload.collectionId}`, + type: 'collection-submissions', + attributes: {}, + relationships: {}, + }, + }; + } } diff --git a/src/app/shared/mappers/projects/projects.mapper.ts b/src/app/shared/mappers/projects/projects.mapper.ts index cfe5faf1a..26140a3b9 100644 --- a/src/app/shared/mappers/projects/projects.mapper.ts +++ b/src/app/shared/mappers/projects/projects.mapper.ts @@ -1,15 +1,16 @@ -import { CollectionSubmissionMetadataPayloadJsonApi } from '@osf/features/collections/models'; +import { CollectionSubmissionMetadataPayloadJsonApi } from '@osf/features/collections/models/collection-license-json-api.models'; +import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; +import { NodesResponseJsonApi } from '@osf/shared/models/nodes/nodes-json-api.model'; import { ProjectMetadataUpdatePayload } from '@osf/shared/models/project-metadata-update-payload.model'; import { ProjectModel } from '@osf/shared/models/projects/projects.models'; -import { ProjectJsonApi, ProjectsResponseJsonApi } from '@osf/shared/models/projects/projects-json-api.models'; import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.helper'; export class ProjectsMapper { - static fromGetAllProjectsResponse(response: ProjectsResponseJsonApi): ProjectModel[] { + static fromGetAllProjectsResponse(response: NodesResponseJsonApi): ProjectModel[] { return response.data.map((project) => this.fromProjectResponse(project)); } - static fromProjectResponse(project: ProjectJsonApi): ProjectModel { + static fromProjectResponse(project: BaseNodeDataJsonApi): ProjectModel { return { id: project.id, type: project.type, diff --git a/src/app/shared/models/collections/collections-json-api.models.ts b/src/app/shared/models/collections/collections-json-api.models.ts index 63fbd2eda..2ce9402af 100644 --- a/src/app/shared/models/collections/collections-json-api.models.ts +++ b/src/app/shared/models/collections/collections-json-api.models.ts @@ -1,3 +1,5 @@ +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; + import { BrandDataJsonApi } from '../brand/brand.json-api.model'; import { JsonApiResponse } from '../common/json-api.model'; import { BaseNodeDataJsonApi } from '../nodes/base-node-data-json-api.model'; @@ -50,7 +52,7 @@ export interface CollectionSubmissionJsonApi { id: string; type: string; attributes: { - reviews_state: string; + reviews_state: CollectionSubmissionReviewState; collected_type: string; status: string; volume: string; @@ -84,7 +86,7 @@ export interface CollectionSubmissionWithGuidJsonApi { id: string; type: string; attributes: { - reviews_state: string; + reviews_state: CollectionSubmissionReviewState; collected_type: string; status: string; volume: string; diff --git a/src/app/shared/models/collections/collections.models.ts b/src/app/shared/models/collections/collections.models.ts index 887851807..1ba9b809d 100644 --- a/src/app/shared/models/collections/collections.models.ts +++ b/src/app/shared/models/collections/collections.models.ts @@ -1,7 +1,9 @@ import { CollectionSubmissionReviewAction } from '@osf/features/moderation/models'; +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { BrandModel } from '../brand/brand.model'; import { ContributorModel } from '../contributors/contributor.model'; +import { ProjectModel } from '../projects/projects.models'; import { BaseProviderModel } from '../provider/provider.model'; export interface CollectionProvider extends BaseProviderModel { @@ -19,16 +21,16 @@ export interface CollectionProvider extends BaseProviderModel { } export interface CollectionFilters { - status: string[]; collectedType: string[]; - volume: string[]; + disease: string[]; + dataType: string[]; + gradeLevels: string[]; issue: string[]; programArea: string[]; schoolType: string[]; + status: string[]; studyDesign: string[]; - dataType: string[]; - disease: string[]; - gradeLevels: string[]; + volume: string[]; } export interface CollectionDetails { @@ -48,7 +50,7 @@ export interface CollectionSubmission { type: string; collectionTitle: string; collectionId: string; - reviewsState: string; + reviewsState: CollectionSubmissionReviewState; collectedType: string; status: string; volume: string; @@ -72,7 +74,7 @@ export interface CollectionSubmissionWithGuid { dateCreated: string; dateModified: string; public: boolean; - reviewsState: string; + reviewsState: CollectionSubmissionReviewState; collectedType: string; status: string; volume: string; @@ -91,6 +93,11 @@ export interface CollectionSubmissionWithGuid { actions?: CollectionSubmissionReviewAction[]; } +export interface CollectionProjectSubmission { + submission: CollectionSubmissionWithGuid; + project: ProjectModel; +} + export type CollectionSubmissionActionType = 'collection_submission_actions'; export type CollectionSubmissionTargetType = 'collection-submissions'; diff --git a/src/app/shared/models/common/key-value.model.ts b/src/app/shared/models/common/key-value.model.ts new file mode 100644 index 000000000..460b1317b --- /dev/null +++ b/src/app/shared/models/common/key-value.model.ts @@ -0,0 +1,5 @@ +export interface KeyValueModel { + key: string; + label: string; + value: string; +} diff --git a/src/app/shared/models/projects/projects-json-api.models.ts b/src/app/shared/models/projects/projects-json-api.models.ts deleted file mode 100644 index f8522ccf4..000000000 --- a/src/app/shared/models/projects/projects-json-api.models.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { JsonApiResponse, MetaJsonApi, PaginationLinksJsonApi } from '../common/json-api.model'; -import { LicenseRecordJsonApi } from '../license/licenses-json-api.model'; - -export interface ProjectJsonApi { - id: string; - type: string; - attributes: { - title: string; - date_modified: string; - public: boolean; - node_license: LicenseRecordJsonApi | null; - description: string; - tags: string[]; - }; - relationships: ProjectRelationshipsJsonApi; -} - -export interface ProjectsResponseJsonApi extends JsonApiResponse { - data: ProjectJsonApi[]; - meta: MetaJsonApi; - links: PaginationLinksJsonApi; -} - -export interface ProjectRelationshipsJsonApi { - license: { - data: { - id: string; - type: 'licenses'; - }; - }; -} diff --git a/src/app/shared/pipes/collection-status-severity.pipe.ts b/src/app/shared/pipes/collection-status-severity.pipe.ts new file mode 100644 index 000000000..57dc32172 --- /dev/null +++ b/src/app/shared/pipes/collection-status-severity.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { CollectionSubmissionReviewState } from '../enums/collection-submission-review-state.enum'; +import { COLLECTION_SUBMISSION_STATUS_SEVERITY } from '../helpers/collection-submission-status.util'; +import { SeverityType } from '../models/severity.type'; + +@Pipe({ + name: 'collectionStatusSeverity', +}) +export class CollectionStatusSeverityPipe implements PipeTransform { + transform(status: CollectionSubmissionReviewState): SeverityType { + return COLLECTION_SUBMISSION_STATUS_SEVERITY[status]; + } +} diff --git a/src/app/shared/services/collections.service.ts b/src/app/shared/services/collections.service.ts index 4b7c83a27..6424e0f44 100644 --- a/src/app/shared/services/collections.service.ts +++ b/src/app/shared/services/collections.service.ts @@ -17,6 +17,7 @@ import { ContributorsMapper } from '../mappers/contributors'; import { ReviewActionsMapper } from '../mappers/review-actions.mapper'; import { CollectionDetails, + CollectionProjectSubmission, CollectionProvider, CollectionSubmission, CollectionSubmissionActionType, @@ -167,16 +168,21 @@ export class CollectionsService { } fetchCurrentSubmission(projectId: string, collectionId: string): Observable { - const params: Record = { - 'filter[id]': projectId, - embed: 'collection', - }; + const params: Record = { embed: 'collection' }; return this.jsonApiService .get< - JsonApiResponse - >(`${this.apiUrl}/collections/${collectionId}/collection_submissions/`, params) - .pipe(map((response) => CollectionsMapper.fromCurrentSubmissionResponse(response.data[0]))); + ResponseJsonApi + >(`${this.apiUrl}/collections/${collectionId}/collection_submissions/${projectId}/`, params) + .pipe(map((response) => CollectionsMapper.fromCurrentSubmissionResponse(response.data))); + } + + fetchProjectSubmission(collectionId: string, projectId: string): Observable { + return this.jsonApiService + .get< + ResponseJsonApi + >(`${this.apiUrl}/collections/${collectionId}/collection_submissions/${projectId}/`) + .pipe(map((response) => CollectionsMapper.getProjectSubmission(response.data))); } fetchCollectionSubmissionsActions( diff --git a/src/app/shared/services/projects.service.ts b/src/app/shared/services/projects.service.ts index 2bf9da1be..fba349ed6 100644 --- a/src/app/shared/services/projects.service.ts +++ b/src/app/shared/services/projects.service.ts @@ -5,9 +5,10 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ProjectsMapper } from '../mappers/projects'; +import { BaseNodeDataJsonApi } from '../models/nodes/base-node-data-json-api.model'; +import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { ProjectMetadataUpdatePayload } from '../models/project-metadata-update-payload.model'; import { ProjectModel } from '../models/projects/projects.models'; -import { ProjectJsonApi, ProjectsResponseJsonApi } from '../models/projects/projects-json-api.models'; import { JsonApiService } from './json-api.service'; @@ -24,7 +25,7 @@ export class ProjectsService { fetchProjects(userId: string, params?: Record): Observable { return this.jsonApiService - .get(`${this.apiUrl}/users/${userId}/nodes/`, params) + .get(`${this.apiUrl}/users/${userId}/nodes/`, params) .pipe(map((response) => ProjectsMapper.fromGetAllProjectsResponse(response))); } @@ -32,13 +33,13 @@ export class ProjectsService { const payload = ProjectsMapper.toUpdateProjectRequest(metadata); return this.jsonApiService - .patch(`${this.apiUrl}/nodes/${metadata.id}/`, payload) + .patch(`${this.apiUrl}/nodes/${metadata.id}/`, payload) .pipe(map((response) => ProjectsMapper.fromProjectResponse(response))); } getProjectChildren(id: string): Observable { return this.jsonApiService - .get(`${this.apiUrl}/nodes/${id}/children/`) + .get(`${this.apiUrl}/nodes/${id}/children/`) .pipe(map((response) => ProjectsMapper.fromGetAllProjectsResponse(response))); } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 73c1f1905..4d22f4549 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -28,6 +28,7 @@ "done": "Done", "select": "Select", "addToCollection": "Add to Collection", + "removeFromCollection": "Remove from Collection", "discardChanges": "Discard Changes", "saveAndContinue": "Save and Continue", "clickToEdit": "Click to edit", @@ -58,7 +59,8 @@ "accept": "Accept", "reject": "Reject", "loadMore": "Load more", - "seeMore": "See more" + "seeMore": "See more", + "resubmit": "Resubmit" }, "accessibility": { "help": "Help", @@ -1412,6 +1414,12 @@ "fieldRequired": "This field can't be empty" } }, + "removeDialog": { + "header": "Remove project from collection", + "message": "Are you sure you want to remove {{projectTitle}} from this collection?", + "success": "Project successfully removed from this collection.", + "reasonPlaceholder": "Optional reason for removal" + }, "common": { "dateCreated": "Date Created:", "dateModified": "Date Modified:", diff --git a/src/testing/data/collections/collection-submissions.mock.ts b/src/testing/data/collections/collection-submissions.mock.ts new file mode 100644 index 000000000..cf812f95e --- /dev/null +++ b/src/testing/data/collections/collection-submissions.mock.ts @@ -0,0 +1,39 @@ +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; + +export const MOCK_PROJECT_COLLECTION_SUBMISSIONS: CollectionSubmission[] = [ + { + id: '1', + type: 'collection-submissions', + collectionTitle: 'Collection A', + collectionId: 'col1', + reviewsState: CollectionSubmissionReviewState.Accepted, + collectedType: 'typeA', + status: 'accepted', + volume: 'vol1', + issue: 'iss1', + programArea: 'prog1', + schoolType: 'school1', + studyDesign: 'design1', + dataType: 'data1', + disease: 'disease1', + gradeLevels: 'grade1', + }, + { + id: '2', + type: 'collection-submissions', + collectionTitle: 'Collection B', + collectionId: 'col2', + reviewsState: CollectionSubmissionReviewState.Pending, + collectedType: 'typeB', + status: 'pending', + volume: 'vol2', + issue: 'iss2', + programArea: 'prog2', + schoolType: 'school2', + studyDesign: 'design2', + dataType: 'data2', + disease: 'disease2', + gradeLevels: 'grade2', + }, +];