diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index c87d8166f..f7da47400 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -2,6 +2,9 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; +import { CollectionsState } from '@osf/features/collections/store/collections'; +import { ProjectsState } from '@shared/stores'; + import { MyProfileResourceFiltersOptionsState } from './features/my-profile/components/filters/store'; import { MyProfileResourceFiltersState } from './features/my-profile/components/my-profile-resource-filters/store'; import { MyProfileState } from './features/my-profile/store'; @@ -71,10 +74,12 @@ export const routes: Routes = [ path: 'my-projects', loadComponent: () => import('./features/my-projects/my-projects.component').then((mod) => mod.MyProjectsComponent), + providers: [provideStates([CollectionsState])], }, { path: 'my-projects/:id', loadChildren: () => import('./features/project/project.routes').then((mod) => mod.projectRoutes), + providers: [provideStates([ProjectsState])], }, { path: 'settings', diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 20157b281..5189ee730 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -1,6 +1,5 @@ import { AuthState } from '@core/store/auth'; import { UserState } from '@core/store/user'; -import { CollectionsState } from '@osf/features/collections/store'; import { MeetingsState } from '@osf/features/meetings/store'; import { MyProjectsState } from '@osf/features/my-projects/store'; import { ProjectMetadataState } from '@osf/features/project/metadata/store'; @@ -26,7 +25,6 @@ export const STATES = [ AccountSettingsState, NotificationSubscriptionState, ProjectOverviewState, - CollectionsState, WikiState, MeetingsState, RegistrationsState, diff --git a/src/app/features/collections/collections.component.html b/src/app/features/collections/collections.component.html index 3c453bfa4..67e7bd4cd 100644 --- a/src/app/features/collections/collections.component.html +++ b/src/app/features/collections/collections.component.html @@ -1,33 +1 @@ -@if (!isProviderLoading()) { -
-
-
- -

{{ collectionProvider()?.name }}

-
- - -
- -
- - @if (collectionProvider()?.description) { -
- } -
- -
- -
-
-} @else { - -} + diff --git a/src/app/features/collections/collections.component.scss b/src/app/features/collections/collections.component.scss index 8130f60ee..e69de29bb 100644 --- a/src/app/features/collections/collections.component.scss +++ b/src/app/features/collections/collections.component.scss @@ -1,38 +0,0 @@ -@use "assets/styles/variables" as var; -@use "assets/styles/mixins" as mix; - -:host { - --collection-bg-color: #013b5c; - @include mix.flex-column; - flex: 1; -} - -.collections { - background: var(--collection-bg-color); - border-top: none; - - .collections-sub-header { - margin: mix.rem(48px) mix.rem(28px); - - .collections-icon { - font-size: mix.rem(42px); - } - } - - .search-input-container { - margin: 0 mix.rem(28px) mix.rem(48px) mix.rem(28px); - position: relative; - - img { - position: absolute; - right: mix.rem(4px); - top: mix.rem(4px); - z-index: 1; - } - } - - .content-container { - background: var.$white; - padding: mix.rem(28px); - } -} diff --git a/src/app/features/collections/collections.component.ts b/src/app/features/collections/collections.component.ts index a67424424..247a6c554 100644 --- a/src/app/features/collections/collections.component.ts +++ b/src/app/features/collections/collections.component.ts @@ -1,174 +1,11 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { DialogService } from 'primeng/dynamicdialog'; - -import { debounceTime } from 'rxjs'; - -import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; - -import { CollectionsHelpDialogComponent, CollectionsMainContentComponent } from '@osf/features/collections/components'; -import { CollectionsFilters } from '@osf/features/collections/models'; -import { CollectionsQuerySyncService } from '@osf/features/collections/services'; -import { - ClearCollections, - ClearCollectionSubmissions, - CollectionsSelectors, - GetCollectionDetails, - GetCollectionProvider, - GetCollectionSubmissions, - SetPageNumber, - SetSearchValue, -} from '@osf/features/collections/store'; -import { LoadingSpinnerComponent, SearchInputComponent } from '@shared/components'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; @Component({ selector: 'osf-collections', - imports: [SearchInputComponent, TranslatePipe, Button, CollectionsMainContentComponent, LoadingSpinnerComponent], + imports: [RouterOutlet], templateUrl: './collections.component.html', styleUrl: './collections.component.scss', - providers: [DialogService, CollectionsQuerySyncService], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CollectionsComponent { - private router = inject(Router); - private route = inject(ActivatedRoute); - private dialogService = inject(DialogService); - private translateService = inject(TranslateService); - private querySyncService = inject(CollectionsQuerySyncService); - private destroyRef = inject(DestroyRef); - - protected searchControl = new FormControl(''); - protected providerId = signal(''); - - protected collectionProvider = select(CollectionsSelectors.getCollectionProvider); - protected collectionDetails = select(CollectionsSelectors.getCollectionDetails); - protected selectedFilters = select(CollectionsSelectors.getAllSelectedFilters); - protected sortBy = select(CollectionsSelectors.getSortBy); - protected searchText = select(CollectionsSelectors.getSearchText); - protected pageNumber = select(CollectionsSelectors.getPageNumber); - protected isProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); - protected primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id); - - protected actions = createDispatchMap({ - getCollectionProvider: GetCollectionProvider, - getCollectionDetails: GetCollectionDetails, - setSearchValue: SetSearchValue, - getCollectionSubmissions: GetCollectionSubmissions, - setPageNumber: SetPageNumber, - clearCollections: ClearCollections, - clearCollectionsSubmissions: ClearCollectionSubmissions, - }); - - constructor() { - this.initializeProvider(); - this.setupEffects(); - this.setupSearchBinding(); - } - - protected openHelpDialog(): void { - this.dialogService.open(CollectionsHelpDialogComponent, { - focusOnShow: false, - header: this.translateService.instant('collections.helpDialog.header'), - closeOnEscape: true, - modal: true, - closable: true, - }); - } - - protected onSearchTriggered(searchValue: string): void { - this.actions.setSearchValue(searchValue); - this.actions.setPageNumber('1'); - } - - private initializeProvider(): void { - const id = this.route.snapshot.paramMap.get('id'); - if (!id) { - this.router.navigate(['/not-found']); - return; - } - - this.providerId.set(id); - this.actions.getCollectionProvider(id); - } - - private setupEffects(): void { - this.querySyncService.initializeFromUrl(); - - effect(() => { - const collectionId = this.primaryCollectionId(); - if (collectionId) { - this.actions.getCollectionDetails(collectionId); - } - }); - - effect(() => { - const searchText = this.searchText(); - const sortBy = this.sortBy(); - const selectedFilters = this.selectedFilters(); - const pageNumber = this.pageNumber(); - - if (searchText !== undefined && sortBy !== undefined && selectedFilters && pageNumber) { - this.querySyncService.syncStoreToUrl(searchText, sortBy, selectedFilters, pageNumber); - } - }); - - effect(() => { - const searchText = this.searchText(); - const sortBy = this.sortBy(); - const selectedFilters = this.selectedFilters(); - const pageNumber = this.pageNumber(); - const providerId = this.providerId(); - const collectionDetails = this.collectionDetails(); - - if (searchText !== undefined && selectedFilters && pageNumber && providerId && collectionDetails) { - const activeFilters = this.getActiveFilters(selectedFilters); - this.actions.clearCollectionsSubmissions(); - this.actions.getCollectionSubmissions(providerId, searchText, activeFilters, pageNumber, sortBy); - } - }); - - effect(() => { - this.destroyRef.onDestroy(() => { - this.actions.clearCollections(); - }); - }); - } - - private getActiveFilters(filters: CollectionsFilters): Record { - return Object.entries(filters) - .filter(([key, value]) => value.length) - .reduce( - (acc, [key, value]) => { - acc[key] = value; - return acc; - }, - {} as Record - ); - } - - private setupSearchBinding(): void { - effect(() => { - const storeSearchText = this.searchText(); - const currentControlValue = this.searchControl.value; - - if (storeSearchText !== currentControlValue) { - this.searchControl.setValue(storeSearchText, { emitEvent: false }); - } - }); - - this.searchControl.valueChanges - .pipe(debounceTime(300), takeUntilDestroyed(this.destroyRef)) - .subscribe((searchValue) => { - const trimmedValue = searchValue?.trim() || ''; - if (trimmedValue !== this.searchText()) { - this.actions.setSearchValue(trimmedValue); - } - }); - } -} +export class CollectionsComponent {} diff --git a/src/app/features/collections/collections.routes.ts b/src/app/features/collections/collections.routes.ts index b49e7310a..e263b82c6 100644 --- a/src/app/features/collections/collections.routes.ts +++ b/src/app/features/collections/collections.routes.ts @@ -2,6 +2,10 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; +import { AddToCollectionState } from '@osf/features/collections/store/add-to-collection'; +import { CollectionsState } from '@osf/features/collections/store/collections'; +import { ContributorsState, ProjectsState } from '@shared/stores'; + import { ModeratorsState } from '../moderation/store/moderation'; export const collectionsRoutes: Routes = [ @@ -12,14 +16,30 @@ export const collectionsRoutes: Routes = [ path: '', pathMatch: 'full', loadComponent: () => - import('@core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent), + import('@core/components/page-not-found/page-not-found.component').then((m) => m.PageNotFoundComponent), data: { skipBreadcrumbs: true }, }, { path: ':id', + redirectTo: ':id/discover', + }, + { + path: ':id/discover', pathMatch: 'full', loadComponent: () => - import('@osf/features/collections/collections.component').then((mod) => mod.CollectionsComponent), + import('@osf/features/collections/components/collections-discover/collections-discover.component').then( + (mod) => mod.CollectionsDiscoverComponent + ), + providers: [provideStates([CollectionsState])], + }, + { + path: ':id/add', + pathMatch: 'full', + loadComponent: () => + import('@osf/features/collections/components/add-to-collection/add-to-collection.component').then( + (mod) => mod.AddToCollectionComponent + ), + providers: [provideStates([ProjectsState, CollectionsState, AddToCollectionState, ContributorsState])], }, { path: ':id/moderation', diff --git a/src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.html b/src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.html deleted file mode 100644 index 6b2db0376..000000000 --- a/src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.html +++ /dev/null @@ -1 +0,0 @@ -

add-to-collection-form works!

diff --git a/src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.ts b/src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.ts deleted file mode 100644 index 38a7ebba4..000000000 --- a/src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; - -@Component({ - selector: 'osf-add-to-collection-form', - imports: [], - templateUrl: './add-to-collection-form.component.html', - styleUrl: './add-to-collection-form.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AddToCollectionFormComponent {} diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.html b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.html new file mode 100644 index 000000000..afa67d751 --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.html @@ -0,0 +1,21 @@ +
+

+ {{ 'collections.addToCollection.confirmationDialogMessage' | translate }} +

+ +
+ + +
+
diff --git a/src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.scss b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.scss similarity index 100% rename from src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.scss rename to src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.scss diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts new file mode 100644 index 000000000..c256076a4 --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts @@ -0,0 +1,61 @@ +import { createDispatchMap } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { forkJoin, of } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { CreateCollectionSubmission } from '@osf/features/collections/store/add-to-collection/add-to-collection.actions'; +import { UpdateProjectPublicStatus } from '@osf/features/project/overview/store'; +import { ToastService } from '@shared/services'; + +@Component({ + selector: 'osf-add-to-collection-confirmation-dialog', + imports: [TranslatePipe, Button], + templateUrl: './add-to-collection-confirmation-dialog.component.html', + styleUrl: './add-to-collection-confirmation-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddToCollectionConfirmationDialogComponent { + private toastService = inject(ToastService); + protected dialogRef = inject(DynamicDialogRef); + protected config = inject(DynamicDialogConfig); + protected destroyRef = inject(DestroyRef); + protected isSubmitting = signal(false); + protected actions = createDispatchMap({ + createCollectionSubmission: CreateCollectionSubmission, + updateProjectPublicStatus: UpdateProjectPublicStatus, + }); + + protected handleAddToCollectionConfirm(): void { + const project = this.config.data; + if (!project) return; + + this.isSubmitting.set(true); + + const updatePublicStatus$ = project.isPublic ? of(null) : this.actions.updateProjectPublicStatus(project.id, true); + + const createSubmission$ = this.actions.createCollectionSubmission(project); + + forkJoin({ + publicStatusUpdate: updatePublicStatus$, + collectionSubmission: createSubmission$, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.isSubmitting.set(false); + this.dialogRef.close(true); + this.toastService.showSuccess('collections.addToCollection.confirmationDialogToastMessage'); + }, + error: () => { + this.isSubmitting.set(false); + }, + }); + } +} 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 new file mode 100644 index 000000000..264e574ac --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html @@ -0,0 +1,62 @@ +@if (!isProviderLoading()) { +
+
+
+ +

{{ collectionProvider()?.name }}

+
+
+ +
+ + + + + + + + + + +
+ + +
+
+
+} @else { + +} diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.scss b/src/app/features/collections/components/add-to-collection/add-to-collection.component.scss new file mode 100644 index 000000000..5c7d2b40e --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.scss @@ -0,0 +1,8 @@ +@use "assets/styles/variables" as var; +@use "assets/styles/mixins" as mix; + +:host { + --collection-bg-color: #013b5c; + @include mix.flex-column; + flex: 1; +} diff --git a/src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts similarity index 54% rename from src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.spec.ts rename to src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts index f1cd32c58..b65e1aee5 100644 --- a/src/app/features/collections/components/add-to-collection-form/add-to-collection-form.component.spec.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts @@ -1,17 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AddToCollectionFormComponent } from './add-to-collection-form.component'; +import { AddToCollectionComponent } from './add-to-collection.component'; describe('AddToCollectionFormComponent', () => { - let component: AddToCollectionFormComponent; - let fixture: ComponentFixture; + let component: AddToCollectionComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [AddToCollectionFormComponent], + imports: [AddToCollectionComponent], }).compileComponents(); - fixture = TestBed.createComponent(AddToCollectionFormComponent); + fixture = TestBed.createComponent(AddToCollectionComponent); component = fixture.componentInstance; fixture.detectChanges(); }); 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 new file mode 100644 index 000000000..a00b6cffa --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -0,0 +1,168 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DialogService } from 'primeng/dynamicdialog'; +import { Stepper } from 'primeng/stepper'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + HostListener, + inject, + signal, +} from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; + +import { UserSelectors } from '@core/store/user'; +import { AddToCollectionSteps } from '@osf/features/collections/enums'; +import { + ClearAddToCollectionState, + CreateCollectionSubmission, +} from '@osf/features/collections/store/add-to-collection/add-to-collection.actions'; +import { CollectionsSelectors, GetCollectionProvider } from '@osf/features/collections/store/collections'; +import { LoadingSpinnerComponent } from '@shared/components'; +import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; + +import { + AddToCollectionConfirmationDialogComponent, + CollectionMetadataStepComponent, + ProjectContributorsStepComponent, + ProjectMetadataStepComponent, + SelectProjectStepComponent, +} from './index'; + +@Component({ + selector: 'osf-add-to-collection-form', + imports: [ + Button, + LoadingSpinnerComponent, + TranslatePipe, + RouterLink, + Stepper, + SelectProjectStepComponent, + ProjectMetadataStepComponent, + ProjectContributorsStepComponent, + CollectionMetadataStepComponent, + ], + templateUrl: './add-to-collection.component.html', + styleUrl: './add-to-collection.component.scss', + providers: [DialogService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddToCollectionComponent { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + private readonly dialogService = inject(DialogService); + private readonly translateService = inject(TranslateService); + + protected readonly AddToCollectionSteps = AddToCollectionSteps; + + protected collectionMetadataForm = new FormGroup({}); + protected isProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); + protected collectionProvider = select(CollectionsSelectors.getCollectionProvider); + protected selectedProject = select(ProjectsSelectors.getSelectedProject); + protected currentUser = select(UserSelectors.getCurrentUser); + protected providerId = signal(''); + protected projectMetadataSaved = signal(false); + protected projectContributorsSaved = signal(false); + protected collectionMetadataSaved = signal(false); + protected stepperActiveValue = signal(AddToCollectionSteps.SelectProject); + protected primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id); + protected isProjectMetadataDisabled = computed(() => !this.selectedProject()); + protected isProjectContributorsDisabled = computed(() => !this.selectedProject() || !this.projectMetadataSaved()); + protected isCollectionMetadataDisabled = computed( + () => !this.selectedProject() || !this.projectMetadataSaved() || !this.projectContributorsSaved() + ); + + protected actions = createDispatchMap({ + getCollectionProvider: GetCollectionProvider, + clearAddToCollectionState: ClearAddToCollectionState, + createCollectionSubmission: CreateCollectionSubmission, + }); + + constructor() { + this.initializeProvider(); + this.setupEffects(); + } + + handleProjectSelected(): void { + this.projectContributorsSaved.set(false); + this.projectMetadataSaved.set(false); + } + + handleChangeStep(step: number): void { + this.stepperActiveValue.set(step); + } + + handleProjectMetadataSaved(): void { + this.projectMetadataSaved.set(true); + } + + handleContributorsSaved(): void { + this.stepperActiveValue.set(AddToCollectionSteps.CollectionMetadata); + this.projectContributorsSaved.set(true); + } + + handleCollectionMetadataSaved(form: FormGroup): void { + this.collectionMetadataForm = form; + this.collectionMetadataSaved.set(true); + this.stepperActiveValue.set(AddToCollectionSteps.Complete); + } + + handleAddToCollection() { + const payload = { + collectionId: this.primaryCollectionId() || '', + projectId: this.selectedProject()?.id || '', + collectionMetadata: this.collectionMetadataForm.value || {}, + userId: this.currentUser()?.id || '', + }; + + const dialogRef = this.dialogService.open(AddToCollectionConfirmationDialogComponent, { + width: '500px', + focusOnShow: false, + header: this.translateService.instant('collections.addToCollection.confirmationDialogHeader'), + closeOnEscape: true, + modal: true, + closable: true, + data: payload, + }); + + dialogRef.onClose.subscribe((result) => { + if (result) { + this.router.navigate(['/my-projects', this.selectedProject()?.id, 'overview']); + } + }); + } + + private initializeProvider(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) { + this.router.navigate(['/not-found']); + return; + } + + this.providerId.set(id); + this.actions.getCollectionProvider(id); + } + + private setupEffects(): void { + effect(() => { + this.destroyRef.onDestroy(() => { + this.actions.clearAddToCollectionState(); + }); + }); + } + + @HostListener('window:beforeunload', ['$event']) + onBeforeUnload($event: BeforeUnloadEvent): boolean { + $event.preventDefault(); + return false; + } +} 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 new file mode 100644 index 000000000..262b0cd7b --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html @@ -0,0 +1,63 @@ + + + +
+

{{ '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 }} +

+
+ } + } + + } +
+
+
+ + + +
+ @for (filterEntry of availableFilterEntries(); track filterEntry.key) { +
+ + +
+ } +
+
+ + +
+
+
+
diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.scss b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.scss new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..2816d42f9 --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts @@ -0,0 +1,108 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Select } from 'primeng/select'; +import { Step, StepItem, StepPanel } from 'primeng/stepper'; +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 '@osf/features/collections/store/collections'; + +@Component({ + selector: 'osf-collection-metadata-step', + imports: [Button, TranslatePipe, Select, ReactiveFormsModule, Step, StepItem, StepPanel, Tooltip], + templateUrl: './collection-metadata-step.component.html', + styleUrl: './collection-metadata-step.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CollectionMetadataStepComponent { + private readonly filterTypes = collectionFilterTypes; + protected readonly collectionFilterOptions = select(CollectionsSelectors.getAllFiltersOptions); + protected readonly availableFilterEntries = computed(() => { + const options = this.collectionFilterOptions(); + + return this.filterTypes + .map((key: string, index: number) => ({ + key, + value: index.toString(), + options: options[key as keyof typeof options] || [], + labelKey: `collections.filters.${key}.label`, + })) + .filter((entry: CollectionFilterEntry) => entry.options.length); + }); + + stepperActiveValue = input.required(); + targetStepValue = input.required(); + isDisabled = input.required(); + primaryCollectionId = input(); + + stepChange = output(); + metadataSaved = output(); + + protected collectionMetadataForm = signal(new FormGroup({})); + protected collectionMetadataSaved = signal(false); + + protected actions = createDispatchMap({ + getCollectionDetails: GetCollectionDetails, + }); + + constructor() { + this.setupEffects(); + } + + handleEditStep() { + this.stepChange.emit(this.targetStepValue()); + } + + handleDiscardChanges() { + this.collectionMetadataForm().reset(); + this.collectionMetadataSaved.set(false); + } + + handleSaveMetadata() { + this.collectionMetadataSaved.set(true); + this.metadataSaved.emit(this.collectionMetadataForm()); + this.stepChange.emit(AddToCollectionSteps.Complete); + } + + private buildCollectionMetadataForm() { + const filterEntries = this.availableFilterEntries(); + const formControls: Record = {}; + + filterEntries.forEach((entry: CollectionFilterEntry) => { + formControls[entry.key] = new FormControl('', [Validators.required]); + }); + + const newForm = new FormGroup(formControls); + this.collectionMetadataForm.set(newForm); + } + + private setupEffects(): void { + effect(() => { + const collectionId = this.primaryCollectionId(); + if (collectionId) { + this.actions.getCollectionDetails(collectionId); + } + }); + + effect(() => { + const filterEntries = this.availableFilterEntries(); + if (filterEntries.length) { + this.buildCollectionMetadataForm(); + } + }); + + effect(() => { + if (!this.collectionMetadataSaved() && this.stepperActiveValue() !== AddToCollectionSteps.CollectionMetadata) { + this.collectionMetadataForm().reset(); + } + }); + } +} diff --git a/src/app/features/collections/components/add-to-collection/index.ts b/src/app/features/collections/components/add-to-collection/index.ts new file mode 100644 index 000000000..3661b3214 --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/index.ts @@ -0,0 +1,5 @@ +export { AddToCollectionConfirmationDialogComponent } from './add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component'; +export { CollectionMetadataStepComponent } from './collection-metadata-step/collection-metadata-step.component'; +export { ProjectContributorsStepComponent } from './project-contributors-step/project-contributors-step.component'; +export { ProjectMetadataStepComponent } from './project-metadata-step/project-metadata-step.component'; +export { SelectProjectStepComponent } from './select-project-step/select-project-step.component'; 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 new file mode 100644 index 000000000..1bfc64fbd --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.html @@ -0,0 +1,58 @@ + + + +
+

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

+ @if (!isDisabled() && stepperActiveValue() !== targetStepValue()) { + @if (projectContributors().length) { +
+ @for (contributor of projectContributors(); track contributor.id) { +

{{ contributor.fullName }}

+ } +
+ } + + + } +
+
+
+ + + +
+ + +
+ + +
+
+
+
+
diff --git a/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.scss b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.scss new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..3a25ee8f3 --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts @@ -0,0 +1,192 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DialogService } from 'primeng/dynamicdialog'; +import { Step, StepItem, StepPanel } from 'primeng/stepper'; +import { Tooltip } from 'primeng/tooltip'; + +import { forkJoin } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, output, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { + AddContributorDialogComponent, + AddUnregisteredContributorDialogComponent, + ContributorsListComponent, +} from '@shared/components/contributors'; +import { AddContributorType, ResourceType } from '@shared/enums'; +import { ContributorDialogAddModel, ContributorModel } from '@shared/models'; +import { CustomConfirmationService, ToastService } from '@shared/services'; +import { AddContributor, ContributorsSelectors, DeleteContributor, UpdateContributor } from '@shared/stores'; +import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; +import { findChangedItems } from '@shared/utils'; + +@Component({ + selector: 'osf-project-contributors-step', + imports: [Button, TranslatePipe, ContributorsListComponent, Step, StepItem, StepPanel, Tooltip], + templateUrl: './project-contributors-step.component.html', + styleUrl: './project-contributors-step.component.scss', + providers: [DialogService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectContributorsStepComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly translateService = inject(TranslateService); + private readonly dialogService = inject(DialogService); + private readonly toastService = inject(ToastService); + private readonly customConfirmationService = inject(CustomConfirmationService); + + protected readonly projectContributors = select(ContributorsSelectors.getContributors); + protected readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + protected readonly isContributorsSubmitting = select(ContributorsSelectors.isContributorsSubmitting); + protected readonly selectedProject = select(ProjectsSelectors.getSelectedProject); + + private initialContributors = signal([]); + + stepperActiveValue = input.required(); + targetStepValue = input.required(); + isDisabled = input.required(); + isProjectMetadataSaved = input(false); + + stepChange = output(); + contributorsSaved = output(); + + protected actions = createDispatchMap({ + addContributor: AddContributor, + updateContributor: UpdateContributor, + deleteContributor: DeleteContributor, + }); + + constructor() { + this.setupEffects(); + } + + hasContributorsChanged(): boolean { + return JSON.stringify(this.initialContributors()) !== JSON.stringify(this.projectContributors()); + } + + handleRemoveContributor(contributor: ContributorModel) { + this.customConfirmationService.confirmDelete({ + headerKey: 'project.contributors.removeDialog.title', + messageKey: 'project.contributors.removeDialog.message', + messageParams: { name: contributor.fullName }, + acceptLabelKey: 'common.buttons.remove', + onConfirm: () => { + this.actions + .deleteContributor(this.selectedProject()?.id, ResourceType.Project, contributor.userId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => + this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { + name: contributor.fullName, + }), + }); + }, + }); + } + + handleAddContributor() { + this.openAddContributorDialog(); + } + + handleSaveContributors() { + if (this.hasContributorsChanged()) { + const updatedContributors = findChangedItems(this.initialContributors(), this.projectContributors(), 'id'); + + if (!updatedContributors.length) { + this.initialContributors.set(JSON.parse(JSON.stringify(this.projectContributors()))); + this.contributorsSaved.emit(); + } else { + const updateRequests = updatedContributors.map((payload) => + this.actions.updateContributor(this.selectedProject()?.id, ResourceType.Project, payload) + ); + forkJoin(updateRequests).subscribe(() => { + this.toastService.showSuccess('project.contributors.toastMessages.multipleUpdateSuccessMessage'); + this.initialContributors.set(JSON.parse(JSON.stringify(this.projectContributors()))); + this.contributorsSaved.emit(); + }); + } + } else { + this.contributorsSaved.emit(); + } + } + + handleEditStep() { + this.stepChange.emit(this.targetStepValue()); + } + + private openAddContributorDialog() { + const addedContributorIds = this.projectContributors().map((x) => x.userId); + + this.dialogService + .open(AddContributorDialogComponent, { + width: '448px', + data: addedContributorIds, + focusOnShow: false, + header: this.translateService.instant('project.contributors.addDialog.addRegisteredContributor'), + closeOnEscape: true, + modal: true, + closable: true, + }) + .onClose.pipe( + filter((res: ContributorDialogAddModel) => !!res), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((res: ContributorDialogAddModel) => { + if (res.type === AddContributorType.Unregistered) { + this.openAddUnregisteredContributorDialog(); + } else { + const addRequests = res.data.map((payload) => + this.actions.addContributor(this.selectedProject()?.id, ResourceType.Project, payload) + ); + + forkJoin(addRequests).subscribe(() => { + this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage'); + }); + } + }); + } + + private openAddUnregisteredContributorDialog() { + this.dialogService + .open(AddUnregisteredContributorDialogComponent, { + width: '448px', + focusOnShow: false, + header: this.translateService.instant('project.contributors.addDialog.addUnregisteredContributor'), + closeOnEscape: true, + modal: true, + closable: true, + }) + .onClose.pipe( + filter((res: ContributorDialogAddModel) => !!res), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((res: ContributorDialogAddModel) => { + if (res.type === AddContributorType.Registered) { + this.openAddContributorDialog(); + } else { + const successMessage = this.translateService.instant('project.contributors.toastMessages.addSuccessMessage'); + const params = { name: res.data[0].fullName }; + + this.actions.addContributor(this.selectedProject()?.id, ResourceType.Project, res.data[0]).subscribe({ + next: () => this.toastService.showSuccess(successMessage, params), + }); + } + }); + } + + private setupEffects(): void { + effect(() => { + const isMetadataSaved = this.isProjectMetadataSaved(); + const contributors = this.projectContributors(); + + if (isMetadataSaved && contributors.length && !this.initialContributors().length) { + this.initialContributors.set(JSON.parse(JSON.stringify(contributors))); + } + }); + } +} diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html new file mode 100644 index 000000000..53059b808 --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html @@ -0,0 +1,171 @@ + + + +
+

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

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

{{ 'collections.addToCollection.form.title' | translate }}

+

{{ selectedProject()?.title }}

+
+
+

{{ 'collections.addToCollection.form.description' | translate }}

+

+ {{ selectedProject()?.description ?? 'collections.addToCollection.noDescription' | translate }} +

+
+
+

{{ 'collections.addToCollection.form.license' | translate }}

+

+ {{ projectLicense()?.name ?? 'collections.addToCollection.noLicense' | translate }} +

+
+
+

{{ 'collections.addToCollection.form.tags' | translate }}

+
+ @if (projectTags().length) { + @for (tag of projectTags(); track tag) { + {{ tag }} + } + } @else { +

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

+ } +
+
+ + } +
+
+
+ + + +

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

+
+
+ + + @let titleControl = projectMetadataForm.controls[ProjectMetadataFormControls.Title]; + @if (titleControl.errors?.['required'] && (titleControl.touched || titleControl.dirty)) { + + {{ 'collections.addToCollection.form.fieldRequired' | translate }} + + } +
+ +
+ + + @let descriptionControl = projectMetadataForm.controls[ProjectMetadataFormControls.Description]; + @if (descriptionControl.errors?.['required'] && (descriptionControl.touched || descriptionControl.dirty)) { + + {{ 'collections.addToCollection.form.fieldRequired' | translate }} + + } +
+ + + + + + @let license = selectedLicense(); + @if (license) { + + @if (license.requiredFields.length) { +
+
+ + +
+ +
+ } + +

+ +

+ } +
+ +
+ + @let tagsControl = projectMetadataForm.controls[ProjectMetadataFormControls.Tags]; + + @if (tagsControl.errors?.['required'] && (tagsControl.touched || tagsControl.dirty)) { + + {{ 'collections.addToCollection.form.fieldRequired' | translate }} + + } +
+ +
+ + +
+
+
+
+
diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.scss b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.scss new file mode 100644 index 000000000..90d3be70a --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.scss @@ -0,0 +1,3 @@ +.highlight-block { + background-color: var(--bg-blue-2); +} 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 new file mode 100644 index 000000000..4dec2442d --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts @@ -0,0 +1,237 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { Chip } from 'primeng/chip'; +import { DatePicker } from 'primeng/datepicker'; +import { Divider } from 'primeng/divider'; +import { InputText } from 'primeng/inputtext'; +import { Message } from 'primeng/message'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { Step, StepItem, StepPanel } from 'primeng/stepper'; +import { Textarea } from 'primeng/textarea'; +import { Tooltip } from 'primeng/tooltip'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + input, + output, + signal, + untracked, +} from '@angular/core'; +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 { ProjectMetadataFormService } from '@osf/features/collections/services/project-metadata-form.service'; +import { GetCollectionLicenses } from '@osf/features/collections/store/add-to-collection/add-to-collection.actions'; +import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection/add-to-collection.selectors'; +import { TagsInputComponent, TextInputComponent, TruncatedTextComponent } from '@shared/components'; +import { InputLimits } from '@shared/constants'; +import { ResourceType } from '@shared/enums'; +import { License } from '@shared/models'; +import { Project } from '@shared/models/projects'; +import { InterpolatePipe } from '@shared/pipes'; +import { ToastService } from '@shared/services'; +import { GetAllContributors, UpdateProjectMetadata } from '@shared/stores'; +import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; + +@Component({ + selector: 'osf-project-metadata-step', + imports: [ + Button, + TranslatePipe, + InputText, + ReactiveFormsModule, + Textarea, + FormsModule, + TagsInputComponent, + Chip, + Card, + Message, + DatePicker, + Divider, + InterpolatePipe, + TextInputComponent, + TruncatedTextComponent, + Select, + Step, + StepItem, + StepPanel, + Tooltip, + ], + templateUrl: './project-metadata-step.component.html', + styleUrl: './project-metadata-step.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectMetadataStepComponent { + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly formService = inject(ProjectMetadataFormService); + protected readonly currentYear = new Date(); + + protected readonly ProjectMetadataFormControls = ProjectMetadataFormControls; + protected readonly inputLimits = InputLimits; + + protected readonly selectedProject = select(ProjectsSelectors.getSelectedProject); + protected readonly collectionLicenses = select(AddToCollectionSelectors.getCollectionLicenses); + protected readonly isSelectedProjectUpdateSubmitting = select(ProjectsSelectors.getSelectedProjectUpdateSubmitting); + + stepperActiveValue = input.required(); + targetStepValue = input.required(); + isDisabled = input.required(); + providerId = input.required(); + + stepChange = output(); + metadataSaved = output(); + + protected actions = createDispatchMap({ + updateCollectionSubmissionMetadata: UpdateProjectMetadata, + getAllContributors: GetAllContributors, + getCollectionLicenses: GetCollectionLicenses, + }); + + protected readonly projectMetadataForm: FormGroup = this.formService.createForm(); + protected readonly projectTags = signal([]); + protected readonly selectedLicense = signal(null); + + private readonly projectMetadataFormValue = toSignal(this.projectMetadataForm.valueChanges); + private readonly initialProjectMetadataFormValues = signal(null); + + protected readonly projectLicense = computed(() => { + const project = this.selectedProject(); + return project ? (this.collectionLicenses().find((license) => license.id === project.licenseId) ?? null) : null; + }); + + private readonly isFormUnchanged = computed(() => { + const currentFormValues = this.projectMetadataFormValue(); + const initialFormValues = this.initialProjectMetadataFormValues(); + + return this.formService.isFormUnchanged(currentFormValues ?? null, initialFormValues); + }); + + constructor() { + this.setupEffects(); + } + + handleSelectCollectionLicense(event: SelectChangeEvent): void { + const license = event.value as License; + const project = this.selectedProject(); + + if (!license || !project) return; + + this.selectedLicense.set(license); + this.formService.updateLicenseValidators(this.projectMetadataForm, license); + this.formService.patchLicenseData(this.projectMetadataForm, license, project); + } + + handleTagsChange(tags: string[]): void { + this.projectTags.set(tags); + this.formService.updateTagsInForm(this.projectMetadataForm, tags); + } + + handleDiscardChanges(): void { + this.formService.resetForm(this.projectMetadataForm); + this.populateFormFromProject(); + } + + handleUpdateMetadata(): void { + const selectedProject = this.selectedProject(); + + if (!selectedProject || !this.projectMetadataForm.valid) return; + + if (this.isFormUnchanged()) { + this.proceedToNextStep(selectedProject.id); + return; + } + + this.updateProjectMetadata(selectedProject); + } + + handleEditStep(): void { + this.stepChange.emit(this.targetStepValue()); + } + + private populateFormFromProject(): void { + const project = this.selectedProject(); + if (!project) return; + + const projectLicense = this.projectLicense(); + + if (projectLicense) { + this.formService.updateLicenseValidators(this.projectMetadataForm, projectLicense); + } + + const { tags } = this.formService.populateFormFromProject(this.projectMetadataForm, project, projectLicense); + + this.projectTags.set(tags); + } + + private proceedToNextStep(projectId: string): void { + this.stepChange.emit(AddToCollectionSteps.ProjectContributors); + this.actions.getAllContributors(projectId, ResourceType.Project); + this.metadataSaved.emit(); + } + + private updateProjectMetadata(selectedProject: Project): void { + const metadata = this.formService.buildMetadataPayload(this.projectMetadataForm, selectedProject); + + this.actions + .updateCollectionSubmissionMetadata(metadata) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.actions.getAllContributors(selectedProject.id, ResourceType.Project); + this.stepChange.emit(AddToCollectionSteps.ProjectContributors); + }, + complete: () => { + this.metadataSaved.emit(); + this.toastService.showSuccess('collections.addToCollection.projectMetadataUpdateSuccess'); + }, + }); + } + + private setupEffects(): void { + effect(() => { + const licenses = this.collectionLicenses(); + const providerId = this.providerId(); + + if (!licenses.length && providerId) { + this.actions.getCollectionLicenses(providerId); + } + }); + + effect(() => { + const selectedProject = this.selectedProject(); + if (!selectedProject) return; + + const license = this.collectionLicenses().find((l) => selectedProject.licenseId === l.id); + + if (license) { + untracked(() => { + this.selectedLicense.set(license); + this.formService.updateLicenseValidators(this.projectMetadataForm, license); + }); + } + + this.populateFormFromProject(); + }); + + effect(() => { + const formValue = this.projectMetadataFormValue(); + const selectedProject = this.selectedProject(); + + if (selectedProject && formValue && !this.initialProjectMetadataFormValues()) { + this.initialProjectMetadataFormValues.set(JSON.stringify(formValue)); + } + }); + } +} 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 new file mode 100644 index 000000000..d6fa5095a --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html @@ -0,0 +1,47 @@ + + + +
+

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

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

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

+ + } +
+
+
+ + + +
+ + + {{ selectedOption.label | translate }} + + +
+
+
+
diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.scss b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts new file mode 100644 index 000000000..f8cfde76e --- /dev/null +++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts @@ -0,0 +1,161 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Select, SelectChangeEvent, SelectFilterEvent } from 'primeng/select'; +import { Step, StepItem, StepPanel } from 'primeng/stepper'; + +import { debounceTime, distinctUntilChanged, Subject, takeUntil } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { UserSelectors } from '@core/store/user'; +import { AddToCollectionSteps } from '@osf/features/collections/enums'; +import { CollectionsSelectors, GetUserCollectionSubmissions } from '@osf/features/collections/store/collections'; +import { GetProjects, SetSelectedProject } from '@osf/shared/stores'; +import { Project } from '@shared/models/projects'; +import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; + +@Component({ + selector: 'osf-select-project-step', + imports: [Button, TranslatePipe, Select, FormsModule, Step, StepItem, StepPanel], + templateUrl: './select-project-step.component.html', + styleUrl: './select-project-step.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SelectProjectStepComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly translateService = inject(TranslateService); + private readonly destroy$ = new Subject(); + private readonly filterSubject = new Subject(); + + protected projects = select(ProjectsSelectors.getProjects); + protected isProjectsLoading = select(ProjectsSelectors.getProjectsLoading); + protected selectedProject = select(ProjectsSelectors.getSelectedProject); + protected currentUser = select(UserSelectors.getCurrentUser); + protected currentUserSubmissions = select(CollectionsSelectors.getUserCollectionSubmissions); + protected isSubmissionsLoading = select(CollectionsSelectors.getUserCollectionSubmissionsLoading); + + stepperActiveValue = input.required(); + targetStepValue = input.required(); + collectionId = input.required(); + + stepChange = output(); + projectSelected = output(); + + protected projectsOptions = signal<{ label: string; value: Project }[]>([]); + + protected filterMessage = computed(() => { + const isLoading = this.isProjectsLoading() || this.isSubmissionsLoading(); + return isLoading + ? this.translateService.instant('collections.addToCollection.form.loadingPlaceholder') + : this.translateService.instant('collections.addToCollection.form.noProjectsFound'); + }); + + protected actions = createDispatchMap({ + getProjects: GetProjects, + setSelectedProject: SetSelectedProject, + getUserCollectionSubmissions: GetUserCollectionSubmissions, + }); + + constructor() { + this.setupEffects(); + this.setupFilterDebounce(); + } + + handleProjectChange(event: SelectChangeEvent) { + const project = event.value; + if (project) { + this.actions.setSelectedProject(project); + this.projectSelected.emit(); + this.stepChange.emit(AddToCollectionSteps.ProjectMetadata); + } + } + + handleFilterSearch(event: SelectFilterEvent) { + event.originalEvent.preventDefault(); + this.filterSubject.next(event.filter); + } + + handleEditStep() { + this.stepChange.emit(this.targetStepValue()); + } + + private setupEffects(): void { + effect(() => { + const currentUser = this.currentUser(); + if (currentUser) { + this.actions.getProjects(currentUser.id); + } + }); + + effect(() => { + const projects = this.projects(); + const collectionId = this.collectionId(); + const isProjectsLoading = this.isProjectsLoading(); + + if (projects.length && collectionId && !isProjectsLoading) { + const projectIds = projects.map((project) => project.id); + this.actions.getUserCollectionSubmissions(collectionId, projectIds); + } + }); + + effect(() => { + const isProjectsLoading = this.isProjectsLoading(); + const isSubmissionsLoading = this.isSubmissionsLoading(); + const projects = this.projects(); + const submissions = this.currentUserSubmissions(); + + if (isProjectsLoading || isSubmissionsLoading || !projects.length) { + this.projectsOptions.set([]); + } + + if (!isProjectsLoading && !isSubmissionsLoading && projects.length) { + const submissionProjectIds = new Set(submissions.map((submission) => submission.nodeId)); + const availableProjects = projects.filter((project) => !submissionProjectIds.has(project.id)); + + const options = availableProjects.map((project) => ({ + label: project.title, + value: project, + })); + + this.projectsOptions.set(options); + } + }); + + effect(() => { + this.destroyRef.onDestroy(() => { + this.destroy$.next(); + this.destroy$.complete(); + }); + }); + } + + private setupFilterDebounce(): void { + this.filterSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe((filterValue) => { + const currentUser = this.currentUser(); + if (!currentUser) return; + + const params: Record = { + 'filter[current_user_permissions]': 'admin', + 'filter[title]': filterValue, + }; + + this.actions.getProjects(currentUser.id, params); + }); + } +} diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.html b/src/app/features/collections/components/collections-discover/collections-discover.component.html new file mode 100644 index 000000000..cd1f09fdf --- /dev/null +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.html @@ -0,0 +1,37 @@ +@if (!isProviderLoading()) { +
+
+
+ +

{{ collectionProvider()?.name }}

+
+ + +
+ +
+ + @if (collectionProvider()?.description) { +
+ } +
+ +
+ +
+
+} @else { + +} diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.scss b/src/app/features/collections/components/collections-discover/collections-discover.component.scss new file mode 100644 index 000000000..5c7d2b40e --- /dev/null +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.scss @@ -0,0 +1,8 @@ +@use "assets/styles/variables" as var; +@use "assets/styles/mixins" as mix; + +:host { + --collection-bg-color: #013b5c; + @include mix.flex-column; + flex: 1; +} diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts new file mode 100644 index 000000000..b5dd4ea4b --- /dev/null +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CollectionsDiscoverComponent } from './collections-discover.component'; + +describe('CollectionsDiscoverComponent', () => { + let component: CollectionsDiscoverComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CollectionsDiscoverComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CollectionsDiscoverComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.ts new file mode 100644 index 000000000..14fa8bcb0 --- /dev/null +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts @@ -0,0 +1,181 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DialogService } from 'primeng/dynamicdialog'; + +import { debounceTime } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; + +import { CollectionsHelpDialogComponent, CollectionsMainContentComponent } from '@osf/features/collections/components'; +import { CollectionsFilters } from '@osf/features/collections/models'; +import { CollectionsQuerySyncService } from '@osf/features/collections/services'; +import { + ClearCollections, + ClearCollectionSubmissions, + CollectionsSelectors, + GetCollectionDetails, + GetCollectionProvider, + SearchCollectionSubmissions, + SetPageNumber, + SetSearchValue, +} from '@osf/features/collections/store/collections'; +import { LoadingSpinnerComponent, SearchInputComponent } from '@shared/components'; + +@Component({ + selector: 'osf-collections-discover', + imports: [ + SearchInputComponent, + TranslatePipe, + Button, + CollectionsMainContentComponent, + LoadingSpinnerComponent, + RouterLink, + ], + templateUrl: './collections-discover.component.html', + styleUrl: './collections-discover.component.scss', + providers: [DialogService, CollectionsQuerySyncService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CollectionsDiscoverComponent { + private router = inject(Router); + private route = inject(ActivatedRoute); + private dialogService = inject(DialogService); + private translateService = inject(TranslateService); + private querySyncService = inject(CollectionsQuerySyncService); + private destroyRef = inject(DestroyRef); + + protected searchControl = new FormControl(''); + protected providerId = signal(''); + + protected collectionProvider = select(CollectionsSelectors.getCollectionProvider); + protected collectionDetails = select(CollectionsSelectors.getCollectionDetails); + protected selectedFilters = select(CollectionsSelectors.getAllSelectedFilters); + protected sortBy = select(CollectionsSelectors.getSortBy); + protected searchText = select(CollectionsSelectors.getSearchText); + protected pageNumber = select(CollectionsSelectors.getPageNumber); + protected isProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); + protected primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id); + + protected actions = createDispatchMap({ + getCollectionProvider: GetCollectionProvider, + getCollectionDetails: GetCollectionDetails, + setSearchValue: SetSearchValue, + searchCollectionSubmissions: SearchCollectionSubmissions, + setPageNumber: SetPageNumber, + clearCollections: ClearCollections, + clearCollectionsSubmissions: ClearCollectionSubmissions, + }); + + constructor() { + this.initializeProvider(); + this.setupEffects(); + this.setupSearchBinding(); + } + + protected openHelpDialog(): void { + this.dialogService.open(CollectionsHelpDialogComponent, { + focusOnShow: false, + header: this.translateService.instant('collections.helpDialog.header'), + closeOnEscape: true, + modal: true, + closable: true, + }); + } + + protected onSearchTriggered(searchValue: string): void { + this.actions.setSearchValue(searchValue); + this.actions.setPageNumber('1'); + } + + private initializeProvider(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) { + this.router.navigate(['/not-found']); + return; + } + + this.providerId.set(id); + this.actions.getCollectionProvider(id); + } + + private setupEffects(): void { + this.querySyncService.initializeFromUrl(); + + effect(() => { + const collectionId = this.primaryCollectionId(); + if (collectionId) { + this.actions.getCollectionDetails(collectionId); + } + }); + + effect(() => { + const searchText = this.searchText(); + const sortBy = this.sortBy(); + const selectedFilters = this.selectedFilters(); + const pageNumber = this.pageNumber(); + + if (searchText !== undefined && sortBy !== undefined && selectedFilters && pageNumber) { + this.querySyncService.syncStoreToUrl(searchText, sortBy, selectedFilters, pageNumber); + } + }); + + effect(() => { + const searchText = this.searchText(); + const sortBy = this.sortBy(); + const selectedFilters = this.selectedFilters(); + const pageNumber = this.pageNumber(); + const providerId = this.providerId(); + const collectionDetails = this.collectionDetails(); + + if (searchText !== undefined && selectedFilters && pageNumber && providerId && collectionDetails) { + const activeFilters = this.getActiveFilters(selectedFilters); + this.actions.clearCollectionsSubmissions(); + this.actions.searchCollectionSubmissions(providerId, searchText, activeFilters, pageNumber, sortBy); + } + }); + + effect(() => { + this.destroyRef.onDestroy(() => { + this.actions.clearCollections(); + }); + }); + } + + private getActiveFilters(filters: CollectionsFilters): Record { + return Object.entries(filters) + .filter(([key, value]) => value.length) + .reduce( + (acc, [key, value]) => { + acc[key] = value; + return acc; + }, + {} as Record + ); + } + + private setupSearchBinding(): void { + effect(() => { + const storeSearchText = this.searchText(); + const currentControlValue = this.searchControl.value; + + if (storeSearchText !== currentControlValue) { + this.searchControl.setValue(storeSearchText, { emitEvent: false }); + } + }); + + this.searchControl.valueChanges + .pipe(debounceTime(300), takeUntilDestroyed(this.destroyRef)) + .subscribe((searchValue) => { + const trimmedValue = searchValue?.trim() || ''; + if (trimmedValue !== this.searchText()) { + this.actions.setSearchValue(trimmedValue); + } + }); + } +} diff --git a/src/app/features/collections/components/collections-filter-chips/collections-filter-chips.component.ts b/src/app/features/collections/components/collections-filter-chips/collections-filter-chips.component.ts index d57452e8d..392395f71 100644 --- a/src/app/features/collections/components/collections-filter-chips/collections-filter-chips.component.ts +++ b/src/app/features/collections/components/collections-filter-chips/collections-filter-chips.component.ts @@ -18,7 +18,7 @@ import { SetStatusFilters, SetStudyDesignFilters, SetVolumeFilters, -} from '@osf/features/collections/store'; +} from '@osf/features/collections/store/collections'; @Component({ selector: 'osf-collections-filter-chips', diff --git a/src/app/features/collections/components/collections-filters/collections-filters.component.ts b/src/app/features/collections/components/collections-filters/collections-filters.component.ts index dac9d23d2..ab00fba8d 100644 --- a/src/app/features/collections/components/collections-filters/collections-filters.component.ts +++ b/src/app/features/collections/components/collections-filters/collections-filters.component.ts @@ -22,7 +22,7 @@ import { SetStatusFilters, SetStudyDesignFilters, SetVolumeFilters, -} from '@osf/features/collections/store'; +} from '@osf/features/collections/store/collections'; @Component({ selector: 'osf-collections-filters', diff --git a/src/app/features/collections/components/collections-main-content/collections-main-content.component.html b/src/app/features/collections/components/collections-main-content/collections-main-content.component.html index 71c08355a..90b8855ed 100644 --- a/src/app/features/collections/components/collections-main-content/collections-main-content.component.html +++ b/src/app/features/collections/components/collections-main-content/collections-main-content.component.html @@ -68,7 +68,7 @@

{{ 'collections.searchResults.noResults' | translate }} } @else { - + } } diff --git a/src/app/features/collections/components/collections-main-content/collections-main-content.component.ts b/src/app/features/collections/components/collections-main-content/collections-main-content.component.ts index 98700fa6d..acd1c9976 100644 --- a/src/app/features/collections/components/collections-main-content/collections-main-content.component.ts +++ b/src/app/features/collections/components/collections-main-content/collections-main-content.component.ts @@ -12,7 +12,7 @@ import { FormsModule } from '@angular/forms'; import { CollectionsFilterChipsComponent } from '@osf/features/collections/components'; import { collectionsSortOptions } from '@osf/features/collections/constants'; -import { CollectionsSelectors, SetSortBy } from '@osf/features/collections/store'; +import { CollectionsSelectors, SetSortBy } from '@osf/features/collections/store/collections'; import { IS_WEB } from '@shared/utils'; import { CollectionsFiltersComponent } from '../collections-filters/collections-filters.component'; @@ -39,6 +39,7 @@ export class CollectionsMainContentComponent { protected isWeb = toSignal(inject(IS_WEB)); protected selectedSort = select(CollectionsSelectors.getSortBy); protected collectionSubmissions = select(CollectionsSelectors.getCollectionSubmissions); + protected isCollectionSubmissionsLoading = select(CollectionsSelectors.getCollectionSubmissionsLoading); protected isFiltersOpen = signal(false); protected isSortingOpen = signal(false); diff --git a/src/app/features/collections/components/collections-search-results/collections-search-results.component.ts b/src/app/features/collections/components/collections-search-results/collections-search-results.component.ts index 5786279ed..6511d0c90 100644 --- a/src/app/features/collections/components/collections-search-results/collections-search-results.component.ts +++ b/src/app/features/collections/components/collections-search-results/collections-search-results.component.ts @@ -8,7 +8,7 @@ import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; -import { CollectionsSelectors, SetPageNumber } from '@osf/features/collections/store'; +import { CollectionsSelectors, SetPageNumber } from '@osf/features/collections/store/collections'; import { CustomPaginatorComponent } from '@osf/shared/components'; import { CollectionsSearchResultCardComponent } from '../collections-search-result-card/collections-search-result-card.component'; diff --git a/src/app/features/collections/enums/add-to-collection-steps.enum.ts b/src/app/features/collections/enums/add-to-collection-steps.enum.ts new file mode 100644 index 000000000..d76918855 --- /dev/null +++ b/src/app/features/collections/enums/add-to-collection-steps.enum.ts @@ -0,0 +1,7 @@ +export enum AddToCollectionSteps { + SelectProject = 0, + ProjectMetadata, + ProjectContributors, + CollectionMetadata, + Complete, +} diff --git a/src/app/features/collections/enums/index.ts b/src/app/features/collections/enums/index.ts index a7d347984..28ab29dad 100644 --- a/src/app/features/collections/enums/index.ts +++ b/src/app/features/collections/enums/index.ts @@ -1 +1,3 @@ +export * from './add-to-collection-steps.enum'; export * from './collection-filter-type.enum'; +export * from './project-metadata-form-controls.enum'; diff --git a/src/app/features/collections/enums/project-metadata-form-controls.enum.ts b/src/app/features/collections/enums/project-metadata-form-controls.enum.ts new file mode 100644 index 000000000..d43c470ea --- /dev/null +++ b/src/app/features/collections/enums/project-metadata-form-controls.enum.ts @@ -0,0 +1,8 @@ +export enum ProjectMetadataFormControls { + Title = 'title', + Description = 'description', + License = 'license', + Tags = 'tags', + LicenseYear = 'licenseYear', + CopyrightHolders = 'copyrightHolders', +} diff --git a/src/app/features/collections/mappers/collections.mapper.ts b/src/app/features/collections/mappers/collections.mapper.ts index 1a4ab1764..62f54bb5d 100644 --- a/src/app/features/collections/mappers/collections.mapper.ts +++ b/src/app/features/collections/mappers/collections.mapper.ts @@ -75,6 +75,32 @@ export class CollectionsMapper { }; } + static fromGetCollectionSubmissionsResponse(response: CollectionSubmissionJsonApi[]): CollectionSubmission[] { + return response.map((submission) => ({ + id: submission.id, + type: submission.type, + nodeId: submission.embeds.guid.data.id, + nodeUrl: submission.embeds.guid.data.links.html, + title: submission.embeds.guid.data.attributes.title, + description: submission.embeds.guid.data.attributes.description, + category: submission.embeds.guid.data.attributes.category, + dateCreated: submission.embeds.guid.data.attributes.date_created, + dateModified: submission.embeds.guid.data.attributes.date_modified, + public: submission.embeds.guid.data.attributes.public, + reviewsState: submission.attributes.reviews_state, + collectedType: submission.attributes.collected_type, + status: submission.attributes.status, + volume: submission.attributes.volume, + issue: submission.attributes.issue, + programArea: submission.attributes.program_area, + schoolType: submission.attributes.school_type, + studyDesign: submission.attributes.study_design, + dataType: submission.attributes.data_type, + disease: submission.attributes.disease, + gradeLevels: submission.attributes.grade_levels, + })); + } + static fromPostCollectionSubmissionsResponse(response: CollectionSubmissionJsonApi[]): CollectionSubmission[] { return response.map((submission) => ({ id: submission.id, diff --git a/src/app/features/collections/models/collection-filter-entry.model.ts b/src/app/features/collections/models/collection-filter-entry.model.ts new file mode 100644 index 000000000..f04210a83 --- /dev/null +++ b/src/app/features/collections/models/collection-filter-entry.model.ts @@ -0,0 +1,6 @@ +export interface CollectionFilterEntry { + key: string; + value: string; + options: string[]; + labelKey: string; +} diff --git a/src/app/features/collections/models/collection-license-json-api.models.ts b/src/app/features/collections/models/collection-license-json-api.models.ts new file mode 100644 index 000000000..42085e518 --- /dev/null +++ b/src/app/features/collections/models/collection-license-json-api.models.ts @@ -0,0 +1,22 @@ +import { LicenseRecordJsonApi } from '@shared/models'; + +export interface CollectionSubmissionMetadataPayloadJsonApi { + data: { + type: 'nodes'; + id: string; + relationships: { + license: { + data: { + id: string; + type: string; + }; + }; + }; + attributes: { + node_license?: LicenseRecordJsonApi; + title?: string; + description?: string; + tags?: string[]; + }; + }; +} diff --git a/src/app/features/collections/models/collection-submission-payload-json-api.model.ts b/src/app/features/collections/models/collection-submission-payload-json-api.model.ts new file mode 100644 index 000000000..c45136263 --- /dev/null +++ b/src/app/features/collections/models/collection-submission-payload-json-api.model.ts @@ -0,0 +1,22 @@ +export interface CollectionSubmissionPayloadJsonApi { + data: { + type: 'collection-submissions'; + attributes: { + guid: string; + }; + relationships: { + collection: { + data: { + id: string; + type: 'collections'; + }; + }; + creator: { + data: { + type: 'users'; + id: string; + }; + }; + }; + }; +} diff --git a/src/app/features/collections/models/collection-submission-payload.ts b/src/app/features/collections/models/collection-submission-payload.ts new file mode 100644 index 000000000..080bc0992 --- /dev/null +++ b/src/app/features/collections/models/collection-submission-payload.ts @@ -0,0 +1,6 @@ +export interface CollectionSubmissionPayload { + collectionId: string; + projectId: string; + userId: string; + collectionMetadata: Record; +} diff --git a/src/app/features/collections/models/collections.models.ts b/src/app/features/collections/models/collections.models.ts index 961654f4d..7c76cb833 100644 --- a/src/app/features/collections/models/collections.models.ts +++ b/src/app/features/collections/models/collections.models.ts @@ -81,5 +81,5 @@ export interface CollectionSubmission { dataType: string; disease: string; gradeLevels: string; - contributors: CollectionContributor[]; + contributors?: CollectionContributor[]; } diff --git a/src/app/features/collections/models/index.ts b/src/app/features/collections/models/index.ts index df5d4745f..4d84b4d20 100644 --- a/src/app/features/collections/models/index.ts +++ b/src/app/features/collections/models/index.ts @@ -1,4 +1,8 @@ +export * from './collection-filter-entry.model'; +export * from './collection-license-json-api.models'; +export * from './collection-submission-payload'; export * from './collections.models'; export * from './collections-filters.model'; export * from './collections-json-api.models'; export * from './collections-query-params.model'; +export * from './project-metadata-form.model'; diff --git a/src/app/features/collections/models/project-metadata-form.model.ts b/src/app/features/collections/models/project-metadata-form.model.ts new file mode 100644 index 000000000..bdb1d0073 --- /dev/null +++ b/src/app/features/collections/models/project-metadata-form.model.ts @@ -0,0 +1,13 @@ +import { FormControl } from '@angular/forms'; + +import { ProjectMetadataFormControls } from '@osf/features/collections/enums'; +import { License } from '@shared/models'; + +export interface ProjectMetadataForm { + [ProjectMetadataFormControls.Title]: FormControl; + [ProjectMetadataFormControls.Description]: FormControl; + [ProjectMetadataFormControls.License]: FormControl; + [ProjectMetadataFormControls.Tags]: FormControl; + [ProjectMetadataFormControls.LicenseYear]: FormControl; + [ProjectMetadataFormControls.CopyrightHolders]: FormControl; +} diff --git a/src/app/features/collections/services/add-to-collection.service.ts b/src/app/features/collections/services/add-to-collection.service.ts new file mode 100644 index 000000000..b6e640e83 --- /dev/null +++ b/src/app/features/collections/services/add-to-collection.service.ts @@ -0,0 +1,61 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@core/services'; +import { CollectionSubmissionPayload } from '@osf/features/collections/models'; +import { CollectionSubmissionPayloadJsonApi } from '@osf/features/collections/models/collection-submission-payload-json-api.model'; +import { LicensesMapper } from '@shared/mappers'; +import { License, LicensesResponseJsonApi } from '@shared/models'; +import { convertToSnakeCase } from '@shared/utils'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class AddToCollectionService { + private apiUrl = environment.apiUrl; + private readonly jsonApiService = inject(JsonApiService); + + fetchCollectionLicenses(providerId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/providers/collections/${providerId}/licenses/`, { + 'page[size]': 100, + sort: 'name', + }) + .pipe(map((licenses) => LicensesMapper.fromLicensesResponse(licenses))); + } + + createCollectionSubmission(payload: CollectionSubmissionPayload): Observable { + const collectionId = payload.collectionId; + + const collectionsMetadata = convertToSnakeCase(payload.collectionMetadata); + + const metadata: CollectionSubmissionPayloadJsonApi = { + data: { + type: 'collection-submissions', + attributes: { + guid: payload.projectId, + ...collectionsMetadata, + }, + relationships: { + collection: { + data: { + id: collectionId, + type: 'collections', + }, + }, + creator: { + data: { + type: 'users', + id: payload.userId, + }, + }, + }, + }, + }; + + return this.jsonApiService.post(`${this.apiUrl}/collections/${collectionId}/collection_submissions/`, metadata); + } +} 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 281365746..c7b43a16d 100644 --- a/src/app/features/collections/services/collections-query-sync.service.ts +++ b/src/app/features/collections/services/collections-query-sync.service.ts @@ -7,8 +7,13 @@ import { ActivatedRoute, Router } from '@angular/router'; import { collectionsSortOptions } from '@osf/features/collections/constants'; import { queryParamsKeys } from '@osf/features/collections/constants/query-params-keys.const'; import { CollectionQueryParams, CollectionsFilters } from '@osf/features/collections/models'; -import { CollectionsSelectors, SetAllFilters, SetSearchValue, SetSortBy } from '@osf/features/collections/store'; -import { SetPageNumber } from '@osf/features/collections/store/collections.actions'; +import { + CollectionsSelectors, + SetAllFilters, + SetSearchValue, + SetSortBy, +} from '@osf/features/collections/store/collections'; +import { SetPageNumber } from '@osf/features/collections/store/collections/collections.actions'; @Injectable() export class CollectionsQuerySyncService { diff --git a/src/app/features/collections/services/collections.service.ts b/src/app/features/collections/services/collections.service.ts index ddf0091fa..75b138d29 100644 --- a/src/app/features/collections/services/collections.service.ts +++ b/src/app/features/collections/services/collections.service.ts @@ -4,10 +4,10 @@ import { forkJoin, map, Observable, of, switchMap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { JsonApiResponseWithPaging } from '@core/models'; +import { JsonApiResponse, JsonApiResponseWithPaging } from '@core/models'; import { JsonApiService } from '@osf/core/services'; import { CollectionsMapper } from '@osf/features/collections/mappers'; -import { SetTotalSubmissions } from '@osf/features/collections/store'; +import { SetTotalSubmissions } from '@osf/features/collections/store/collections'; import { CollectionContributor, @@ -92,7 +92,7 @@ export class CollectionsService { .pipe(map((response) => CollectionsMapper.fromGetCollectionDetailsResponse(response.data))); } - getCollectionSubmissions( + searchCollectionSubmissions( providerId: string, searchText: string, activeFilters: Record, @@ -146,6 +146,15 @@ export class CollectionsService { ); } + fetchAllUserCollectionSubmissions(providerId: string, projectIds: string[]): Observable { + const pendingSubmissions$ = this.fetchUserCollectionSubmissionsByStatus(providerId, projectIds, 'pending'); + const acceptedSubmissions$ = this.fetchUserCollectionSubmissionsByStatus(providerId, projectIds, 'accepted'); + + return forkJoin([pendingSubmissions$, acceptedSubmissions$]).pipe( + map(([pending, accepted]) => [...pending, ...accepted]) + ); + } + private getCollectionContributors(contributorsUrl: string): Observable { const params: Record = { 'fields[users]': 'full_name', @@ -185,4 +194,25 @@ export class CollectionsService { return this.jsonApiService.delete(url, payload); } + + private fetchUserCollectionSubmissionsByStatus( + providerId: string, + projectIds: string[], + submissionStatus: string + ): Observable { + const params: Record = { + 'filter[reviews_state]': submissionStatus, + 'filter[id]': projectIds.join(','), + }; + + return this.jsonApiService + .get< + JsonApiResponse + >(`${environment.apiUrl}/collections/${providerId}/collection_submissions/`, params) + .pipe( + map((response) => { + return CollectionsMapper.fromGetCollectionSubmissionsResponse(response.data); + }) + ); + } } diff --git a/src/app/features/collections/services/index.ts b/src/app/features/collections/services/index.ts index da877760a..f02958ed8 100644 --- a/src/app/features/collections/services/index.ts +++ b/src/app/features/collections/services/index.ts @@ -1,2 +1,4 @@ +export * from './add-to-collection.service'; export * from './collections.service'; export * from './collections-query-sync.service'; +export * from './project-metadata-form.service'; diff --git a/src/app/features/collections/services/project-metadata-form.service.ts b/src/app/features/collections/services/project-metadata-form.service.ts new file mode 100644 index 000000000..5b41b4758 --- /dev/null +++ b/src/app/features/collections/services/project-metadata-form.service.ts @@ -0,0 +1,115 @@ +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 { License, ProjectMetadataUpdatePayload } from '@shared/models'; +import { Project } from '@shared/models/projects'; +import { CustomValidators } from '@shared/utils'; + +@Injectable({ + providedIn: 'root', +}) +export class ProjectMetadataFormService { + private readonly currentYear = new Date(); + + createForm(): FormGroup { + return new FormGroup({ + [ProjectMetadataFormControls.Title]: new FormControl('', { + nonNullable: true, + validators: [CustomValidators.requiredTrimmed()], + }), + [ProjectMetadataFormControls.Description]: new FormControl('', { + nonNullable: true, + validators: [CustomValidators.requiredTrimmed()], + }), + [ProjectMetadataFormControls.License]: new FormControl(null, { + nonNullable: true, + validators: [Validators.required], + }), + [ProjectMetadataFormControls.Tags]: new FormControl([], { + nonNullable: true, + validators: [CustomValidators.requiredArrayValidator()], + }), + [ProjectMetadataFormControls.LicenseYear]: new FormControl(this.currentYear.getFullYear().toString(), { + nonNullable: true, + }), + [ProjectMetadataFormControls.CopyrightHolders]: new FormControl('', { + nonNullable: true, + }), + }); + } + + updateLicenseValidators(form: FormGroup, license: License): void { + const yearControl = form.get(ProjectMetadataFormControls.LicenseYear); + const copyrightHoldersControl = form.get(ProjectMetadataFormControls.CopyrightHolders); + + const validators = license.requiredFields.length ? [CustomValidators.requiredTrimmed()] : []; + + yearControl?.setValidators(validators); + copyrightHoldersControl?.setValidators(validators); + + yearControl?.updateValueAndValidity(); + copyrightHoldersControl?.updateValueAndValidity(); + } + + populateFormFromProject( + form: FormGroup, + project: Project, + license: License | null + ): { tags: string[] } { + const tags = project.tags || []; + + form.patchValue({ + [ProjectMetadataFormControls.Title]: project.title, + [ProjectMetadataFormControls.Description]: project.description || '', + [ProjectMetadataFormControls.License]: license, + [ProjectMetadataFormControls.Tags]: tags, + [ProjectMetadataFormControls.LicenseYear]: + project.licenseOptions?.year || this.currentYear.getFullYear().toString(), + [ProjectMetadataFormControls.CopyrightHolders]: project.licenseOptions?.copyrightHolders || '', + }); + + return { tags }; + } + + patchLicenseData(form: FormGroup, license: License, project: Project): void { + form.patchValue({ + [ProjectMetadataFormControls.License]: license, + [ProjectMetadataFormControls.LicenseYear]: + project.licenseOptions?.year || this.currentYear.getFullYear().toString(), + [ProjectMetadataFormControls.CopyrightHolders]: project.licenseOptions?.copyrightHolders || '', + }); + } + + updateTagsInForm(form: FormGroup, tags: string[]): void { + form.patchValue({ [ProjectMetadataFormControls.Tags]: tags }); + form.get(ProjectMetadataFormControls.Tags)?.markAsTouched(); + } + + buildMetadataPayload(form: FormGroup, project: Project): ProjectMetadataUpdatePayload { + const formValue = form.value; + + return { + id: project.id, + title: formValue.title || '', + description: formValue.description || '', + tags: formValue.tags || [], + licenseId: formValue.license?.id || '', + licenseOptions: formValue.license?.requiredFields?.length + ? { + year: formValue.licenseYear || '', + copyrightHolders: formValue.copyrightHolders || '', + } + : undefined, + }; + } + + isFormUnchanged(currentFormValues: unknown, initialFormValues: string | null): boolean { + return !initialFormValues || !currentFormValues || JSON.stringify(currentFormValues) === initialFormValues; + } + + resetForm(form: FormGroup): void { + form.markAsUntouched(); + } +} 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 new file mode 100644 index 000000000..3522031b5 --- /dev/null +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.actions.ts @@ -0,0 +1,17 @@ +import { CollectionSubmissionPayload } from '@osf/features/collections/models'; + +export class GetCollectionLicenses { + static readonly type = '[Add To Collection] Get Collection Licenses'; + + constructor(public providerId: string) {} +} + +export class CreateCollectionSubmission { + static readonly type = '[Add To Collection] Create Collection Submission'; + + constructor(public metadata: CollectionSubmissionPayload) {} +} + +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 new file mode 100644 index 000000000..5db47bc61 --- /dev/null +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts @@ -0,0 +1,6 @@ +import { License } from '@shared/models'; +import { AsyncStateModel } from '@shared/models/store'; + +export interface AddToCollectionStateModel { + collectionLicenses: AsyncStateModel; +} 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 new file mode 100644 index 000000000..6cdf7f3d4 --- /dev/null +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.selectors.ts @@ -0,0 +1,16 @@ +import { Selector } from '@ngxs/store'; + +import { AddToCollectionStateModel } from './add-to-collection.model'; +import { AddToCollectionState } from './add-to-collection.state'; + +export class AddToCollectionSelectors { + @Selector([AddToCollectionState]) + static getCollectionLicenses(state: AddToCollectionStateModel) { + return state.collectionLicenses.data; + } + + @Selector([AddToCollectionState]) + static getCollectionLicensesLoading(state: AddToCollectionStateModel) { + return state.collectionLicenses.isLoading; + } +} 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 new file mode 100644 index 000000000..2cfed5f2c --- /dev/null +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts @@ -0,0 +1,67 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { tap } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@core/handlers'; +import { AddToCollectionService } from '@osf/features/collections/services/add-to-collection.service'; + +import { + ClearAddToCollectionState, + CreateCollectionSubmission, + GetCollectionLicenses, +} from './add-to-collection.actions'; +import { AddToCollectionStateModel } from './add-to-collection.model'; + +const ADD_TO_COLLECTION_DEFAULTS = { + collectionLicenses: { + data: [], + isLoading: false, + error: null, + }, +}; + +@State({ + name: 'addToCollection', + defaults: ADD_TO_COLLECTION_DEFAULTS, +}) +@Injectable() +export class AddToCollectionState { + addToCollectionService = inject(AddToCollectionService); + + @Action(GetCollectionLicenses) + getCollectionLicenses(ctx: StateContext, action: GetCollectionLicenses) { + const state = ctx.getState(); + ctx.patchState({ + collectionLicenses: { + ...state.collectionLicenses, + isLoading: true, + }, + }); + + return this.addToCollectionService.fetchCollectionLicenses(action.providerId).pipe( + tap((licenses) => { + ctx.patchState({ + collectionLicenses: { + data: licenses, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'collectionLicenses', error)) + ); + } + + @Action(CreateCollectionSubmission) + createCollectionSubmission(ctx: StateContext, action: CreateCollectionSubmission) { + return this.addToCollectionService.createCollectionSubmission(action.metadata); + } + + @Action(ClearAddToCollectionState) + clearAddToCollection(ctx: StateContext) { + ctx.patchState(ADD_TO_COLLECTION_DEFAULTS); + } +} diff --git a/src/app/features/collections/store/add-to-collection/index.ts b/src/app/features/collections/store/add-to-collection/index.ts new file mode 100644 index 000000000..c194be144 --- /dev/null +++ b/src/app/features/collections/store/add-to-collection/index.ts @@ -0,0 +1,4 @@ +export * from './add-to-collection.actions'; +export * from './add-to-collection.model'; +export * from './add-to-collection.selectors'; +export * from './add-to-collection.state'; diff --git a/src/app/features/collections/store/collections.actions.ts b/src/app/features/collections/store/collections/collections.actions.ts similarity index 88% rename from src/app/features/collections/store/collections.actions.ts rename to src/app/features/collections/store/collections/collections.actions.ts index f49040405..595a39783 100644 --- a/src/app/features/collections/store/collections.actions.ts +++ b/src/app/features/collections/store/collections/collections.actions.ts @@ -87,6 +87,12 @@ export class SetIssueFilters { constructor(public issueFilters: string[]) {} } +export class SetReviewsStateFilters { + static readonly type = '[Collections] Set Reviews State Filters'; + + constructor(public reviewsStateFilters: string[]) {} +} + export class SetSchoolTypeFilters { static readonly type = '[Collections] Set School Type Filters'; @@ -135,8 +141,8 @@ export class SetAllFilters { constructor(public filters: Partial) {} } -export class GetCollectionSubmissions { - static readonly type = '[Collections] Get Collection Submissions'; +export class SearchCollectionSubmissions { + static readonly type = '[Collections] Search Collection Submissions'; constructor( public providerId: string, @@ -146,3 +152,12 @@ export class GetCollectionSubmissions { public sort: string ) {} } + +export class GetUserCollectionSubmissions { + static readonly type = '[Collections] Get User Collection Submissions'; + + constructor( + public providerId: string, + public projectsIds: string[] + ) {} +} diff --git a/src/app/features/collections/store/collections.model.ts b/src/app/features/collections/store/collections/collections.model.ts similarity index 82% rename from src/app/features/collections/store/collections.model.ts rename to src/app/features/collections/store/collections/collections.model.ts index 52ea3c4bd..6e8d97fe8 100644 --- a/src/app/features/collections/store/collections.model.ts +++ b/src/app/features/collections/store/collections/collections.model.ts @@ -4,7 +4,7 @@ import { CollectionsFilters, CollectionSubmission, } from '@osf/features/collections/models'; -import { AsyncStateModel } from '@osf/shared/models/store'; +import { AsyncStateModel } from '@shared/models/store'; export interface CollectionsStateModel { bookmarksId: AsyncStateModel; @@ -13,6 +13,7 @@ export interface CollectionsStateModel { collectionProvider: AsyncStateModel; collectionDetails: AsyncStateModel; collectionSubmissions: AsyncStateModel; + userCollectionSubmissions: AsyncStateModel; totalSubmissions: number; sortBy: string; searchText: string; diff --git a/src/app/features/collections/store/collections.selectors.ts b/src/app/features/collections/store/collections/collections.selectors.ts similarity index 88% rename from src/app/features/collections/store/collections.selectors.ts rename to src/app/features/collections/store/collections/collections.selectors.ts index 76c1b68bb..36627c908 100644 --- a/src/app/features/collections/store/collections.selectors.ts +++ b/src/app/features/collections/store/collections/collections.selectors.ts @@ -61,6 +61,16 @@ export class CollectionsSelectors { return state.collectionSubmissions.isLoading; } + @Selector([CollectionsState]) + static getUserCollectionSubmissions(state: CollectionsStateModel) { + return state.userCollectionSubmissions.data; + } + + @Selector([CollectionsState]) + static getUserCollectionSubmissionsLoading(state: CollectionsStateModel) { + return state.userCollectionSubmissions.isLoading; + } + @Selector([CollectionsState]) static getSortBy(state: CollectionsStateModel) { return state.sortBy; diff --git a/src/app/features/collections/store/collections.state.ts b/src/app/features/collections/store/collections/collections.state.ts similarity index 89% rename from src/app/features/collections/store/collections.state.ts rename to src/app/features/collections/store/collections/collections.state.ts index 8705445fd..f9512b956 100644 --- a/src/app/features/collections/store/collections.state.ts +++ b/src/app/features/collections/store/collections/collections.state.ts @@ -2,12 +2,11 @@ import { Action, State, StateContext } from '@ngxs/store'; import { catchError, EMPTY, tap, throwError } from 'rxjs'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { CollectionsService } from '@osf/features/collections/services'; import { ResourceType } from '@shared/enums'; -import { CollectionsService } from '../services'; - import { AddResourceToBookmarks, ClearCollections, @@ -15,8 +14,9 @@ import { GetBookmarksCollectionId, GetCollectionDetails, GetCollectionProvider, - GetCollectionSubmissions, + GetUserCollectionSubmissions, RemoveResourceFromBookmarks, + SearchCollectionSubmissions, SetAllFilters, SetCollectedTypeFilters, SetDataTypeFilters, @@ -75,6 +75,12 @@ const COLLECTIONS_DEFAULTS: CollectionsStateModel = { isSubmitting: false, error: null, }, + userCollectionSubmissions: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, totalSubmissions: 0, sortBy: '', searchText: '', @@ -87,7 +93,7 @@ const COLLECTIONS_DEFAULTS: CollectionsStateModel = { }) @Injectable() export class CollectionsState { - constructor(private collectionsService: CollectionsService) {} + collectionsService = inject(CollectionsService); @Action(GetCollectionProvider) getCollectionProvider(ctx: StateContext, action: GetCollectionProvider) { @@ -417,8 +423,8 @@ export class CollectionsState { }); } - @Action(GetCollectionSubmissions) - getCollectionSubmission(ctx: StateContext, action: GetCollectionSubmissions) { + @Action(SearchCollectionSubmissions) + searchCollectionSubmissions(ctx: StateContext, action: SearchCollectionSubmissions) { const state = ctx.getState(); ctx.patchState({ collectionSubmissions: { @@ -428,7 +434,7 @@ export class CollectionsState { }); return this.collectionsService - .getCollectionSubmissions(action.providerId, action.searchText, action.activeFilters, action.page, action.sort) + .searchCollectionSubmissions(action.providerId, action.searchText, action.activeFilters, action.page, action.sort) .pipe( tap((res) => { ctx.patchState({ @@ -443,6 +449,30 @@ export class CollectionsState { ); } + @Action(GetUserCollectionSubmissions) + getUserCollectionSubmissions(ctx: StateContext, action: GetUserCollectionSubmissions) { + const state = ctx.getState(); + ctx.patchState({ + userCollectionSubmissions: { + ...state.userCollectionSubmissions, + isLoading: true, + }, + }); + + return this.collectionsService.fetchAllUserCollectionSubmissions(action.providerId, action.projectsIds).pipe( + tap((res) => { + ctx.patchState({ + userCollectionSubmissions: { + data: res, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'userCollectionSubmissions', error)) + ); + } + private handleError(ctx: StateContext, section: keyof CollectionsStateModel, error: Error) { const state = ctx.getState(); if (section !== 'sortBy' && section !== 'searchText' && section !== 'page' && section !== 'totalSubmissions') { diff --git a/src/app/features/collections/store/index.ts b/src/app/features/collections/store/collections/index.ts similarity index 100% rename from src/app/features/collections/store/index.ts rename to src/app/features/collections/store/collections/index.ts diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 6830e85ac..77afbdbee 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -32,7 +32,7 @@ import { ResourceType, SortOrder } from '@osf/shared/enums'; import { QueryParams, TableParameters, TabOption } from '@osf/shared/models'; import { IS_XSMALL } from '@osf/shared/utils'; -import { CollectionsSelectors, GetBookmarksCollectionId } from '../collections/store'; +import { CollectionsSelectors, GetBookmarksCollectionId } from '../collections/store/collections'; import { MyProjectsItem, MyProjectsSearchFilters } from './models'; import { diff --git a/src/app/features/preprints/components/filters/preprints-resources/preprints-resources.component.ts b/src/app/features/preprints/components/filters/preprints-resources/preprints-resources.component.ts index b96080e2e..882dd38b8 100644 --- a/src/app/features/preprints/components/filters/preprints-resources/preprints-resources.component.ts +++ b/src/app/features/preprints/components/filters/preprints-resources/preprints-resources.component.ts @@ -9,7 +9,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { Primitive } from '@core/helpers'; -import { SetSortBy } from '@osf/features/collections/store'; +import { SetSortBy } from '@osf/features/collections/store/collections'; import { GetResourcesByLink } from '@osf/features/my-profile/store'; import { PreprintsFilterChipsComponent, PreprintsResourcesFiltersComponent } from '@osf/features/preprints/components'; import { PreprintsDiscoverSelectors } from '@osf/features/preprints/store/preprints-discover'; diff --git a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts index 0b682b9d9..aef5f3fb8 100644 --- a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts +++ b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts @@ -20,7 +20,7 @@ import { AddResourceToBookmarks, CollectionsSelectors, RemoveResourceFromBookmarks, -} from '@osf/features/collections/store'; +} from '@osf/features/collections/store/collections'; import { GetMyBookmarks, MyProjectsSelectors } from '@osf/features/my-projects/store'; import { DuplicateDialogComponent, TogglePublicityDialogComponent } from '@osf/features/project/overview/components'; import { IconComponent } from '@osf/shared/components'; diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 356a66657..6a5691d67 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -9,7 +9,7 @@ import { ChangeDetectionStrategy, Component, computed, DestroyRef, HostBinding, import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; -import { ClearCollections, GetBookmarksCollectionId } from '@osf/features/collections/store'; +import { ClearCollections, GetBookmarksCollectionId } from '@osf/features/collections/store/collections'; import { LoadingSpinnerComponent, ResourceMetadataComponent, SubHeaderComponent } from '@shared/components'; import { ResourceType } from '@shared/enums'; import { MapProjectOverview } from '@shared/mappers/resource-overview.mappers'; 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 3213c51fd..487e5e0d1 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 @@ -5,7 +5,7 @@ import { DialogService } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, computed, HostBinding, inject, signal } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { GetBookmarksCollectionId } from '@osf/features/collections/store'; +import { GetBookmarksCollectionId } from '@osf/features/collections/store/collections'; import { OverviewToolbarComponent } from '@osf/features/project/overview/components'; import { DataResourcesComponent, diff --git a/src/app/shared/components/license/license.component.html b/src/app/shared/components/license/license.component.html index 585f9ca74..8fd8dcadf 100644 --- a/src/app/shared/components/license/license.component.html +++ b/src/app/shared/components/license/license.component.html @@ -48,6 +48,7 @@ class="w-6 md:w-9rem" styleClass="w-full" [label]="'common.buttons.save' | translate" + [loading]="isSubmitting()" [disabled]="licenseForm.invalid || saveButtonDisabled()" (click)="saveLicense()" /> diff --git a/src/app/shared/components/license/license.component.ts b/src/app/shared/components/license/license.component.ts index 73ca61aef..6a0e1e26e 100644 --- a/src/app/shared/components/license/license.component.ts +++ b/src/app/shared/components/license/license.component.ts @@ -41,6 +41,7 @@ export class LicenseComponent { selectedLicenseId = input(null); selectedLicenseOptions = input(null); licenses = input.required(); + isSubmitting = input(false); selectedLicense = model(null); createLicense = output<{ id: string; licenseOptions: LicenseOptions }>(); selectLicense = output(); diff --git a/src/app/shared/components/tags-input/tags-input.component.html b/src/app/shared/components/tags-input/tags-input.component.html index 85cbedbe6..ede43867d 100644 --- a/src/app/shared/components/tags-input/tags-input.component.html +++ b/src/app/shared/components/tags-input/tags-input.component.html @@ -1,10 +1,10 @@
diff --git a/src/app/shared/components/tags-input/tags-input.component.ts b/src/app/shared/components/tags-input/tags-input.component.ts index e81fd8eea..67c4957df 100644 --- a/src/app/shared/components/tags-input/tags-input.component.ts +++ b/src/app/shared/components/tags-input/tags-input.component.ts @@ -14,6 +14,7 @@ import { FormsModule } from '@angular/forms'; }) export class TagsInputComponent { tags = input([]); + required = input(false); tagsChanged = output(); onTagsChange(tags: string[]): void { diff --git a/src/app/shared/mappers/projects/index.ts b/src/app/shared/mappers/projects/index.ts new file mode 100644 index 000000000..b9e1cae3c --- /dev/null +++ b/src/app/shared/mappers/projects/index.ts @@ -0,0 +1 @@ +export * from './projects.mapper'; diff --git a/src/app/shared/mappers/projects/projects.mapper.ts b/src/app/shared/mappers/projects/projects.mapper.ts new file mode 100644 index 000000000..ea2aeac1f --- /dev/null +++ b/src/app/shared/mappers/projects/projects.mapper.ts @@ -0,0 +1,41 @@ +import { Project, ProjectJsonApi, ProjectsResponseJsonApi } from '@shared/models/projects'; + +export class ProjectsMapper { + static fromGetAllProjectsResponse(response: ProjectsResponseJsonApi): Project[] { + return response.data.map((project) => ({ + id: project.id, + type: project.type, + title: project.attributes.title, + dateModified: project.attributes.date_modified, + isPublic: project.attributes.public, + licenseId: project.relationships.license?.data?.id || null, + licenseOptions: project.attributes.node_license + ? { + year: project.attributes.node_license.year, + copyrightHolders: project.attributes.node_license.copyright_holders.join(','), + } + : null, + description: project.attributes.description, + tags: project.attributes.tags || [], + })); + } + + static fromPatchProjectResponse(project: ProjectJsonApi): Project { + return { + id: project.id, + type: project.type, + title: project.attributes.title, + dateModified: project.attributes.date_modified, + isPublic: project.attributes.public, + licenseId: project.relationships.license?.data?.id || null, + licenseOptions: project.attributes.node_license + ? { + year: project.attributes.node_license.year, + copyrightHolders: project.attributes.node_license.copyright_holders.join(','), + } + : null, + description: project.attributes.description, + tags: project.attributes.tags || [], + }; + } +} diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index 809165bd6..3d21e9c57 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -21,6 +21,7 @@ export * from './metadata-field.model'; export * from './nodes/create-project-form.model'; export * from './nodes/nodes-json-api.model'; export * from './paginated-data.model'; +export * from './project-metadata-update-payload.model'; export * from './query-params.model'; export * from './resource-card'; export * from './resource-overview.model'; diff --git a/src/app/shared/models/project-metadata-update-payload.model.ts b/src/app/shared/models/project-metadata-update-payload.model.ts new file mode 100644 index 000000000..85a37a3f6 --- /dev/null +++ b/src/app/shared/models/project-metadata-update-payload.model.ts @@ -0,0 +1,10 @@ +import { LicenseOptions } from '@shared/models/license.model'; + +export interface ProjectMetadataUpdatePayload { + id: string; + title: string; + description: string; + tags: string[]; + licenseId: string; + licenseOptions?: LicenseOptions | null; +} diff --git a/src/app/shared/models/projects/index.ts b/src/app/shared/models/projects/index.ts new file mode 100644 index 000000000..3697c4b36 --- /dev/null +++ b/src/app/shared/models/projects/index.ts @@ -0,0 +1,2 @@ +export * from './projects.models'; +export * from './projects-json-api.models'; diff --git a/src/app/shared/models/projects/projects-json-api.models.ts b/src/app/shared/models/projects/projects-json-api.models.ts new file mode 100644 index 000000000..1db67b7b8 --- /dev/null +++ b/src/app/shared/models/projects/projects-json-api.models.ts @@ -0,0 +1,29 @@ +import { JsonApiResponse } from '@core/models'; +import { LicenseRecordJsonApi } from '@shared/models'; + +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[]; +} + +export interface ProjectRelationshipsJsonApi { + license: { + data: { + id: string; + type: 'licenses'; + }; + }; +} diff --git a/src/app/shared/models/projects/projects.models.ts b/src/app/shared/models/projects/projects.models.ts new file mode 100644 index 000000000..4e6ecdd88 --- /dev/null +++ b/src/app/shared/models/projects/projects.models.ts @@ -0,0 +1,14 @@ +import { StringOrNull } from '@core/helpers'; +import { LicenseOptions } from '@shared/models'; + +export interface Project { + id: string; + type: string; + title: string; + dateModified: string; + isPublic: boolean; + licenseId: StringOrNull; + licenseOptions: LicenseOptions | null; + description: string; + tags: string[]; +} diff --git a/src/app/shared/services/contributors.service.ts b/src/app/shared/services/contributors.service.ts index 0224edbf4..7c7bde8fb 100644 --- a/src/app/shared/services/contributors.service.ts +++ b/src/app/shared/services/contributors.service.ts @@ -70,7 +70,7 @@ export class ContributorsService { resourceId: string, data: ContributorModel ): Observable { - const baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/${data.userId}`; + const baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/${data.userId}/`; const contributorData = { data: ContributorsMapper.toContributorAddRequest(data) }; @@ -80,7 +80,7 @@ export class ContributorsService { } deleteContributor(resourceType: ResourceType, resourceId: string, userId: string): Observable { - const baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/${userId}`; + const baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/${userId}/`; return this.jsonApiService.delete(baseUrl); } diff --git a/src/app/shared/services/projects.service.ts b/src/app/shared/services/projects.service.ts new file mode 100644 index 000000000..1f616a04a --- /dev/null +++ b/src/app/shared/services/projects.service.ts @@ -0,0 +1,56 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@core/services'; +import { CollectionSubmissionMetadataPayloadJsonApi } from '@osf/features/collections/models'; +import { ProjectsMapper } from '@shared/mappers/projects'; +import { ProjectMetadataUpdatePayload } from '@shared/models'; +import { Project, ProjectJsonApi, ProjectsResponseJsonApi } from '@shared/models/projects'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class ProjectsService { + private jsonApiService = inject(JsonApiService); + + fetchProjects(userId: string, params?: Record): Observable { + return this.jsonApiService + .get(`${environment.apiUrl}/users/${userId}/nodes/`, params) + .pipe(map((response) => ProjectsMapper.fromGetAllProjectsResponse(response))); + } + + updateProjectMetadata(metadata: ProjectMetadataUpdatePayload): Observable { + const payload: CollectionSubmissionMetadataPayloadJsonApi = { + data: { + type: 'nodes', + id: metadata.id, + relationships: { + license: { + data: { + id: metadata.licenseId, + type: 'licenses', + }, + }, + }, + attributes: { + title: metadata.title, + description: metadata.description, + tags: metadata.tags, + ...(metadata.licenseOptions && { + node_license: { + copyright_holders: [metadata.licenseOptions.copyrightHolders], + year: metadata.licenseOptions.year, + }, + }), + }, + }, + }; + + return this.jsonApiService + .patch(`${environment.apiUrl}/nodes/${metadata.id}/`, payload) + .pipe(map((response) => ProjectsMapper.fromPatchProjectResponse(response))); + } +} diff --git a/src/app/shared/stores/contributors/contributors.selectors.ts b/src/app/shared/stores/contributors/contributors.selectors.ts index 9f087b89b..cd5cfadd4 100644 --- a/src/app/shared/stores/contributors/contributors.selectors.ts +++ b/src/app/shared/stores/contributors/contributors.selectors.ts @@ -33,6 +33,11 @@ export class ContributorsSelectors { return state?.contributorsList?.isLoading || false; } + @Selector([ContributorsState]) + static isContributorsSubmitting(state: ContributorsStateModel) { + return state?.contributorsList?.isSubmitting || false; + } + @Selector([ContributorsState]) static isContributorsError(state: ContributorsStateModel) { return !!state?.contributorsList?.error?.length; diff --git a/src/app/shared/stores/contributors/contributors.state.ts b/src/app/shared/stores/contributors/contributors.state.ts index f57b60e03..3dbf79b64 100644 --- a/src/app/shared/stores/contributors/contributors.state.ts +++ b/src/app/shared/stores/contributors/contributors.state.ts @@ -73,7 +73,7 @@ export class ContributorsState { const state = ctx.getState(); ctx.patchState({ - contributorsList: { ...state.contributorsList, isLoading: true, error: null }, + contributorsList: { ...state.contributorsList, isSubmitting: true, error: null }, }); if (!action.resourceId || !action.resourceType) { @@ -88,7 +88,7 @@ export class ContributorsState { contributorsList: { ...currentState.contributorsList, data: [...currentState.contributorsList.data, contributor], - isLoading: false, + isSubmitting: false, }, }); }), @@ -101,7 +101,7 @@ export class ContributorsState { const state = ctx.getState(); ctx.patchState({ - contributorsList: { ...state.contributorsList, isLoading: true, error: null }, + contributorsList: { ...state.contributorsList, isSubmitting: true, error: null }, }); if (!action.resourceId || !action.resourceType) { @@ -118,7 +118,7 @@ export class ContributorsState { data: currentState.contributorsList.data.map((contributor) => contributor.id === updatedContributor.id ? updatedContributor : contributor ), - isLoading: false, + isSubmitting: false, }, }); }), @@ -131,7 +131,7 @@ export class ContributorsState { const state = ctx.getState(); ctx.patchState({ - contributorsList: { ...state.contributorsList, isLoading: true, error: null }, + contributorsList: { ...state.contributorsList, isSubmitting: true, error: null }, }); if (!action.resourceId || !action.resourceType) { @@ -146,7 +146,7 @@ export class ContributorsState { contributorsList: { ...state.contributorsList, data: state.contributorsList.data.filter((contributor) => contributor.userId !== action.contributorId), - isLoading: false, + isSubmitting: false, }, }); }), diff --git a/src/app/shared/stores/index.ts b/src/app/shared/stores/index.ts index a7650198c..72f9aa373 100644 --- a/src/app/shared/stores/index.ts +++ b/src/app/shared/stores/index.ts @@ -3,5 +3,6 @@ export * from './contributors'; export * from './institutions'; export * from './institutions-search'; export * from './licenses'; +export * from './projects'; export * from './subjects'; export * from './view-only-links'; diff --git a/src/app/shared/stores/projects/index.ts b/src/app/shared/stores/projects/index.ts new file mode 100644 index 000000000..636713e39 --- /dev/null +++ b/src/app/shared/stores/projects/index.ts @@ -0,0 +1,4 @@ +export * from './projects.actions'; +export * from './projects.model'; +// export * from './projects.selectors'; +export * from './projects.state'; diff --git a/src/app/shared/stores/projects/projects.actions.ts b/src/app/shared/stores/projects/projects.actions.ts new file mode 100644 index 000000000..0abac435e --- /dev/null +++ b/src/app/shared/stores/projects/projects.actions.ts @@ -0,0 +1,23 @@ +import { ProjectMetadataUpdatePayload } from '@shared/models'; +import { Project } from '@shared/models/projects'; + +export class GetProjects { + static readonly type = '[Projects] Get Projects'; + + constructor( + public userId: string, + public params?: Record + ) {} +} + +export class SetSelectedProject { + static readonly type = '[Projects] Set Selected Project'; + + constructor(public project: Project) {} +} + +export class UpdateProjectMetadata { + static readonly type = '[Projects] Update Project Metadata'; + + constructor(public metadata: ProjectMetadataUpdatePayload) {} +} diff --git a/src/app/shared/stores/projects/projects.model.ts b/src/app/shared/stores/projects/projects.model.ts new file mode 100644 index 000000000..0217d4149 --- /dev/null +++ b/src/app/shared/stores/projects/projects.model.ts @@ -0,0 +1,7 @@ +import { AsyncStateModel } from '@shared/models'; +import { Project } from '@shared/models/projects'; + +export interface ProjectsStateModel { + projects: AsyncStateModel; + selectedProject: AsyncStateModel; +} diff --git a/src/app/shared/stores/projects/projects.selectors.ts b/src/app/shared/stores/projects/projects.selectors.ts new file mode 100644 index 000000000..cb3f20dce --- /dev/null +++ b/src/app/shared/stores/projects/projects.selectors.ts @@ -0,0 +1,26 @@ +import { Selector } from '@ngxs/store'; + +import { ProjectsStateModel } from './projects.model'; +import { ProjectsState } from './projects.state'; + +export class ProjectsSelectors { + @Selector([ProjectsState]) + static getProjects(state: ProjectsStateModel) { + return state.projects.data; + } + + @Selector([ProjectsState]) + static getProjectsLoading(state: ProjectsStateModel): boolean { + return state.projects.isLoading; + } + + @Selector([ProjectsState]) + static getSelectedProject(state: ProjectsStateModel) { + return state.selectedProject.data; + } + + @Selector([ProjectsState]) + static getSelectedProjectUpdateSubmitting(state: ProjectsStateModel) { + return state.selectedProject.isSubmitting; + } +} diff --git a/src/app/shared/stores/projects/projects.state.ts b/src/app/shared/stores/projects/projects.state.ts new file mode 100644 index 000000000..91e20e834 --- /dev/null +++ b/src/app/shared/stores/projects/projects.state.ts @@ -0,0 +1,103 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { catchError, tap, throwError } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@core/handlers'; +import { ProjectOverviewService } from '@osf/features/project/overview/services'; +import { ProjectsService } from '@shared/services/projects.service'; +import { GetProjects, ProjectsStateModel, SetSelectedProject, UpdateProjectMetadata } from '@shared/stores'; + +@State({ + name: 'projects', + defaults: { + projects: { + data: [], + isLoading: false, + error: null, + }, + selectedProject: { + data: null, + isLoading: false, + isSubmitting: false, + error: null, + }, + }, +}) +@Injectable() +export class ProjectsState { + private readonly projectsService = inject(ProjectsService); + private readonly projectOverviewService = inject(ProjectOverviewService); + + @Action(GetProjects) + getProjects(ctx: StateContext, action: GetProjects) { + const state = ctx.getState(); + + ctx.patchState({ + projects: { + ...state.projects, + isLoading: true, + }, + }); + + return this.projectsService.fetchProjects(action.userId, action.params).pipe( + tap({ + next: (projects) => { + ctx.patchState({ + projects: { + data: projects, + error: null, + isLoading: false, + }, + }); + }, + }), + catchError((error) => { + ctx.patchState({ + projects: { + ...ctx.getState().projects, + isLoading: false, + error, + }, + }); + return throwError(() => error); + }) + ); + } + + @Action(SetSelectedProject) + setSelectedProject(ctx: StateContext, action: SetSelectedProject) { + const state = ctx.getState(); + ctx.patchState({ + selectedProject: { + ...state.selectedProject, + data: action.project, + }, + }); + } + + @Action(UpdateProjectMetadata) + updateProjectMetadata(ctx: StateContext, action: UpdateProjectMetadata) { + const state = ctx.getState(); + ctx.patchState({ + selectedProject: { + ...state.selectedProject, + isSubmitting: true, + }, + }); + + return this.projectsService.updateProjectMetadata(action.metadata).pipe( + tap((project) => { + ctx.patchState({ + selectedProject: { + ...state.selectedProject, + data: project, + isSubmitting: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'selectedProject', error)) + ); + } +} diff --git a/src/app/shared/utils/convert-to-snake-case.helper.ts b/src/app/shared/utils/convert-to-snake-case.helper.ts new file mode 100644 index 000000000..d63afe76d --- /dev/null +++ b/src/app/shared/utils/convert-to-snake-case.helper.ts @@ -0,0 +1,10 @@ +export function convertToSnakeCase(obj: Record): Record { + return Object.entries(obj).reduce( + (acc, [key, value]) => { + const snakeCaseKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + acc[snakeCaseKey] = value; + return acc; + }, + {} as Record + ); +} diff --git a/src/app/shared/utils/custom-form-validators.helper.ts b/src/app/shared/utils/custom-form-validators.helper.ts index 935966456..ebe77695d 100644 --- a/src/app/shared/utils/custom-form-validators.helper.ts +++ b/src/app/shared/utils/custom-form-validators.helper.ts @@ -40,4 +40,14 @@ export class CustomValidators { return isValid ? null : { link: true }; }; } + + static requiredArrayValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (!Array.isArray(value) || !value.length) { + return { required: true }; + } + return null; + }; + } } diff --git a/src/app/shared/utils/index.ts b/src/app/shared/utils/index.ts index 77bce136c..64852254f 100644 --- a/src/app/shared/utils/index.ts +++ b/src/app/shared/utils/index.ts @@ -3,6 +3,7 @@ export * from './add-filters-params.helper'; export * from './addon-type.helper'; export * from './breakpoints.tokens'; export { BrowserTabHelper } from './browser-tab.helper'; +export * from './convert-to-snake-case.helper'; export * from './custom-form-validators.helper'; export * from './default-confirmation-config.helper'; export * from './find-changed-fields'; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 029cfefcc..cc2277447 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -25,6 +25,10 @@ "skip": "Skip", "done": "Done", "select": "Select", + "addToCollection": "Add to Collection", + "discardChanges": "Discard Changes", + "saveAndContinue": "Save and Continue", + "clickToEdit": "Click to edit", "deselect": "Deselect", "yes": "Yes", "no": "No", @@ -396,8 +400,7 @@ "enabledForWiki": "This feature is enabled for wikis of private projects.", "delete": { "title": "Delete Link {{name}}?", - "message": "Are you sure you want to delete this view only link?", - "success": "View only link successfully deleted." + "message": "Are you sure you want to delete this view only link?" }, "descriptions": { "comments": { @@ -957,9 +960,6 @@ } }, "collections": { - "buttons": { - "addToCollection": "Add to Collection" - }, "helpDialog": { "header": "Search help", "message": "OSF Search provides a powerful discovery tool to help you find data, papers, analysis plans, and more content across the research lifecycle. OSF Collections have some specialized filters, which you can learn more about on our ", @@ -1038,6 +1038,34 @@ "noResultsFound": "No results found", "noResultsFoundMessage": "Try broadening your search terms" }, + "addToCollection": { + "confirmationDialogHeader": "Add to Collection", + "selectProject": "Select a project", + "project": "Project", + "projectMetadata": "Project Metadata", + "projectMetadataMessage": "Updates made in this section will update the project.", + "projectContributors": "Project Contributors", + "collectionMetadata": "Collection Metadata", + "tooltipMessage": "Complete previous step to edit this section", + "noDescription": "No description", + "noLicense": "No license", + "noTags": "No tags", + "projectMetadataUpdateSuccess": "Project Metadata successfully updated.", + "confirmationDialogMessage": "Once submitted to the collection, the project will be made public. It can later be made private again. A moderator will review your submission before it is included in the collection.", + "confirmationDialogToastMessage": "Project has been successfully submitted to the collection", + "form": { + "title": "Title", + "description": "Description", + "chooseLicense": "Choose License", + "license": "License", + "licensePlaceholder": "Select license", + "tags": "Tags", + "tagsPlaceholder": "Add tags here", + "loadingPlaceholder": "Loading projects...", + "noProjectsFound": "No projects found", + "fieldRequired": "This field can't be empty" + } + }, "common": { "dateCreated": "Date Created:", "dateModified": "Date Modified:", diff --git a/src/assets/styles/components/collections.scss b/src/assets/styles/components/collections.scss new file mode 100644 index 000000000..d988edb82 --- /dev/null +++ b/src/assets/styles/components/collections.scss @@ -0,0 +1,32 @@ +@use "assets/styles/variables" as var; +@use "assets/styles/mixins" as mix; + +.collections-content { + background: var(--collection-bg-color); + border-top: none; + + .collections-sub-header { + margin: mix.rem(48px) mix.rem(28px); + + .collections-icon { + font-size: mix.rem(42px); + } + } + + .search-input-container { + margin: 0 mix.rem(28px) mix.rem(48px) mix.rem(28px); + position: relative; + + img { + position: absolute; + right: mix.rem(4px); + top: mix.rem(4px); + z-index: 1; + } + } + + .content-container { + background: var.$white; + padding: mix.rem(28px); + } +} diff --git a/src/assets/styles/overrides/accordion.scss b/src/assets/styles/overrides/accordion.scss index 7b03f6ad5..da0ce6333 100644 --- a/src/assets/styles/overrides/accordion.scss +++ b/src/assets/styles/overrides/accordion.scss @@ -75,3 +75,14 @@ } } } + +.collections-accordion { + .p-accordionpanel { + --p-accordion-panel-border-width: 1px; + border-radius: 0.5rem; + + .p-accordionheader { + --p-accordion-header-padding: 0; + } + } +} diff --git a/src/assets/styles/overrides/chip.scss b/src/assets/styles/overrides/chip.scss index 38b406ab7..661d1a9f7 100644 --- a/src/assets/styles/overrides/chip.scss +++ b/src/assets/styles/overrides/chip.scss @@ -31,3 +31,9 @@ margin-top: 2px; } } + +p-chips.ng-invalid.ng-touched { + .p-inputchips-input { + border-color: var(--p-inputchips-invalid-border-color); + } +} diff --git a/src/assets/styles/overrides/paginator.scss b/src/assets/styles/overrides/paginator.scss index c6e868f0c..230271eda 100644 --- a/src/assets/styles/overrides/paginator.scss +++ b/src/assets/styles/overrides/paginator.scss @@ -20,6 +20,7 @@ .collections-paginator { .p-paginator { + --p-paginator-padding: 0; justify-content: end; } } diff --git a/src/assets/styles/overrides/stepper.scss b/src/assets/styles/overrides/stepper.scss index 38c05ffb6..ab10669ac 100644 --- a/src/assets/styles/overrides/stepper.scss +++ b/src/assets/styles/overrides/stepper.scss @@ -7,3 +7,26 @@ display: none; } } + +.collection-stepper { + --p-stepper-step-padding: 0; + --p-stepper-step-title-color: var(--dark-blue-1); + --p-stepper-step-title-active-color: var(--dark-blue-1); + + .p-step-header { + text-align: left; + } + + .p-step-number { + display: none; + } + + .p-stepitem { + border: 1px solid var(--grey-2); + border-radius: 0.5rem; + + .p-steppanel-content { + margin-inline-start: 0; + } + } +} diff --git a/src/assets/styles/styles.scss b/src/assets/styles/styles.scss index 3918b86ab..70cdee3f9 100644 --- a/src/assets/styles/styles.scss +++ b/src/assets/styles/styles.scss @@ -45,3 +45,4 @@ @use "./components/preprints"; @use "./overrides/cedar-metadata"; @use "./overrides/date-picker"; +@use "./components/collections";