diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 92dbd5630..1ba2c58ba 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -12,15 +12,19 @@ export const authInterceptor: HttpInterceptorFn = ( const localStorageToken = localStorage.getItem('authToken'); const token = localStorageToken || authToken; if (token) { - const authReq = req.clone({ - setHeaders: { - Authorization: `Bearer ${token}`, - Accept: req.responseType === 'text' ? '*/*' : 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json', - }, - }); + if (!req.url.includes('/api.crossref.org/funders')) { + const authReq = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}`, + Accept: req.responseType === 'text' ? '*/*' : 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + }, + }); - return next(authReq); + return next(authReq); + } else { + return next(req); + } } return next(req); diff --git a/src/app/features/project/metadata/components/project-metadata-funding/project-metadata-funding.component.html b/src/app/features/project/metadata/components/project-metadata-funding/project-metadata-funding.component.html deleted file mode 100644 index cc66c8927..000000000 --- a/src/app/features/project/metadata/components/project-metadata-funding/project-metadata-funding.component.html +++ /dev/null @@ -1,29 +0,0 @@ - -
-

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

- - -
- - @if (currentProject()?.supplements?.length) { -
- @for (supplement of currentProject()!.supplements!; track supplement.id) { -
-

{{ supplement.title }}

-

{{ supplement.dateCreated | date: 'MMM d, y' }}

- @if (supplement.url) { - {{ supplement.url }} - } -
- } -
- } @else { -
-

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

-
- } -
diff --git a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html deleted file mode 100644 index 991d700d1..000000000 --- a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts deleted file mode 100644 index 10a3a81e6..000000000 --- a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { Card } from 'primeng/card'; - -import { ChangeDetectionStrategy, Component, input, OnInit } from '@angular/core'; - -import { ResourceType } from '@osf/shared/enums'; -import { SubjectModel } from '@osf/shared/models'; -import { - FetchChildrenSubjects, - FetchSelectedSubjects, - FetchSubjects, - SubjectsSelectors, - UpdateResourceSubjects, -} from '@osf/shared/stores'; -import { SubjectsComponent } from '@shared/components'; - -@Component({ - selector: 'osf-project-metadata-subjects', - imports: [SubjectsComponent, Card], - templateUrl: './project-metadata-subjects.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ProjectMetadataSubjectsComponent implements OnInit { - projectId = input(); - - protected selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); - protected isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); - - protected actions = createDispatchMap({ - fetchSubjects: FetchSubjects, - fetchSelectedSubjects: FetchSelectedSubjects, - fetchChildrenSubjects: FetchChildrenSubjects, - updateResourceSubjects: UpdateResourceSubjects, - }); - - ngOnInit(): void { - this.actions.fetchSubjects(ResourceType.Project); - this.actions.fetchSelectedSubjects(this.projectId()!, ResourceType.Project); - } - - getSubjectChildren(parentId: string) { - this.actions.fetchChildrenSubjects(parentId); - } - - searchSubjects(search: string) { - this.actions.fetchSubjects(ResourceType.Project, this.projectId()!, search); - } - - updateSelectedSubjects(subjects: SubjectModel[]) { - this.actions.updateResourceSubjects(this.projectId()!, ResourceType.Project, subjects); - } -} diff --git a/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.html b/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.html deleted file mode 100644 index 78a9d5e98..000000000 --- a/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.html +++ /dev/null @@ -1,34 +0,0 @@ -
- @if (licensesLoading()) { - - } @else { -
-
- - -
- - @if (selectedLicenseText()) { -
-

{{ selectedLicenseText() }}

-
- } -
- } - -
- - -
-
diff --git a/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.ts b/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.ts deleted file mode 100644 index c939898fe..000000000 --- a/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { Select } from 'primeng/select'; - -import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; - -import { ProjectOverview } from '@osf/features/project/overview/models'; -import { LoadingSpinnerComponent } from '@osf/shared/components'; -import { License, SelectOption } from '@shared/models'; -import { LicensesSelectors, LoadAllLicenses } from '@shared/stores/licenses'; - -interface LicenseForm { - licenseName: FormControl; -} - -@Component({ - selector: 'osf-license-dialog', - imports: [Button, Select, TranslatePipe, ReactiveFormsModule, LoadingSpinnerComponent], - templateUrl: './license-dialog.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class LicenseDialogComponent implements OnInit { - protected dialogRef = inject(DynamicDialogRef); - protected config = inject(DynamicDialogConfig); - - licenseForm = new FormGroup({ - licenseName: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - }); - - protected actions = createDispatchMap({ - loadLicenses: LoadAllLicenses, - }); - - licenses = select(LicensesSelectors.getLicenses); - licensesLoading = select(LicensesSelectors.getLoading); - - selectedLicenseText = signal(''); - licenseOptions: SelectOption[] = []; - currentProject: ProjectOverview | null = null; - - constructor() { - effect(() => { - const currentLicenses = this.licenses(); - - if (currentLicenses) { - this.licenseOptions = currentLicenses.map((license: License) => ({ - label: license.name, - value: license.id, - })); - } - }); - } - - ngOnInit(): void { - this.actions.loadLicenses(); - - if (this.currentProject?.license) { - this.licenseForm.patchValue({ - licenseName: this.currentProject.license.name || '', - }); - } - - this.licenseForm.get('licenseName')?.valueChanges.subscribe((licenseName) => { - this.updateSelectedLicenseText(licenseName); - }); - } - - private updateSelectedLicenseText(licenseId: string): void { - const selectedLicense = this.licenses().find((license: License) => license.id === licenseId); - - if (selectedLicense) { - this.selectedLicenseText.set(selectedLicense.text); - } else { - this.selectedLicenseText.set(''); - } - } - - save(): void { - if (this.licenseForm.valid) { - const formValue = this.licenseForm.getRawValue(); - const selectedLicense = this.licenses().find((license) => license.id === formValue.licenseName); - this.dialogRef.close({ - licenseName: formValue.licenseName, - licenseId: selectedLicense?.id, - projectId: this.currentProject?.id, - }); - } - } - - cancel(): void { - this.dialogRef.close(); - } -} diff --git a/src/app/features/project/metadata/helpers/cedar-metadata.helper.ts b/src/app/features/project/metadata/helpers/cedar-metadata.helper.ts index f49a4e5c4..9ee0ecc35 100644 --- a/src/app/features/project/metadata/helpers/cedar-metadata.helper.ts +++ b/src/app/features/project/metadata/helpers/cedar-metadata.helper.ts @@ -4,6 +4,7 @@ export class CedarMetadataHelper { return items.map((item) => { const safeItem = typeof item === 'object' && item !== null ? (item as Record) : {}; + return { '@id': safeItem['@id'] ?? '', '@type': safeItem['@type'] ?? '', diff --git a/src/app/features/project/metadata/models/cedar-metadata-template.models.ts b/src/app/features/project/metadata/models/cedar-metadata-template.models.ts index 681d716c2..75044e300 100644 --- a/src/app/features/project/metadata/models/cedar-metadata-template.models.ts +++ b/src/app/features/project/metadata/models/cedar-metadata-template.models.ts @@ -225,7 +225,7 @@ export interface CedarMetadataRecordData { }; target: { data: { - type: 'nodes'; + type: 'nodes' | 'registrations'; id: string; }; }; diff --git a/src/app/features/project/metadata/pages/add-metadata/add-metadata.component.ts b/src/app/features/project/metadata/pages/add-metadata/add-metadata.component.ts index 167cb67d4..4686a4572 100644 --- a/src/app/features/project/metadata/pages/add-metadata/add-metadata.component.ts +++ b/src/app/features/project/metadata/pages/add-metadata/add-metadata.component.ts @@ -9,7 +9,6 @@ import { ChangeDetectionStrategy, Component, DestroyRef, effect, HostBinding, in import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { CedarTemplateFormComponent } from '@osf/features/project/metadata/components'; import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecord, @@ -17,6 +16,7 @@ import { CedarRecordDataBinding, } from '@osf/features/project/metadata/models'; import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; +import { CedarTemplateFormComponent } from '@shared/components/shared-metadata/components'; import { ToastService } from '@shared/services'; import { @@ -57,10 +57,6 @@ export class AddMetadataComponent implements OnInit { updateCedarMetadataRecord: UpdateCedarMetadataRecord, }); - get isEditingExistingRecord(): boolean { - return !!this.activatedRoute.snapshot.params['record-id']; - } - constructor() { effect(() => { const records = this.cedarRecords(); diff --git a/src/app/features/project/metadata/project-metadata.component.html b/src/app/features/project/metadata/project-metadata.component.html index b477e407a..080ba1e4c 100644 --- a/src/app/features/project/metadata/project-metadata.component.html +++ b/src/app/features/project/metadata/project-metadata.component.html @@ -24,83 +24,23 @@ @for (tab of tabs(); track $index) { @if (tab.type === 'project') { -
-
- -
-
-

- {{ 'project.overview.metadata.dateCreated' | translate }} -

- -

- {{ currentProject()?.dateCreated | date: 'MMM d, y, h:mm a' }} -

-
- -
-

- {{ 'project.overview.metadata.dateUpdated' | translate }} -

- -

- {{ currentProject()?.dateModified | date: 'MMM d, y, h:mm a' }} -

-
-
-
- - - - - - - - - - -
- -
- - - - - -
-

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

- - -
-
- - -
-
+ } @else {
@if (selectedCedarTemplate() && selectedCedarRecord()) { diff --git a/src/app/features/project/metadata/project-metadata.component.ts b/src/app/features/project/metadata/project-metadata.component.ts index e603d6e5e..bd3c3c942 100644 --- a/src/app/features/project/metadata/project-metadata.component.ts +++ b/src/app/features/project/metadata/project-metadata.component.ts @@ -2,37 +2,16 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { Card } from 'primeng/card'; import { DialogService } from 'primeng/dynamicdialog'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { EMPTY, filter, switchMap } from 'rxjs'; -import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@osf/core/store/user'; -import { - CedarTemplateFormComponent, - ProjectMetadataAffiliatedInstitutionsComponent, - ProjectMetadataContributorsComponent, - ProjectMetadataDescriptionComponent, - ProjectMetadataFundingComponent, - ProjectMetadataLicenseComponent, - ProjectMetadataPublicationDoiComponent, - ProjectMetadataResourceInformationComponent, - ProjectMetadataSubjectsComponent, -} from '@osf/features/project/metadata/components'; -import { - AffiliatedInstitutionsDialogComponent, - ContributorsDialogComponent, - DescriptionDialogComponent, - FundingDialogComponent, - LicenseDialogComponent, - ResourceInformationDialogComponent, -} from '@osf/features/project/metadata/dialogs'; import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecord, @@ -52,25 +31,35 @@ import { UpdateCustomItemMetadata, UpdateProjectDetails, } from '@osf/features/project/metadata/store'; -import { ResourceType } from '@osf/shared/enums'; -import { ContributorsSelectors, GetAllContributors } from '@osf/shared/stores'; -import { LoadingSpinnerComponent, SubHeaderComponent, TagsInputComponent } from '@shared/components'; +import { MetadataProjectsEnum, ResourceType } from '@osf/shared/enums'; +import { + ContributorsSelectors, + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + GetAllContributors, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores'; +import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; +import { CedarTemplateFormComponent } from '@shared/components/shared-metadata/components'; +import { + AffiliatedInstitutionsDialogComponent, + ContributorsDialogComponent, + DescriptionDialogComponent, + FundingDialogComponent, + LicenseDialogComponent, + ResourceInformationDialogComponent, +} from '@shared/components/shared-metadata/dialogs'; +import { SharedMetadataComponent } from '@shared/components/shared-metadata/shared-metadata.component'; +import { SubjectModel } from '@shared/models'; +import { MetadataTabsModel } from '@shared/models/metadata-tabs.model'; import { CustomConfirmationService, LoaderService, ToastService } from '@shared/services'; @Component({ selector: 'osf-project-metadata', imports: [ SubHeaderComponent, - Card, - DatePipe, - ProjectMetadataContributorsComponent, - ProjectMetadataDescriptionComponent, - ProjectMetadataResourceInformationComponent, - ProjectMetadataLicenseComponent, - ProjectMetadataPublicationDoiComponent, - ProjectMetadataSubjectsComponent, - ProjectMetadataFundingComponent, - ProjectMetadataAffiliatedInstitutionsComponent, CedarTemplateFormComponent, TranslatePipe, Tab, @@ -79,7 +68,7 @@ import { CustomConfirmationService, LoaderService, ToastService } from '@shared/ TabPanels, Tabs, LoadingSpinnerComponent, - TagsInputComponent, + SharedMetadataComponent, ], templateUrl: './project-metadata.component.html', styleUrl: './project-metadata.component.scss', @@ -96,7 +85,9 @@ export class ProjectMetadataComponent implements OnInit { private readonly loaderService = inject(LoaderService); private readonly customConfirmationService = inject(CustomConfirmationService); - tabs = signal<{ id: string; label: string; type: 'project' | 'cedar' }[]>([]); + private projectId = ''; + + tabs = signal([]); protected readonly selectedTab = signal('project'); selectedCedarRecord = signal(null); @@ -115,6 +106,11 @@ export class ProjectMetadataComponent implements OnInit { getCedarTemplates: GetCedarMetadataTemplates, createCedarRecord: CreateCedarMetadataRecord, updateCedarRecord: UpdateCedarMetadataRecord, + + fetchSubjects: FetchSubjects, + fetchSelectedSubjects: FetchSelectedSubjects, + fetchChildrenSubjects: FetchChildrenSubjects, + updateResourceSubjects: UpdateResourceSubjects, }); protected currentProject = select(ProjectMetadataSelectors.getProject); @@ -126,6 +122,8 @@ export class ProjectMetadataComponent implements OnInit { protected currentUser = select(UserSelectors.getCurrentUser); protected cedarRecords = select(ProjectMetadataSelectors.getCedarRecords); protected cedarTemplates = select(ProjectMetadataSelectors.getCedarTemplates); + protected selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + protected isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); constructor() { effect(() => { @@ -133,13 +131,14 @@ export class ProjectMetadataComponent implements OnInit { const project = this.currentProject(); if (!project) return; - const baseTabs = [{ id: 'project', label: project.title, type: 'project' as const }]; + const baseTabs = [{ id: 'project', label: project.title, type: MetadataProjectsEnum.PROJECT }]; - const cedarTabs = records.map((record) => ({ - id: record.id || '', - label: record.embeds?.template?.data?.attributes?.schema_name || `Record ${record.id}`, - type: 'cedar' as const, - })); + const cedarTabs = + records?.map((record) => ({ + id: record.id || '', + label: record.embeds?.template?.data?.attributes?.schema_name || `Record ${record.id}`, + type: MetadataProjectsEnum.CEDAR, + })) || []; this.tabs.set([...baseTabs, ...cedarTabs]); @@ -163,14 +162,16 @@ export class ProjectMetadataComponent implements OnInit { } ngOnInit(): void { - const projectId = this.route.parent?.parent?.snapshot.params['id']; + this.projectId = this.route.parent?.parent?.snapshot.params['id']; - if (projectId) { - this.actions.getProject(projectId); - this.actions.getCustomItemMetadata(projectId); - this.actions.getContributors(projectId, ResourceType.Project); - this.actions.getCedarRecords(projectId); + if (this.projectId) { + this.actions.getProject(this.projectId); + this.actions.getCustomItemMetadata(this.projectId); + this.actions.getContributors(this.projectId, ResourceType.Project); + this.actions.getCedarRecords(this.projectId); this.actions.getCedarTemplates(); + this.actions.fetchSubjects(ResourceType.Project); + this.actions.fetchSelectedSubjects(this.projectId!, ResourceType.Project); const user = this.currentUser(); if (user?.id) { @@ -179,27 +180,6 @@ export class ProjectMetadataComponent implements OnInit { } } - private handleRouteBasedTabSelection(): void { - const recordId = this.route.snapshot.paramMap.get('recordId'); - - if (!recordId) { - this.selectedTab.set('project'); - this.selectedCedarRecord.set(null); - this.selectedCedarTemplate.set(null); - return; - } - - const tab = this.tabs().find((tab) => tab.id === recordId); - - if (tab) { - this.selectedTab.set('project'); - - if (tab.type === 'cedar') { - this.loadCedarRecord(tab.id); - } - } - } - onTagsChanged(tags: string[]): void { const projectId = this.currentProject()?.id; if (projectId) { @@ -234,13 +214,6 @@ export class ProjectMetadataComponent implements OnInit { }); } - private refreshContributorsData(): void { - const projectId = this.route.parent?.parent?.snapshot.params['id']; - if (projectId) { - this.actions.getContributors(projectId, ResourceType.Project); - } - } - openEditDescriptionDialog(): void { const dialogRef = this.dialogService.open(DescriptionDialogComponent, { header: this.translateService.instant('project.metadata.description.dialog.header'), @@ -312,7 +285,6 @@ export class ProjectMetadataComponent implements OnInit { ) .subscribe({ next: () => this.toastService.showSuccess('project.metadata.resourceInformation.updated'), - error: () => this.toastService.showError('project.metadata.resourceInformation.updateFailed'), }); } @@ -459,18 +431,20 @@ export class ProjectMetadataComponent implements OnInit { } onTabChange(tabId: string | number): void { - const tab = this.tabs().find((x) => x.id === tabId); + const tab = this.tabs().find((x) => x.id === tabId.toString()); if (!tab) { return; } + this.selectedTab.set(tab.id); + if (tab.type === 'cedar') { this.loadCedarRecord(tab.id); const currentRecordId = this.route.snapshot.paramMap.get('recordId'); if (currentRecordId !== tab.id) { - this.router.navigate(['..', tab.id], { relativeTo: this.route }); + this.router.navigate(['metadata', tab.id], { relativeTo: this.route.parent?.parent }); } } else { this.selectedCedarRecord.set(null); @@ -478,38 +452,11 @@ export class ProjectMetadataComponent implements OnInit { const currentRecordId = this.route.snapshot.paramMap.get('recordId'); if (currentRecordId) { - this.router.navigate(['.'], { relativeTo: this.route }); + this.router.navigate(['metadata'], { relativeTo: this.route.parent?.parent }); } } } - private loadCedarRecord(recordId: string): void { - const records = this.cedarRecords(); - const templates = this.cedarTemplates(); - - const record = records.find((r) => r.id === recordId); - if (!record) { - return; - } - - this.selectedCedarRecord.set(record); - this.cedarFormReadonly.set(true); - - const templateId = record.relationships?.template?.data?.id; - if (templateId && templates?.data) { - const template = templates.data.find((t) => t.id === templateId); - if (template) { - this.selectedCedarTemplate.set(template); - } else { - this.selectedCedarTemplate.set(null); - this.actions.getCedarTemplates(); - } - } else { - this.selectedCedarTemplate.set(null); - this.actions.getCedarTemplates(); - } - } - onCedarFormEdit(): void { this.cedarFormReadonly.set(false); } @@ -561,4 +508,74 @@ export class ProjectMetadataComponent implements OnInit { onCedarFormChangeTemplate(): void { this.router.navigate(['add'], { relativeTo: this.route }); } + + getSubjectChildren(parentId: string) { + this.actions.fetchChildrenSubjects(parentId); + } + + searchSubjects(search: string) { + this.actions.fetchSubjects(ResourceType.Project, this.projectId, search); + } + + updateSelectedSubjects(subjects: SubjectModel[]) { + this.actions.updateResourceSubjects(this.projectId, ResourceType.Project, subjects); + } + + private refreshContributorsData(): void { + if (this.projectId) { + this.actions.getContributors(this.projectId, ResourceType.Project); + } + } + + private loadCedarRecord(recordId: string): void { + const records = this.cedarRecords(); + const templates = this.cedarTemplates(); + + if (!records) { + return; + } + + const record = records.find((r) => r.id === recordId); + if (!record) { + return; + } + + this.selectedCedarRecord.set(record); + this.cedarFormReadonly.set(true); + + const templateId = record.relationships?.template?.data?.id; + if (templateId && templates?.data) { + const template = templates.data.find((t) => t.id === templateId); + if (template) { + this.selectedCedarTemplate.set(template); + } else { + this.selectedCedarTemplate.set(null); + this.actions.getCedarTemplates(); + } + } else { + this.selectedCedarTemplate.set(null); + this.actions.getCedarTemplates(); + } + } + + private handleRouteBasedTabSelection(): void { + const recordId = this.route.snapshot.paramMap.get('recordId'); + + if (!recordId) { + this.selectedTab.set('project'); + this.selectedCedarRecord.set(null); + this.selectedCedarTemplate.set(null); + return; + } + + const tab = this.tabs().find((tab) => tab.id === recordId); + + if (tab) { + this.selectedTab.set(tab.id); + + if (tab.type === 'cedar') { + this.loadCedarRecord(tab.id); + } + } + } } diff --git a/src/app/features/project/metadata/services/metadata.service.ts b/src/app/features/project/metadata/services/metadata.service.ts index 0515535d8..a6c56b206 100644 --- a/src/app/features/project/metadata/services/metadata.service.ts +++ b/src/app/features/project/metadata/services/metadata.service.ts @@ -40,7 +40,7 @@ export class MetadataService { } getFundersList(searchQuery?: string): Observable { - let url = environment.funderApiUrl; + let url = `${environment.funderApiUrl}funders?mailto=support%40osf.io`; if (searchQuery && searchQuery.trim()) { url += `&query=${encodeURIComponent(searchQuery.trim())}`; diff --git a/src/app/features/project/overview/mappers/project-overview.mapper.ts b/src/app/features/project/overview/mappers/project-overview.mapper.ts index 8fcc92437..79cbe3bcd 100644 --- a/src/app/features/project/overview/mappers/project-overview.mapper.ts +++ b/src/app/features/project/overview/mappers/project-overview.mapper.ts @@ -1,3 +1,5 @@ +import { License } from '@shared/models'; + import { ProjectOverview, ProjectOverviewGetResponseJsoApi } from '../models'; export class ProjectOverviewMapper { @@ -23,7 +25,7 @@ export class ProjectOverviewMapper { year: response.attributes.node_license.year, } : undefined, - license: response.embeds.license?.data?.attributes, + license: response.embeds.license?.data?.attributes as License, doi: response.attributes.doi, publicationDoi: response.attributes.publication_doi, analyticsKey: response.attributes.analytics_key, @@ -77,6 +79,6 @@ export class ProjectOverviewMapper { rootFolder: response.relationships?.files?.links?.related?.href, iri: response.links?.iri, }, - }; + } as ProjectOverview; } } diff --git a/src/app/features/project/overview/models/project-overview.models.ts b/src/app/features/project/overview/models/project-overview.models.ts index fdb5a7519..0fd3bb43b 100644 --- a/src/app/features/project/overview/models/project-overview.models.ts +++ b/src/app/features/project/overview/models/project-overview.models.ts @@ -1,4 +1,5 @@ import { JsonApiResponse } from '@osf/core/models'; +import { License } from '@shared/models'; export interface ProjectOverviewContributor { familyName: string; @@ -28,11 +29,7 @@ export interface ProjectOverview { copyrightHolders: string[]; year: string; }; - license?: { - name: string; - text: string; - url: string; - }; + license?: License; doi?: string; publicationDoi?: string; storage?: { @@ -41,19 +38,8 @@ export interface ProjectOverview { storageLimitStatus: string; storageUsage: string; }; - identifiers?: { - id: string; - type: string; - category: string; - value: string; - }[]; - supplements?: { - id: string; - type: string; - title: string; - dateCreated: string; - url: string; - }[]; + identifiers?: ProjectIdentifiers[]; + supplements?: ProjectSupplements[]; analyticsKey: string; currentUserCanComment: boolean; currentUserPermissions: string[]; @@ -66,13 +52,7 @@ export interface ProjectOverview { id: string; type: string; }; - affiliatedInstitutions?: { - id: string; - type: string; - name: string; - description: string; - logo: string; - }[]; + affiliatedInstitutions?: ProjectAffiliatedInstitutions[]; forksCount: number; viewOnlyLinksCount: number; links: { @@ -237,3 +217,26 @@ export interface ProjectOverviewGetResponseJsoApi { export interface ProjectOverviewResponseJsonApi extends JsonApiResponse { data: ProjectOverviewGetResponseJsoApi; } + +export interface ProjectIdentifiers { + id: string; + type: string; + category: string; + value: string; +} + +export interface ProjectAffiliatedInstitutions { + id: string; + type: string; + name: string; + description: string; + logo: string; +} + +export interface ProjectSupplements { + id: string; + type: string; + title: string; + dateCreated: string; + url: string; +} diff --git a/src/app/features/registry/mappers/cedar-form.mapper.ts b/src/app/features/registry/mappers/cedar-form.mapper.ts new file mode 100644 index 000000000..54c680f34 --- /dev/null +++ b/src/app/features/registry/mappers/cedar-form.mapper.ts @@ -0,0 +1,27 @@ +import { CedarMetadataRecord, CedarRecordDataBinding } from '@osf/features/project/metadata/models'; + +export function CedarFormMapper(data: CedarRecordDataBinding, registryId: string): CedarMetadataRecord { + return { + data: { + type: 'cedar_metadata_records' as const, + attributes: { + metadata: data.data, + is_published: false, + }, + relationships: { + template: { + data: { + type: 'cedar-metadata-templates' as const, + id: data.id, + }, + }, + target: { + data: { + type: 'registrations' as const, + id: registryId, + }, + }, + }, + }, + }; +} diff --git a/src/app/features/registry/mappers/index.ts b/src/app/features/registry/mappers/index.ts index 2d7d7308c..07eddfde6 100644 --- a/src/app/features/registry/mappers/index.ts +++ b/src/app/features/registry/mappers/index.ts @@ -1,2 +1,4 @@ +export * from './cedar-form.mapper'; +export * from './registry-metadata.mapper'; export * from './registry-overview.mapper'; export * from './registry-schema-block.mapper'; diff --git a/src/app/features/registry/mappers/registry-metadata.mapper.ts b/src/app/features/registry/mappers/registry-metadata.mapper.ts new file mode 100644 index 000000000..d94a07ef1 --- /dev/null +++ b/src/app/features/registry/mappers/registry-metadata.mapper.ts @@ -0,0 +1,132 @@ +import { ProjectOverviewContributor } from '@osf/features/project/overview/models'; +import { RegistryStatus, RevisionReviewStates } from '@shared/enums'; +import { License } from '@shared/models'; + +import { + BibliographicContributor, + BibliographicContributorData, + BibliographicContributorsJsonApi, +} from '../models/registry-metadata.models'; +import { RegistryOverview } from '../models/registry-overview.models'; + +export class RegistryMetadataMapper { + static fromMetadataApiResponse(response: Record): RegistryOverview { + const attributes = response['attributes'] as Record; + const embeds = response['embeds'] as Record; + const relationships = response['relationships'] as Record; + + const contributors: ProjectOverviewContributor[] = []; + if (embeds && embeds['contributors']) { + const contributorsData = (embeds['contributors'] as Record)['data'] as Record[]; + contributorsData?.forEach((contributor) => { + const contributorEmbeds = contributor['embeds'] as Record; + if (contributorEmbeds && contributorEmbeds['users']) { + const userData = (contributorEmbeds['users'] as Record)['data'] as Record; + const userAttributes = userData['attributes'] as Record; + + contributors.push({ + id: userData['id'] as string, + type: userData['type'] as string, + fullName: userAttributes['full_name'] as string, + givenName: userAttributes['given_name'] as string, + familyName: userAttributes['family_name'] as string, + middleName: (userAttributes['middle_name'] as string) || '', + }); + } + }); + } + + let license: License | undefined; + let licenseUrl: string | undefined; + + if (embeds && embeds['license']) { + const licenseData = (embeds['license'] as Record)['data'] as Record; + if (licenseData) { + const licenseAttributes = licenseData['attributes'] as Record; + license = { + id: licenseData['id'] as string, + name: licenseAttributes['name'] as string, + text: licenseAttributes['text'] as string, + url: licenseAttributes['url'] as string, + requiredFields: (licenseAttributes['required_fields'] as string[]) || [], + }; + } + } else if (relationships && relationships['license']) { + const licenseRelationship = relationships['license'] as Record; + if (licenseRelationship['links']) { + const licenseLinks = licenseRelationship['links'] as Record; + if (licenseLinks['related'] && typeof licenseLinks['related'] === 'object') { + const relatedLinks = licenseLinks['related'] as Record; + licenseUrl = relatedLinks['href'] as string; + } + } + } + + let nodeLicense: { copyrightHolders: string[]; year: string } | undefined; + if (attributes['node_license']) { + const nodeLicenseData = attributes['node_license'] as Record; + nodeLicense = { + copyrightHolders: (nodeLicenseData['copyright_holders'] as string[]) || [], + year: (nodeLicenseData['year'] as string) || new Date().getFullYear().toString(), + }; + } + + return { + id: response['id'] as string, + type: (response['type'] as string) || 'registrations', + title: attributes['title'] as string, + description: attributes['description'] as string, + category: attributes['category'] as string, + tags: (attributes['tags'] as string[]) || [], + dateCreated: attributes['date_created'] as string, + dateModified: attributes['date_modified'] as string, + dateRegistered: attributes['date_registered'] as string, + registrationType: (attributes['registration_type'] as string) || '', + doi: (attributes['doi'] as string) || '', + isPublic: attributes['public'] as boolean, + isFork: attributes['fork'] as boolean, + accessRequestsEnabled: attributes['access_requests_enabled'] as boolean, + wikiEnabled: attributes['wiki_enabled'] as boolean, + currentUserCanComment: attributes['current_user_can_comment'] as boolean, + currentUserPermissions: (attributes['current_user_permissions'] as string[]) || [], + currentUserIsContributor: attributes['current_user_is_contributor'] as boolean, + currentUserIsContributorOrGroupMember: attributes['current_user_is_contributor_or_group_member'] as boolean, + analyticsKey: (attributes['analytics_key'] as string) || '', + contributors: contributors, + subjects: Array.isArray(attributes['subjects']) ? attributes['subjects'].flat() : attributes['subjects'], + license: license, + nodeLicense: nodeLicense, + licenseUrl: licenseUrl, + forksCount: 0, + citation: '', + hasData: false, + hasAnalyticCode: false, + hasMaterials: false, + hasPapers: false, + hasSupplements: false, + questions: {}, + registrationSchemaLink: '', + associatedProjectId: '', + schemaResponses: [], + status: attributes['status'] as RegistryStatus, + revisionStatus: attributes['revision_status'] as RevisionReviewStates, + links: { + files: '', + }, + } as RegistryOverview; + } + + static mapBibliographicContributors(response: BibliographicContributorsJsonApi): BibliographicContributor[] { + return response.data.map((contributor: BibliographicContributorData) => ({ + id: contributor.id, + index: contributor.attributes.index, + user: { + id: contributor.embeds.users.data.id, + fullName: contributor.embeds.users.data.attributes.full_name, + profileImage: contributor.embeds.users.data.links.profile_image, + htmlUrl: contributor.embeds.users.data.links.html, + iri: contributor.embeds.users.data.links.iri, + }, + })); + } +} diff --git a/src/app/features/registry/models/index.ts b/src/app/features/registry/models/index.ts index 05202a94a..e64ed4424 100644 --- a/src/app/features/registry/models/index.ts +++ b/src/app/features/registry/models/index.ts @@ -2,7 +2,10 @@ export * from './get-registry-institutions-json-api.model'; export * from './get-registry-overview-json-api.model'; export * from './get-registry-schema-block-json-api.model'; export * from './get-resource-subjects-json-api.model'; +export * from './registry-contributor-json-api.model'; export * from './registry-institution.model'; +export * from './registry-institutions-json-api.model'; +export * from './registry-metadata.models'; export * from './registry-overview.models'; export * from './registry-schema-block.model'; export * from './registry-subject.model'; diff --git a/src/app/features/registry/models/registry-contributor-json-api.model.ts b/src/app/features/registry/models/registry-contributor-json-api.model.ts new file mode 100644 index 000000000..3193d114e --- /dev/null +++ b/src/app/features/registry/models/registry-contributor-json-api.model.ts @@ -0,0 +1,114 @@ +export interface RegistryContributorJsonApi { + id: string; + type: 'contributors'; + attributes: { + index: number; + bibliographic: boolean; + permission: string; + unregistered_contributor: string | null; + is_curator: boolean; + }; + relationships: { + users: { + links: { + related: { + href: string; + meta: Record; + }; + }; + data: { + id: string; + type: 'users'; + }; + }; + node: { + links: { + related: { + href: string; + meta: Record; + }; + }; + data: { + id: string; + type: 'nodes'; + }; + }; + }; + embeds?: { + users: { + data: { + id: string; + type: 'users'; + attributes: { + full_name: string; + given_name: string; + middle_names: string; + family_name: string; + suffix: string; + date_registered: string; + active: boolean; + timezone: string; + locale: string; + social: Record; + employment: unknown[]; + education: unknown[]; + }; + relationships: Record; + links: { + html: string; + profile_image: string; + self: string; + iri: string; + }; + }; + }; + }; + links: { + self: string; + }; +} + +export interface RegistryContributorJsonApiResponse { + data: RegistryContributorJsonApi; + links: { + self: string; + }; + meta: { + version: string; + }; +} + +export interface RegistryContributorUpdateRequest { + data: { + id: string; + type: 'contributors'; + attributes: Record; + relationships: Record; + }; +} + +export interface RegistryContributorsListJsonApiResponse { + data: RegistryContributorJsonApi[]; + links: { + first: string | null; + last: string | null; + prev: string | null; + next: string | null; + meta: { + total: number; + per_page: number; + total_bibliographic: number; + }; + }; + meta: { + version: string; + }; +} + +export interface RegistryContributorAddRequest { + data: { + type: 'contributors'; + attributes: Record; + relationships: Record; + }; +} diff --git a/src/app/features/registry/models/registry-institutions-json-api.model.ts b/src/app/features/registry/models/registry-institutions-json-api.model.ts new file mode 100644 index 000000000..e3aab2ad1 --- /dev/null +++ b/src/app/features/registry/models/registry-institutions-json-api.model.ts @@ -0,0 +1,29 @@ +export interface RegistryInstitutionJsonApi { + id: string; + type: string; + attributes: { + name: string; + }; + links: { + self: string; + html: string; + iri: string; + }; +} + +export interface RegistryInstitutionsJsonApiResponse { + data: RegistryInstitutionJsonApi[]; + links: { + first: string | null; + last: string | null; + prev: string | null; + next: string | null; + meta: { + total: number; + per_page: number; + }; + }; + meta: { + version: string; + }; +} diff --git a/src/app/features/registry/models/registry-metadata.models.ts b/src/app/features/registry/models/registry-metadata.models.ts new file mode 100644 index 000000000..0a5e7d1bc --- /dev/null +++ b/src/app/features/registry/models/registry-metadata.models.ts @@ -0,0 +1,195 @@ +export interface BibliographicContributorsJsonApi { + data: BibliographicContributorData[]; + meta: { + total: number; + per_page: number; + total_bibliographic: number; + version: string; + }; + links: { + self: string; + first: string | null; + last: string | null; + prev: string | null; + next: string | null; + }; +} + +export interface BibliographicContributorData { + id: string; + type: string; + attributes: { + index: number; + }; + relationships: { + users: { + links: { + related: { + href: string; + meta: Record; + }; + }; + data: { + id: string; + type: string; + }; + }; + }; + embeds: { + users: { + data: { + id: string; + type: string; + attributes: { + full_name: string; + }; + links: { + html: string; + profile_image: string; + self: string; + iri: string; + }; + }; + }; + }; + links: { + self: string; + }; +} + +export interface BibliographicContributor { + id: string; + index: number; + user: { + id: string; + fullName: string; + profileImage: string; + htmlUrl: string; + iri: string; + }; +} + +export interface CustomItemMetadataRecord { + language?: string; + resource_type_general?: string; + funders?: { + funder_name: string; + funder_identifier?: string; + funder_identifier_type?: string; + award_number?: string; + award_uri?: string; + award_title?: string; + }[]; +} + +export interface CustomItemMetadataResponse { + data: { + id: string; + type: string; + attributes: CustomItemMetadataRecord; + }; +} + +export interface CrossRefFunder { + id: number; + location: string; + name: string; + alt_names: string[]; + uri: string; + replaces: number[]; + replaced_by: number | null; + tokens: string[]; +} + +export interface CrossRefFundersResponse { + status: string; + message_type: string; + message_version: string; + message: { + facets: Record; + total_results: number; + items: CrossRefFunder[]; + items_per_page: number; + }; +} + +export interface UserInstitution { + id: string; + type: string; + attributes: { + name: string; + description: string; + logo_path: string | null; + }; +} + +export interface UserInstitutionsResponse { + data: UserInstitution[]; + meta: { + total: number; + per_page: number; + version: string; + }; + links: { + self: string; + first: string | null; + last: string | null; + prev: string | null; + next: string | null; + }; +} + +export interface RegistryMetadata { + tags?: string[]; + description?: string; + category?: string; + doi?: boolean; + node_license?: { + id: string; + type: string; + }; + institutions?: { + id: string; + type: string; + }[]; +} + +export interface RegistrySubjectsJsonApi { + data: RegistrySubjectData[]; + links: { + first: string | null; + last: string | null; + prev: string | null; + next: string | null; + meta: { + total: number; + per_page: number; + }; + }; + meta: { + version: string; + }; +} + +export interface RegistrySubjectData { + id: string; + type: string; + attributes: { + text: string; + taxonomy_name: string; + }; + relationships: { + children: { + links: { + related: { + href: string; + meta: Record; + }; + }; + }; + }; + links: { + self: string; + iri: string; + }; +} diff --git a/src/app/features/registry/models/registry-overview.models.ts b/src/app/features/registry/models/registry-overview.models.ts index 91cdc6d53..b9c34528a 100644 --- a/src/app/features/registry/models/registry-overview.models.ts +++ b/src/app/features/registry/models/registry-overview.models.ts @@ -1,6 +1,7 @@ import { ProjectOverviewContributor } from '@osf/features/project/overview/models'; import { RegistrationQuestions, RegistrySubject } from '@osf/features/registry/models'; import { RegistryStatus, RevisionReviewStates } from '@shared/enums'; +import { License } from '@shared/models'; export interface RegistryOverview { id: string; @@ -24,11 +25,8 @@ export interface RegistryOverview { copyrightHolders: string[]; year: string; }; - license?: { - name: string; - text: string; - url: string; - }; + license?: License; + licenseUrl?: string; identifiers?: { id: string; type: string; diff --git a/src/app/features/registry/pages/index.ts b/src/app/features/registry/pages/index.ts index de079877f..369382d7f 100644 --- a/src/app/features/registry/pages/index.ts +++ b/src/app/features/registry/pages/index.ts @@ -1,2 +1,4 @@ +export * from './registry-metadata/registry-metadata.component'; +export * from './registry-metadata-add/registry-metadata-add.component'; export * from '@osf/features/registry/pages/registry-files/registry-files.component'; export * from '@osf/features/registry/pages/registry-overview/registry-overview.component'; diff --git a/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.html b/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.html new file mode 100644 index 000000000..1ab039dde --- /dev/null +++ b/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.html @@ -0,0 +1,77 @@ + + +@if (!selectedTemplate()) { + @if (cedarTemplatesLoading()) { +
+ +
+ } @else { +
+
+
+

{{ 'project.metadata.addMetadata.selectTemplate' | translate }}

+
+
+ +
+ @for (meta of cedarTemplates()?.data; track meta.id) { + + } +
+ +
+ + @if (hasMultiplePages()) { + + } +
+
+
+ } +} @else { + +} diff --git a/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.scss b/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.scss new file mode 100644 index 000000000..924232170 --- /dev/null +++ b/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.scss @@ -0,0 +1,9 @@ +@use "assets/styles/variables" as var; + +.metadata { + flex-basis: calc(50% - 1.5rem); + + @media (max-width: var.$breakpoint-sm) { + flex-basis: 100%; + } +} diff --git a/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.spec.ts b/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.spec.ts new file mode 100644 index 000000000..b51634051 --- /dev/null +++ b/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.spec.ts @@ -0,0 +1,568 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; +import { MockComponent, MockPipe } from 'ng-mocks'; + +import { of, throwError } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter, Router } from '@angular/router'; + +import { + CedarMetadataDataTemplateJsonApi, + CedarMetadataRecord, + CedarMetadataRecordData, + CedarRecordDataBinding, +} from '@osf/features/project/metadata/models'; +import { CedarFormMapper } from '@osf/features/registry/mappers'; +import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; +import { CedarTemplateFormComponent } from '@shared/components/shared-metadata/components'; +import { ToastService } from '@shared/services'; + +import { RegistryMetadataState } from '../../store/registry-metadata'; + +import { RegistryMetadataAddComponent } from './registry-metadata-add.component'; + +jest.mock('@osf/features/registry/mappers', () => ({ + CedarFormMapper: jest.fn(), +})); + +describe('RegistryMetadataAddComponent', () => { + let component: RegistryMetadataAddComponent; + let fixture: ComponentFixture; + let store: Store; + let router: Router; + let activatedRoute: ActivatedRoute; + let toastService: ToastService; + + const mockRegistryId = 'test-registry-id'; + const mockRecordId = 'test-record-id'; + + const mockCedarTemplate: CedarMetadataDataTemplateJsonApi = { + id: 'template-1', + type: 'cedar-metadata-templates', + attributes: { + schema_name: 'Test Template', + cedar_id: 'cedar-123', + template: { + '@id': 'test-id', + '@type': 'test-type', + type: 'object', + title: 'Test Template', + description: 'Test Description', + $schema: 'http://json-schema.org/draft-04/schema#', + '@context': { + pav: 'http://purl.org/pav/', + xsd: 'http://www.w3.org/2001/XMLSchema#', + bibo: 'http://purl.org/ontology/bibo/', + oslc: 'http://open-services.net/ns/core#', + schema: 'http://schema.org/', + 'schema:name': { '@type': 'xsd:string' }, + 'pav:createdBy': { '@type': '@id' }, + 'pav:createdOn': { '@type': 'xsd:dateTime' }, + 'oslc:modifiedBy': { '@type': '@id' }, + 'pav:lastUpdatedOn': { '@type': 'xsd:dateTime' }, + 'schema:description': { '@type': 'xsd:string' }, + }, + required: ['@context', 'schema:name'], + properties: {}, + _ui: { + order: ['schema:name'], + propertyLabels: { 'schema:name': 'Name' }, + propertyDescriptions: { 'schema:name': 'Template name' }, + }, + }, + }, + }; + + const mockCedarRecord: CedarMetadataRecordData = { + id: mockRecordId, + type: 'cedar_metadata_records', + attributes: { + metadata: { + '@context': {}, + Constructs: [], + Assessments: [], + Organization: [], + 'Project Name': { '@value': 'Test Project' }, + LDbaseWebsite: {}, + 'Project Methods': [], + 'Participant Types': [], + 'Special Populations': [], + 'Developmental Design': {}, + LDbaseProjectEndDate: { '@type': 'xsd:date', '@value': '2024-12-31' }, + 'Educational Curricula': [], + LDbaseInvestigatorORCID: [], + LDbaseProjectStartDates: { '@type': 'xsd:date', '@value': '2024-01-01' }, + 'Educational Environments': {}, + LDbaseProjectDescription: { '@value': 'Test Description' }, + LDbaseProjectContributors: [], + }, + is_published: false, + }, + relationships: { + template: { + data: { + type: 'cedar-metadata-templates', + id: 'template-1', + }, + }, + target: { + data: { + type: 'registrations', + id: mockRegistryId, + }, + }, + }, + }; + + const mockCedarTemplates = { + data: [mockCedarTemplate], + links: { + first: 'http://api.test.com/first', + last: 'http://api.test.com/last', + next: 'http://api.test.com/next', + prev: null, + }, + }; + + const mockCedarRecords = [mockCedarRecord]; + + const mockActivatedRoute = { + snapshot: { + params: {}, + }, + parent: { + parent: { + snapshot: { + params: { id: mockRegistryId }, + }, + }, + }, + }; + + beforeEach(async () => { + const mockToastService = { + showSuccess: jest.fn(), + showError: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ + RegistryMetadataAddComponent, + MockComponent(SubHeaderComponent), + MockComponent(CedarTemplateFormComponent), + MockComponent(LoadingSpinnerComponent), + MockPipe(TranslatePipe), + ], + providers: [ + provideStore([RegistryMetadataState]), + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: ToastService, useValue: mockToastService }, + ], + }).compileComponents(); + + store = TestBed.inject(Store); + router = TestBed.inject(Router); + activatedRoute = TestBed.inject(ActivatedRoute); + toastService = TestBed.inject(ToastService); + + store.reset({ + registryMetadata: { + cedarRecords: { data: mockCedarRecords, isLoading: false, error: null }, + cedarTemplates: { data: mockCedarTemplates, isLoading: false, error: null }, + cedarRecord: { data: null, isLoading: false, error: null }, + }, + }); + + fixture = TestBed.createComponent(RegistryMetadataAddComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should initialize with registryId from route params', () => { + component.ngOnInit(); + expect(component['registryId']).toBe(mockRegistryId); + }); + + it('should dispatch actions when registryId is available', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + component.ngOnInit(); + + expect(dispatchSpy).toHaveBeenCalledTimes(2); + }); + + it('should not dispatch actions when registryId is not available', () => { + component['route'].parent!.parent!.snapshot.params['id'] = undefined; + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.ngOnInit(); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('constructor effect', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should handle record-id in route params for editing existing record', () => { + activatedRoute.snapshot.params = { 'record-id': mockRecordId }; + + const newFixture = TestBed.createComponent(RegistryMetadataAddComponent); + newFixture.detectChanges(); + const newComponent = newFixture.componentInstance; + + expect(newComponent.existingRecord()).toEqual(mockCedarRecord); + expect(newComponent.selectedTemplate()).toEqual(mockCedarTemplate); + expect(newComponent.isEditMode).toBe(false); + }); + + it('should handle no record-id in route params for creating new record', () => { + activatedRoute.snapshot.params = {}; + + const newFixture = TestBed.createComponent(RegistryMetadataAddComponent); + newFixture.detectChanges(); + const newComponent = newFixture.componentInstance; + + expect(newComponent.existingRecord()).toBeNull(); + expect(newComponent.selectedTemplate()).toBeNull(); + expect(newComponent.isEditMode).toBe(true); + }); + }); + + describe('hasMultiplePages', () => { + it('should return true when first and last links are different', () => { + expect(component.hasMultiplePages()).toBe(true); + }); + + it('should return false when first and last links are the same', () => { + store.reset({ + registryMetadata: { + cedarTemplates: { + data: { + ...mockCedarTemplates, + links: { first: 'same', last: 'same' }, + }, + isLoading: false, + error: null, + }, + }, + }); + fixture.detectChanges(); + + expect(component.hasMultiplePages()).toBe(false); + }); + + it('should return false when templates are null', () => { + store.reset({ + registryMetadata: { + cedarTemplates: { data: null, isLoading: false, error: null }, + }, + }); + fixture.detectChanges(); + + expect(component.hasMultiplePages()).toBe(false); + }); + }); + + describe('hasNextPage', () => { + it('should return true when next link exists', () => { + expect(component.hasNextPage()).toBe(true); + }); + + it('should return false when next link does not exist', () => { + store.reset({ + registryMetadata: { + cedarTemplates: { + data: { + ...mockCedarTemplates, + links: { ...mockCedarTemplates.links, next: null }, + }, + isLoading: false, + error: null, + }, + }, + }); + fixture.detectChanges(); + + expect(component.hasNextPage()).toBe(false); + }); + }); + + describe('hasExistingRecord', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should return true when record with template id exists', () => { + const result = component.hasExistingRecord('template-1'); + expect(result).toBe(true); + }); + + it('should return false when no record with template id exists', () => { + const result = component.hasExistingRecord('non-existent-template'); + expect(result).toBe(false); + }); + + it('should return false when records are null', () => { + store.reset({ + registryMetadata: { + cedarRecords: { data: null, isLoading: false, error: null }, + }, + }); + fixture.detectChanges(); + + const result = component.hasExistingRecord('template-1'); + expect(result).toBe(false); + }); + }); + + describe('onTemplateSelected', () => { + it('should set selected template', () => { + component.onTemplateSelected(mockCedarTemplate); + expect(component.selectedTemplate()).toEqual(mockCedarTemplate); + }); + }); + + describe('onSubmit', () => { + const mockSubmissionData: CedarRecordDataBinding = { + data: { + '@context': {}, + Constructs: [], + Assessments: [], + Organization: [], + 'Project Name': { '@value': 'Test Project' }, + LDbaseWebsite: {}, + 'Project Methods': [], + 'Participant Types': [], + 'Special Populations': [], + 'Developmental Design': {}, + LDbaseProjectEndDate: { '@type': 'xsd:date', '@value': '2024-12-31' }, + 'Educational Curricula': [], + LDbaseInvestigatorORCID: [], + LDbaseProjectStartDates: { '@type': 'xsd:date', '@value': '2024-01-01' }, + 'Educational Environments': {}, + LDbaseProjectDescription: { '@value': 'Test Description' }, + LDbaseProjectContributors: [], + }, + id: 'template-1', + }; + + const mockMappedRecord: CedarMetadataRecord = { + data: { + type: 'cedar_metadata_records', + attributes: { + metadata: mockSubmissionData.data, + is_published: false, + }, + relationships: { + template: { + data: { + type: 'cedar-metadata-templates', + id: mockSubmissionData.id, + }, + }, + target: { + data: { + type: 'registrations', + id: mockRegistryId, + }, + }, + }, + }, + }; + + beforeEach(() => { + component.ngOnInit(); + fixture.detectChanges(); + (CedarFormMapper as jest.Mock).mockReturnValue(mockMappedRecord); + }); + + it('should not submit when registryId is not available', () => { + component['registryId'] = ''; + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.onSubmit(mockSubmissionData); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(component.isSubmitting()).toBe(false); + }); + + it('should successfully create cedar record', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of({})); + const routerSpy = jest.spyOn(router, 'navigate'); + + store.reset({ + registryMetadata: { + cedarRecord: { + data: { data: { id: 'new-record-id' } }, + isLoading: false, + error: null, + }, + }, + }); + + component.onSubmit(mockSubmissionData); + + expect(component.isSubmitting()).toBe(true); + expect(CedarFormMapper).toHaveBeenCalledWith(mockSubmissionData, mockRegistryId); + expect(dispatchSpy).toHaveBeenCalled(); + }); + + it('should handle submission success', (done) => { + const routerSpy = jest.spyOn(router, 'navigate'); + const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of({})); + + store.reset({ + registryMetadata: { + cedarRecord: { + data: { data: { id: 'new-record-id' } }, + isLoading: false, + error: null, + }, + }, + }); + + component.onSubmit(mockSubmissionData); + + // Use setTimeout to allow the subscription to complete + setTimeout(() => { + expect(component.isSubmitting()).toBe(false); + expect(toastService.showSuccess).toHaveBeenCalledWith( + 'project.overview.metadata.cedarRecordCreatedSuccessfully' + ); + expect(routerSpy).toHaveBeenCalledWith(['../metadata', 'new-record-id'], { + relativeTo: activatedRoute.parent, + }); + done(); + }, 0); + }); + + it('should handle submission error', (done) => { + const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(throwError(() => new Error('Test error'))); + + component.onSubmit(mockSubmissionData); + + // Use setTimeout to allow the subscription to complete + setTimeout(() => { + expect(component.isSubmitting()).toBe(false); + expect(toastService.showError).toHaveBeenCalledWith('project.overview.metadata.failedToCreateCedarRecord'); + done(); + }, 0); + }); + }); + + describe('onChangeTemplate', () => { + it('should reset selected template', () => { + component.selectedTemplate.set(mockCedarTemplate); + component.onChangeTemplate(); + expect(component.selectedTemplate()).toBeNull(); + }); + }); + + describe('toggleEditMode', () => { + it('should toggle edit mode', () => { + const initialMode = component.isEditMode; + component.toggleEditMode(); + expect(component.isEditMode).toBe(!initialMode); + }); + }); + + describe('onNext', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should dispatch action with next link when available', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.onNext(); + + expect(dispatchSpy).toHaveBeenCalled(); + }); + + it('should not dispatch action when next link is not available', () => { + store.reset({ + registryMetadata: { + cedarTemplates: { + data: { + ...mockCedarTemplates, + links: { ...mockCedarTemplates.links, next: null }, + }, + isLoading: false, + error: null, + }, + }, + }); + fixture.detectChanges(); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.onNext(); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('onCancel', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should dispatch getCedarTemplates when multiple pages exist', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.onCancel(); + + expect(dispatchSpy).toHaveBeenCalled(); + }); + + it('should navigate back when single page', () => { + store.reset({ + registryMetadata: { + cedarTemplates: { + data: { + ...mockCedarTemplates, + links: { first: 'same', last: 'same' }, + }, + isLoading: false, + error: null, + }, + }, + }); + fixture.detectChanges(); + + const routerSpy = jest.spyOn(router, 'navigate'); + + component.onCancel(); + + expect(routerSpy).toHaveBeenCalledWith(['..'], { relativeTo: activatedRoute }); + }); + + it('should navigate back when templates are null', () => { + store.reset({ + registryMetadata: { + cedarTemplates: { data: null, isLoading: false, error: null }, + }, + }); + fixture.detectChanges(); + + const routerSpy = jest.spyOn(router, 'navigate'); + + component.onCancel(); + + expect(routerSpy).toHaveBeenCalledWith(['..'], { relativeTo: activatedRoute }); + }); + }); +}); diff --git a/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.ts b/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.ts new file mode 100644 index 000000000..d186849f0 --- /dev/null +++ b/src/app/features/registry/pages/registry-metadata-add/registry-metadata-add.component.ts @@ -0,0 +1,172 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; + +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { + CedarMetadataDataTemplateJsonApi, + CedarMetadataRecordData, + CedarRecordDataBinding, +} from '@osf/features/project/metadata/models'; +import { CedarFormMapper } from '@osf/features/registry/mappers'; +import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; +import { CedarTemplateFormComponent } from '@shared/components/shared-metadata/components'; +import { ToastService } from '@shared/services'; + +import { + CreateCedarMetadataRecord, + GetCedarMetadataTemplates, + GetRegistryCedarMetadataRecords, + RegistryMetadataSelectors, +} from '../../store/registry-metadata'; + +@Component({ + selector: 'osf-registry-metadata-add', + imports: [SubHeaderComponent, CedarTemplateFormComponent, LoadingSpinnerComponent, TranslatePipe, Button, Tooltip], + templateUrl: './registry-metadata-add.component.html', + styleUrl: './registry-metadata-add.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistryMetadataAddComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly toastService = inject(ToastService); + + isEditMode = true; + + private registryId = ''; + + existingRecord = signal(null); + selectedTemplate = signal(null); + isSubmitting = signal(false); + + protected actions = createDispatchMap({ + getCedarTemplates: GetCedarMetadataTemplates, + getCedarRecords: GetRegistryCedarMetadataRecords, + createCedarRecord: CreateCedarMetadataRecord, + }); + + protected cedarRecords = select(RegistryMetadataSelectors.getCedarRecords); + protected cedarTemplates = select(RegistryMetadataSelectors.getCedarTemplates); + protected cedarTemplatesLoading = select(RegistryMetadataSelectors.getCedarTemplatesLoading); + protected cedarRecord = select(RegistryMetadataSelectors.getCedarRecord); + + constructor() { + effect(() => { + const records = this.cedarRecords(); + const cedarTemplatesData = this.cedarTemplates()?.data; + const recordId = this.route.snapshot.params['record-id']; + + if (!records || !cedarTemplatesData) { + return; + } + + if (recordId) { + const existingRecord = records.find((record) => { + return record.id === recordId; + }); + + if (existingRecord) { + const templateId = existingRecord.relationships.template.data.id; + const matchingTemplate = cedarTemplatesData.find((template) => template.id === templateId); + + if (matchingTemplate) { + this.selectedTemplate.set(matchingTemplate); + this.existingRecord.set(existingRecord); + this.isEditMode = false; + } + } + } else { + this.selectedTemplate.set(null); + this.existingRecord.set(null); + this.isEditMode = true; + } + }); + } + + ngOnInit(): void { + this.registryId = this.route.parent?.parent?.snapshot.params['id']; + + if (this.registryId) { + this.actions.getCedarTemplates(); + this.actions.getCedarRecords(this.registryId); + } + } + + hasMultiplePages(): boolean { + const templates = this.cedarTemplates(); + return !!(templates?.links?.first && templates?.links?.last && templates.links.first !== templates.links.last); + } + + hasNextPage(): boolean { + const templates = this.cedarTemplates(); + return !!templates?.links?.next; + } + + hasExistingRecord(templateId: string): boolean { + const records = this.cedarRecords(); + if (!records) return false; + + return records.some((record) => record.relationships.template.data.id === templateId); + } + + onTemplateSelected(template: CedarMetadataDataTemplateJsonApi): void { + this.selectedTemplate.set(template); + } + + onSubmit(data: CedarRecordDataBinding): void { + const registryId = this.registryId; + if (!registryId) return; + + this.isSubmitting.set(true); + + const model = CedarFormMapper(data, registryId); + + this.actions + .createCedarRecord(model) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.isSubmitting.set(false); + this.toastService.showSuccess('project.overview.metadata.cedarRecordCreatedSuccessfully'); + this.router.navigate(['../metadata', this.cedarRecord()?.data.id], { relativeTo: this.route.parent }); + }, + error: () => { + this.isSubmitting.set(false); + this.toastService.showError('project.overview.metadata.failedToCreateCedarRecord'); + }, + }); + } + + onChangeTemplate(): void { + this.selectedTemplate.set(null); + } + + toggleEditMode(): void { + this.isEditMode = !this.isEditMode; + } + + onNext(): void { + const templates = this.cedarTemplates(); + if (!templates?.links?.next) { + return; + } + this.actions.getCedarTemplates(templates.links.next); + } + + onCancel(): void { + const templates = this.cedarTemplates(); + if (templates?.links?.first && templates?.links?.last && templates.links.first !== templates.links.last) { + this.actions.getCedarTemplates(); + } else { + this.router.navigate(['..'], { relativeTo: this.route }); + } + } +} diff --git a/src/app/features/registry/pages/registry-metadata/registry-metadata.component.html b/src/app/features/registry/pages/registry-metadata/registry-metadata.component.html new file mode 100644 index 000000000..0f4c3d417 --- /dev/null +++ b/src/app/features/registry/pages/registry-metadata/registry-metadata.component.html @@ -0,0 +1,69 @@ +
+ + + @if (!tabs().length) { +
+ +
+ } + + @if (tabs().length) { + + + @for (item of tabs(); track $index) { + {{ item.label | translate }} + } + + + + @for (tab of tabs(); track $index) { + + @if (tab.type === 'registry') { + + } @else { +
+ @if (selectedCedarTemplate() && selectedCedarRecord()) { + + } @else { +
+

{{ 'project.metadata.addMetadata.loadingCedar' | translate }}

+

{{ tab.label }}

+
+ } +
+ } +
+ } +
+
+ } +
diff --git a/src/app/features/project/metadata/components/cedar-template-form/cedar-template-form.component.scss b/src/app/features/registry/pages/registry-metadata/registry-metadata.component.scss similarity index 100% rename from src/app/features/project/metadata/components/cedar-template-form/cedar-template-form.component.scss rename to src/app/features/registry/pages/registry-metadata/registry-metadata.component.scss diff --git a/src/app/features/registry/pages/registry-metadata/registry-metadata.component.spec.ts b/src/app/features/registry/pages/registry-metadata/registry-metadata.component.spec.ts new file mode 100644 index 000000000..6d4445e0c --- /dev/null +++ b/src/app/features/registry/pages/registry-metadata/registry-metadata.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistryMetadataComponent } from './registry-metadata.component'; + +describe('RegistryMetadataComponent', () => { + let component: RegistryMetadataComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistryMetadataComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistryMetadataComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registry/pages/registry-metadata/registry-metadata.component.ts b/src/app/features/registry/pages/registry-metadata/registry-metadata.component.ts new file mode 100644 index 000000000..e0056983f --- /dev/null +++ b/src/app/features/registry/pages/registry-metadata/registry-metadata.component.ts @@ -0,0 +1,563 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { DialogService } from 'primeng/dynamicdialog'; +import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; + +import { EMPTY, filter, switchMap } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + OnInit, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { UserSelectors } from '@osf/core/store/user'; +import { + CedarMetadataDataTemplateJsonApi, + CedarMetadataRecordData, + CedarRecordDataBinding, + CustomItemMetadataRecord, +} from '@osf/features/project/metadata/models'; +import { ProjectOverview } from '@osf/features/project/overview/models'; +import { CedarFormMapper } from '@osf/features/registry/mappers'; +import { + ContributorsSelectors, + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + GetAllContributors, + SubjectsSelectors, +} from '@osf/shared/stores'; +import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; +import { CedarTemplateFormComponent } from '@shared/components/shared-metadata/components'; +import { + AffiliatedInstitutionsDialogComponent, + ContributorsDialogComponent, + DescriptionDialogComponent, + FundingDialogComponent, + ResourceInformationDialogComponent, +} from '@shared/components/shared-metadata/dialogs'; +import { SharedMetadataComponent } from '@shared/components/shared-metadata/shared-metadata.component'; +import { MetadataProjectsEnum, ResourceType } from '@shared/enums'; +import { SubjectModel } from '@shared/models'; +import { MetadataTabsModel } from '@shared/models/metadata-tabs.model'; +import { ToastService } from '@shared/services'; + +import { + AddRegistryContributor, + CreateCedarMetadataRecord, + GetBibliographicContributors, + GetCedarMetadataTemplates, + GetCustomItemMetadata, + GetRegistryCedarMetadataRecords, + GetRegistryForMetadata, + GetRegistryInstitutions, + GetRegistrySubjects, + GetUserInstitutions, + RegistryMetadataSelectors, + UpdateCedarMetadataRecord, + UpdateCustomItemMetadata, + UpdateRegistryContributor, + UpdateRegistryDetails, + UpdateRegistryInstitutions, + UpdateRegistrySubjects, +} from '../../store/registry-metadata'; + +@Component({ + selector: 'osf-registry-metadata', + imports: [ + SubHeaderComponent, + TranslatePipe, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + LoadingSpinnerComponent, + SharedMetadataComponent, + CedarTemplateFormComponent, + ], + templateUrl: './registry-metadata.component.html', + styleUrl: './registry-metadata.component.scss', + providers: [DialogService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistryMetadataComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly dialogService = inject(DialogService); + private readonly translateService = inject(TranslateService); + private readonly toastService = inject(ToastService); + + private registryId = ''; + + tabs = signal([]); + protected readonly selectedTab = signal('registry'); + + selectedCedarRecord = signal(null); + selectedCedarTemplate = signal(null); + cedarFormReadonly = signal(true); + + protected actions = createDispatchMap({ + getRegistry: GetRegistryForMetadata, + getBibliographicContributors: GetBibliographicContributors, + updateRegistryDetails: UpdateRegistryDetails, + getCustomItemMetadata: GetCustomItemMetadata, + updateCustomItemMetadata: UpdateCustomItemMetadata, + getContributors: GetAllContributors, + getUserInstitutions: GetUserInstitutions, + getRegistryInstitutions: GetRegistryInstitutions, + getRegistrySubjects: GetRegistrySubjects, + getCedarRecords: GetRegistryCedarMetadataRecords, + getCedarTemplates: GetCedarMetadataTemplates, + createCedarRecord: CreateCedarMetadataRecord, + updateCedarRecord: UpdateCedarMetadataRecord, + addRegistryContributor: AddRegistryContributor, + + fetchSubjects: FetchSubjects, + fetchSelectedSubjects: FetchSelectedSubjects, + fetchChildrenSubjects: FetchChildrenSubjects, + updateRegistrySubjects: UpdateRegistrySubjects, + updateRegistryInstitutions: UpdateRegistryInstitutions, + updateRegistryContributor: UpdateRegistryContributor, + }); + + protected currentRegistry = select(RegistryMetadataSelectors.getRegistry); + protected currentRegistryLoading = select(RegistryMetadataSelectors.getRegistryLoading); + protected customItemMetadata = select(RegistryMetadataSelectors.getCustomItemMetadata); + protected currentUser = select(UserSelectors.getCurrentUser); + protected contributors = select(ContributorsSelectors.getContributors); + protected isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + protected institutions = select(RegistryMetadataSelectors.getInstitutions); + protected selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + protected isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); + protected cedarRecords = select(RegistryMetadataSelectors.getCedarRecords); + protected cedarTemplates = select(RegistryMetadataSelectors.getCedarTemplates); + + protected readonly isReadonly = computed(() => { + const registry = this.currentRegistry(); + if (!registry) return false; + + const permissions = registry.currentUserPermissions || []; + return permissions.length === 1 && permissions[0] === 'read'; + }); + + constructor() { + effect(() => { + const records = this.cedarRecords(); + const registry = this.currentRegistry(); + if (!registry) return; + + const baseTabs = [{ id: 'registry', label: registry.title, type: MetadataProjectsEnum.REGISTRY }]; + + const cedarTabs = + records?.map((record) => ({ + id: record.id || '', + label: record.embeds?.template?.data?.attributes?.schema_name || `Record ${record.id}`, + type: MetadataProjectsEnum.CEDAR, + })) || []; + + this.tabs.set([...baseTabs, ...cedarTabs]); + + this.handleRouteBasedTabSelection(); + }); + + effect(() => { + const templates = this.cedarTemplates(); + const selectedRecord = this.selectedCedarRecord(); + + if (selectedRecord && templates?.data && !this.selectedCedarTemplate()) { + const templateId = selectedRecord.relationships?.template?.data?.id; + if (templateId) { + const template = templates.data.find((t) => t.id === templateId); + if (template) { + this.selectedCedarTemplate.set(template); + } + } + } + }); + } + + ngOnInit(): void { + this.registryId = this.route.parent?.parent?.snapshot.params['id']; + + if (this.registryId) { + this.actions.getRegistry(this.registryId); + this.actions.getBibliographicContributors(this.registryId); + this.actions.getCustomItemMetadata(this.registryId); + this.actions.getContributors(this.registryId, ResourceType.Registration); + this.actions.getRegistryInstitutions(this.registryId); + this.actions.getRegistrySubjects(this.registryId); + this.actions.getCedarRecords(this.registryId); + this.actions.getCedarTemplates(); + this.actions.fetchSubjects(ResourceType.Registration, this.registryId, '', true); + this.actions.fetchSelectedSubjects(this.registryId, ResourceType.Registration); + + const user = this.currentUser(); + if (user?.id) { + this.actions.getUserInstitutions(user.id); + } + } + } + + openAddRecord(): void { + this.router.navigate(['add'], { relativeTo: this.route }); + } + + onTagsChanged(tags: string[]): void { + const registryId = this.currentRegistry()?.id; + if (registryId) { + this.actions.updateRegistryDetails(registryId, { tags }); + } + } + + openEditContributorDialog(): void { + const dialogRef = this.dialogService.open(ContributorsDialogComponent, { + width: '800px', + header: this.translateService.instant('project.metadata.contributors.editContributors'), + focusOnShow: false, + closeOnEscape: true, + modal: true, + closable: true, + data: { + projectId: this.currentRegistry()?.id, + contributors: this.contributors(), + isLoading: this.isContributorsLoading(), + isRegistry: true, + }, + }); + + dialogRef.onClose.pipe(filter((result) => !!result && (result.refresh || result.saved))).subscribe({ + next: () => { + this.refreshContributorsData(); + this.toastService.showSuccess('project.metadata.contributors.updateSucceed'); + }, + }); + } + + openEditDescriptionDialog(): void { + const dialogRef = this.dialogService.open(DescriptionDialogComponent, { + header: this.translateService.instant('project.metadata.description.dialog.header'), + width: '500px', + focusOnShow: false, + closeOnEscape: true, + modal: true, + closable: true, + data: { + currentProject: this.currentRegistry(), + }, + }); + + dialogRef.onClose + .pipe( + filter((result) => !!result), + switchMap((result) => { + const registryId = this.currentRegistry()?.id; + if (registryId) { + return this.actions.updateRegistryDetails(registryId, { description: result }); + } + return EMPTY; + }) + ) + .subscribe({ + next: () => { + this.toastService.showSuccess('project.metadata.description.updated'); + const registryId = this.currentRegistry()?.id; + if (registryId) { + this.actions.getRegistry(registryId); + } + }, + }); + } + + openEditResourceInformationDialog(): void { + const dialogRef = this.dialogService.open(ResourceInformationDialogComponent, { + header: this.translateService.instant('project.metadata.resourceInformation.dialog.header'), + width: '500px', + focusOnShow: false, + closeOnEscape: true, + modal: true, + closable: true, + data: { + currentProject: this.currentRegistry(), + customItemMetadata: this.customItemMetadata(), + }, + }); + + dialogRef.onClose + .pipe( + filter((result) => !!result && (result.resourceType || result.resourceLanguage)), + switchMap((result) => { + const registryId = this.currentRegistry()?.id; + if (registryId) { + const currentMetadata = this.customItemMetadata(); + + const updatedMetadata = { + ...currentMetadata, + language: result.resourceLanguage || currentMetadata?.language, + resource_type_general: result.resourceType || currentMetadata?.resource_type_general, + funders: currentMetadata?.funders, + }; + + return this.actions.updateCustomItemMetadata(registryId, updatedMetadata); + } + return EMPTY; + }) + ) + .subscribe({ + next: () => this.toastService.showSuccess('project.metadata.resourceInformation.updated'), + }); + } + + openEditFundingDialog(): void { + const dialogRef = this.dialogService.open(FundingDialogComponent, { + header: this.translateService.instant('project.metadata.funding.dialog.header'), + width: '600px', + focusOnShow: false, + closeOnEscape: true, + modal: true, + closable: true, + data: { + funders: this.customItemMetadata().funders, + }, + }); + + dialogRef.onClose + .pipe( + filter((result) => !!result && result.fundingEntries), + switchMap((result) => { + const registryId = this.currentRegistry()?.id; + if (registryId) { + const currentMetadata = this.customItemMetadata() || { + language: 'en', + resource_type_general: 'Dataset', + funders: [], + }; + + const updatedMetadata = { + ...currentMetadata, + funders: result.fundingEntries.map( + (entry: { + funderName?: string; + funderIdentifier?: string; + funderIdentifierType?: string; + awardNumber?: string; + awardUri?: string; + awardTitle?: string; + }) => ({ + funder_name: entry.funderName || '', + funder_identifier: entry.funderIdentifier || '', + funder_identifier_type: entry.funderIdentifierType || '', + award_number: entry.awardNumber || '', + award_uri: entry.awardUri || '', + award_title: entry.awardTitle || '', + }) + ), + }; + + return this.actions.updateCustomItemMetadata(registryId, updatedMetadata); + } + + return EMPTY; + }) + ) + .subscribe({ + next: () => this.toastService.showSuccess('project.metadata.funding.updated'), + }); + } + + openEditAffiliatedInstitutionsDialog(): void { + const dialogRef = this.dialogService.open(AffiliatedInstitutionsDialogComponent, { + header: this.translateService.instant('project.metadata.affiliatedInstitutions.dialog.header'), + width: '500px', + focusOnShow: false, + closeOnEscape: true, + modal: true, + closable: true, + data: { + currentProject: this.getCurrentInstanceForTemplate(), + }, + }); + + dialogRef.onClose + .pipe( + filter((result) => !!result), + switchMap((result) => { + const registryId = this.currentRegistry()?.id; + if (registryId) { + const institutionsData = result.map((institutionId: string) => ({ + type: 'institutions', + id: institutionId, + })); + + return this.actions.updateRegistryInstitutions(registryId, institutionsData); + } + return EMPTY; + }) + ) + .subscribe({ + next: () => { + this.toastService.showSuccess('project.metadata.affiliatedInstitutions.updated'); + }, + }); + } + + getSubjectChildren(parentId: string) { + this.actions.fetchChildrenSubjects(parentId); + } + + searchSubjects(search: string) { + this.actions.fetchSubjects(ResourceType.Registration, this.registryId, search, true); + } + + updateSelectedSubjects(subjects: SubjectModel[]) { + const subjectData = subjects.map((subject) => ({ + type: 'subjects', + id: subject.id, + })); + this.actions.updateRegistrySubjects(this.registryId, subjectData); + } + + getCurrentInstanceForTemplate(): ProjectOverview { + const registry = this.currentRegistry(); + const institutions = this.institutions(); + + const institutionsFormatted = + institutions?.map((inst) => ({ + id: inst.id, + name: inst.attributes.name, + })) || []; + + return { + ...registry, + institutions: institutionsFormatted, + } as unknown as ProjectOverview; + } + + getCustomMetadataForTemplate(): CustomItemMetadataRecord { + return this.customItemMetadata() as unknown as CustomItemMetadataRecord; + } + + onTabChange(tabId: string | number): void { + const tab = this.tabs().find((x) => x.id === tabId.toString()); + + if (!tab) { + return; + } + + this.selectedTab.set(tab.id); + + if (tab.type === 'cedar') { + this.loadCedarRecord(tab.id); + + const currentRecordId = this.route.snapshot.paramMap.get('recordId'); + if (currentRecordId !== tab.id) { + this.router.navigate(['metadata', tab.id], { relativeTo: this.route.parent?.parent }); + } + } else { + this.selectedCedarRecord.set(null); + this.selectedCedarTemplate.set(null); + + const currentRecordId = this.route.snapshot.paramMap.get('recordId'); + if (currentRecordId) { + this.router.navigate(['metadata'], { relativeTo: this.route.parent?.parent }); + } + } + } + + onCedarFormEdit(): void { + this.cedarFormReadonly.set(false); + } + + onCedarFormSubmit(data: CedarRecordDataBinding): void { + const registryId = this.currentRegistry()?.id; + const selectedRecord = this.selectedCedarRecord(); + + if (!registryId || !selectedRecord) return; + + const model = CedarFormMapper(data, registryId); + + if (selectedRecord.id) { + this.actions + .updateCedarRecord(model, selectedRecord.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.cedarFormReadonly.set(true); + this.toastService.showSuccess('CEDAR record updated successfully'); + this.actions.getCedarRecords(registryId); + }, + }); + } + } + + onCedarFormChangeTemplate(): void { + this.router.navigate(['add'], { relativeTo: this.route }); + } + + private loadCedarRecord(recordId: string): void { + const records = this.cedarRecords(); + const templates = this.cedarTemplates(); + + if (!records) { + return; + } + + const record = records.find((r) => r.id === recordId); + if (!record) { + return; + } + + this.selectedCedarRecord.set(record); + this.cedarFormReadonly.set(true); + + const templateId = record.relationships?.template?.data?.id; + if (templateId && templates?.data) { + const template = templates.data.find((t) => t.id === templateId); + if (template) { + this.selectedCedarTemplate.set(template); + } else { + this.selectedCedarTemplate.set(null); + this.actions.getCedarTemplates(); + } + } else { + this.selectedCedarTemplate.set(null); + this.actions.getCedarTemplates(); + } + } + + private handleRouteBasedTabSelection(): void { + const recordId = this.route.snapshot.paramMap.get('recordId'); + + if (!recordId) { + this.selectedTab.set('registry'); + this.selectedCedarRecord.set(null); + this.selectedCedarTemplate.set(null); + return; + } + + const tab = this.tabs().find((tab) => tab.id === recordId); + + if (tab) { + this.selectedTab.set(tab.id); + + if (tab.type === 'cedar') { + this.loadCedarRecord(tab.id); + } + } + } + + private refreshContributorsData(): void { + this.actions.getContributors(this.registryId, ResourceType.Registration); + } +} diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index fef5eec8f..e2db7e720 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -3,6 +3,7 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { RegistryFilesState } from '@osf/features/registry/store/registry-files'; +import { RegistryMetadataState } from '@osf/features/registry/store/registry-metadata'; import { RegistryOverviewState } from '@osf/features/registry/store/registry-overview'; import { ResourceType } from '@osf/shared/enums'; import { ContributorsState, ViewOnlyLinkState } from '@osf/shared/stores'; @@ -27,6 +28,26 @@ export const registryRoutes: Routes = [ loadComponent: () => import('./pages/registry-overview/registry-overview.component').then((c) => c.RegistryOverviewComponent), }, + { + path: 'metadata', + loadComponent: () => + import('./pages/registry-metadata/registry-metadata.component').then((c) => c.RegistryMetadataComponent), + providers: [provideStates([RegistryMetadataState])], + }, + { + path: 'metadata/add', + loadComponent: () => + import('./pages/registry-metadata-add/registry-metadata-add.component').then( + (c) => c.RegistryMetadataAddComponent + ), + providers: [provideStates([RegistryMetadataState])], + }, + { + path: 'metadata/:recordId', + loadComponent: () => + import('./pages/registry-metadata/registry-metadata.component').then((c) => c.RegistryMetadataComponent), + providers: [provideStates([RegistryMetadataState])], + }, { path: 'contributors', loadComponent: () => diff --git a/src/app/features/registry/services/index.ts b/src/app/features/registry/services/index.ts index 08edb6ef5..e65075448 100644 --- a/src/app/features/registry/services/index.ts +++ b/src/app/features/registry/services/index.ts @@ -1 +1,2 @@ +export * from './registry-metadata.service'; export * from './registry-overview.service'; diff --git a/src/app/features/registry/services/registry-metadata.service.ts b/src/app/features/registry/services/registry-metadata.service.ts new file mode 100644 index 000000000..d138ba8a8 --- /dev/null +++ b/src/app/features/registry/services/registry-metadata.service.ts @@ -0,0 +1,227 @@ +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@osf/core/services'; +import { + CedarMetadataRecord, + CedarMetadataRecordJsonApi, + CedarMetadataTemplateJsonApi, +} from '@osf/features/project/metadata/models'; +import { License } from '@shared/models'; + +import { RegistryMetadataMapper } from '../mappers'; +import { + BibliographicContributorsJsonApi, + CustomItemMetadataRecord, + CustomItemMetadataResponse, + RegistryContributorAddRequest, + RegistryContributorJsonApiResponse, + RegistryContributorUpdateRequest, + RegistryInstitutionsJsonApiResponse, + RegistryOverview, + RegistrySubjectsJsonApi, + UserInstitutionsResponse, +} from '../models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class RegistryMetadataService { + private readonly jsonApiService = inject(JsonApiService); + private readonly apiUrl = environment.apiUrl; + + getBibliographicContributors( + registryId: string, + page = 1, + pageSize = 100 + ): Observable { + const params: Record = { + 'fields[contributors]': 'index,users', + 'fields[users]': 'full_name', + page: page, + 'page[size]': pageSize, + }; + + return this.jsonApiService.get( + `${this.apiUrl}/registrations/${registryId}/bibliographic_contributors/`, + params + ); + } + + getCustomItemMetadata(guid: string): Observable { + return this.jsonApiService.get(`${this.apiUrl}/custom_item_metadata_records/${guid}/`); + } + + updateCustomItemMetadata(guid: string, metadata: CustomItemMetadataRecord): Observable { + return this.jsonApiService.patch( + `${this.apiUrl}/custom_item_metadata_records/${guid}/`, + { + data: { + id: guid, + type: 'custom-item-metadata-records', + attributes: metadata, + relationships: {}, + }, + } + ); + } + + getRegistryForMetadata(registryId: string): Observable { + const params: Record = { + 'embed[]': ['contributors', 'affiliated_institutions', 'identifiers', 'license', 'subjects_acceptable'], + 'fields[institutions]': 'assets,description,name', + 'fields[users]': 'family_name,full_name,given_name,middle_name', + 'fields[subjects]': 'text,taxonomy', + }; + + return this.jsonApiService + .get<{ data: Record }>(`${environment.apiUrl}/registrations/${registryId}/`, params) + .pipe(map((response) => RegistryMetadataMapper.fromMetadataApiResponse(response.data))); + } + + updateRegistryDetails(registryId: string, updates: Partial>): Observable { + const payload = { + data: { + id: registryId, + type: 'registrations', + attributes: updates, + }, + }; + + return this.jsonApiService + .patch>(`${this.apiUrl}/registrations/${registryId}`, payload) + .pipe(map((response) => RegistryMetadataMapper.fromMetadataApiResponse(response))); + } + + getUserInstitutions(userId: string, page = 1, pageSize = 10): Observable { + const params = { + page: page.toString(), + 'page[size]': pageSize.toString(), + }; + + return this.jsonApiService.get(`${this.apiUrl}/users/${userId}/institutions/`, { + params, + }); + } + + getRegistrySubjects(registryId: string, page = 1, pageSize = 100): Observable { + const params: Record = { + 'page[size]': pageSize, + page: page, + }; + + return this.jsonApiService.get( + `${this.apiUrl}/registrations/${registryId}/subjects/`, + params + ); + } + + getRegistryCedarMetadataRecords(registryId: string): Observable { + const params: Record = { + embed: 'template', + 'page[size]': 20, + }; + + return this.jsonApiService.get( + `${this.apiUrl}/registrations/${registryId}/cedar_metadata_records/`, + params + ); + } + + getCedarMetadataTemplates(url?: string): Observable { + return this.jsonApiService.get( + url || `${environment.apiDomainUrl}/_/cedar_metadata_templates/?adapterOptions[sort]=schema_name` + ); + } + + createCedarMetadataRecord(data: CedarMetadataRecord): Observable { + return this.jsonApiService.post(`${environment.apiDomainUrl}/_/cedar_metadata_records/`, data); + } + + updateCedarMetadataRecord(data: CedarMetadataRecord, recordId: string): Observable { + return this.jsonApiService.patch( + `${environment.apiDomainUrl}/_/cedar_metadata_records/${recordId}/`, + data + ); + } + + updateRegistrySubjects( + registryId: string, + subjects: { type: string; id: string }[] + ): Observable<{ data: { type: string; id: string }[] }> { + return this.jsonApiService.patch<{ data: { type: string; id: string }[] }>( + `${this.apiUrl}/registrations/${registryId}/relationships/subjects/`, + { + data: subjects, + } + ); + } + + updateRegistryInstitutions( + registryId: string, + institutions: { type: string; id: string }[] + ): Observable<{ data: { type: string; id: string }[] }> { + return this.jsonApiService.patch<{ data: { type: string; id: string }[] }>( + `${this.apiUrl}/registrations/${registryId}/relationships/institutions/`, + { + data: institutions, + } + ); + } + + getLicenseFromUrl(licenseUrl: string): Observable { + return this.jsonApiService.get<{ data: Record }>(licenseUrl).pipe( + map((response) => { + const licenseData = response.data; + const attributes = licenseData['attributes'] as Record; + + return { + id: licenseData['id'] as string, + name: attributes['name'] as string, + text: attributes['text'] as string, + url: attributes['url'] as string, + requiredFields: (attributes['required_fields'] as string[]) || [], + } as License; + }) + ); + } + + getRegistryInstitutions( + registryId: string, + page = 1, + pageSize = 100 + ): Observable { + const params: Record = { + 'fields[institutions]': 'name', + page: page, + 'page[size]': pageSize, + }; + + return this.jsonApiService.get(`${this.apiUrl}/registrations/${registryId}/institutions/`, params); + } + + updateRegistryContributor( + registryId: string, + contributorId: string, + updateData: RegistryContributorUpdateRequest + ): Observable { + return this.jsonApiService.patch( + `${this.apiUrl}/registrations/${registryId}/contributors/${contributorId}/`, + updateData + ); + } + + addRegistryContributor( + registryId: string, + contributorData: RegistryContributorAddRequest + ): Observable { + return this.jsonApiService.post( + `${this.apiUrl}/registrations/${registryId}/contributors/`, + contributorData + ); + } +} diff --git a/src/app/features/registry/store/registry-metadata/index.ts b/src/app/features/registry/store/registry-metadata/index.ts new file mode 100644 index 000000000..d73dba1f8 --- /dev/null +++ b/src/app/features/registry/store/registry-metadata/index.ts @@ -0,0 +1,4 @@ +export * from './registry-metadata.actions'; +export * from './registry-metadata.model'; +export * from './registry-metadata.selectors'; +export * from './registry-metadata.state'; diff --git a/src/app/features/registry/store/registry-metadata/registry-metadata.actions.ts b/src/app/features/registry/store/registry-metadata/registry-metadata.actions.ts new file mode 100644 index 000000000..f7cec76ca --- /dev/null +++ b/src/app/features/registry/store/registry-metadata/registry-metadata.actions.ts @@ -0,0 +1,140 @@ +import { CedarMetadataRecord, CedarMetadataRecordData } from '@osf/features/project/metadata/models'; + +import { CustomItemMetadataRecord, RegistryMetadata } from '../../models/registry-metadata.models'; + +export class GetRegistryForMetadata { + static readonly type = '[RegistryMetadata] Get Registry For Metadata'; + constructor(public registryId: string) {} +} + +export class GetBibliographicContributors { + static readonly type = '[RegistryMetadata] Get Bibliographic Contributors'; + constructor( + public registryId: string, + public page?: number, + public pageSize?: number + ) {} +} + +export class GetCustomItemMetadata { + static readonly type = '[RegistryMetadata] Get Custom Item Metadata'; + constructor(public guid: string) {} +} + +export class UpdateCustomItemMetadata { + static readonly type = '[RegistryMetadata] Update Custom Item Metadata'; + constructor( + public guid: string, + public metadata: CustomItemMetadataRecord + ) {} +} + +export class UpdateRegistryDetails { + static readonly type = '[RegistryMetadata] Update Registry Details'; + constructor( + public registryId: string, + public updates: Partial + ) {} +} + +export class GetUserInstitutions { + static readonly type = '[RegistryMetadata] Get User Institutions'; + constructor( + public userId: string, + public page?: number, + public pageSize?: number + ) {} +} + +export class GetRegistrySubjects { + static readonly type = '[RegistryMetadata] Get Registry Subjects'; + constructor( + public registryId: string, + public page?: number, + public pageSize?: number + ) {} +} + +export class UpdateRegistrySubjects { + static readonly type = '[RegistryMetadata] Update Registry Subjects'; + constructor( + public registryId: string, + public subjects: { type: string; id: string }[] + ) {} +} + +export class UpdateRegistryInstitutions { + static readonly type = '[RegistryMetadata] Update Registry Institutions'; + constructor( + public registryId: string, + public institutions: { type: string; id: string }[] + ) {} +} + +export class GetRegistryInstitutions { + static readonly type = '[RegistryMetadata] Get Registry Institutions'; + constructor( + public registryId: string, + public page?: number, + public pageSize?: number + ) {} +} + +export class UpdateRegistryContributor { + static readonly type = '[RegistryMetadata] Update Registry Contributor'; + constructor( + public registryId: string, + public contributorId: string, + public updateData: { + id: string; + type: 'contributors'; + attributes: Record; + relationships: Record; + } + ) {} +} + +export class AddRegistryContributor { + static readonly type = '[RegistryMetadata] Add Registry Contributor'; + constructor( + public registryId: string, + public contributorData: { + type: 'contributors'; + attributes: Record; + relationships: Record; + } + ) {} +} + +export class GetCedarMetadataTemplates { + static readonly type = '[RegistryMetadata] Get Cedar Metadata Templates'; + constructor(public url?: string) {} +} + +export class GetRegistryCedarMetadataRecords { + static readonly type = '[RegistryMetadata] Get Registry Cedar Metadata Records'; + constructor(public registryId: string) {} +} + +export class CreateCedarMetadataRecord { + static readonly type = '[RegistryMetadata] Create Cedar Metadata Record'; + constructor(public record: CedarMetadataRecord) {} +} + +export class UpdateCedarMetadataRecord { + static readonly type = '[RegistryMetadata] Update Cedar Metadata Record'; + constructor( + public record: CedarMetadataRecord, + public recordId: string + ) {} +} + +export class AddCedarMetadataRecordToState { + static readonly type = '[RegistryMetadata] Add Cedar Metadata Record To State'; + constructor(public record: CedarMetadataRecordData) {} +} + +export class GetLicenseFromUrl { + static readonly type = '[RegistryMetadata] Get License From URL'; + constructor(public licenseUrl: string) {} +} diff --git a/src/app/features/registry/store/registry-metadata/registry-metadata.model.ts b/src/app/features/registry/store/registry-metadata/registry-metadata.model.ts new file mode 100644 index 000000000..a55821781 --- /dev/null +++ b/src/app/features/registry/store/registry-metadata/registry-metadata.model.ts @@ -0,0 +1,28 @@ +import { + CedarMetadataRecord, + CedarMetadataRecordData, + CedarMetadataTemplateJsonApi, +} from '@osf/features/project/metadata/models'; +import { AsyncStateModel, License } from '@shared/models'; + +import { + BibliographicContributor, + CustomItemMetadataRecord, + RegistryInstitutionJsonApi, + RegistryOverview, + RegistrySubjectData, + UserInstitution, +} from '../../models'; + +export interface RegistryMetadataStateModel { + registry: AsyncStateModel; + bibliographicContributors: AsyncStateModel; + customItemMetadata: AsyncStateModel; + userInstitutions: AsyncStateModel; + institutions: AsyncStateModel; + subjects: AsyncStateModel; + cedarTemplates: AsyncStateModel; + cedarRecord: AsyncStateModel; + cedarRecords: AsyncStateModel; + license: AsyncStateModel; +} diff --git a/src/app/features/registry/store/registry-metadata/registry-metadata.selectors.ts b/src/app/features/registry/store/registry-metadata/registry-metadata.selectors.ts new file mode 100644 index 000000000..9b05ecc0a --- /dev/null +++ b/src/app/features/registry/store/registry-metadata/registry-metadata.selectors.ts @@ -0,0 +1,106 @@ +import { Selector } from '@ngxs/store'; + +import { RegistryMetadataStateModel } from './registry-metadata.model'; +import { RegistryMetadataState } from './registry-metadata.state'; + +export class RegistryMetadataSelectors { + @Selector([RegistryMetadataState]) + static getRegistry(state: RegistryMetadataStateModel) { + return state.registry.data; + } + + @Selector([RegistryMetadataState]) + static getLicense(state: RegistryMetadataStateModel) { + return state.license.data; + } + + @Selector([RegistryMetadataState]) + static getLicenseLoading(state: RegistryMetadataStateModel) { + return state.license.isLoading; + } + + @Selector([RegistryMetadataState]) + static getRegistryLoading(state: RegistryMetadataStateModel) { + return state.registry.isLoading; + } + + @Selector([RegistryMetadataState]) + static getBibliographicContributors(state: RegistryMetadataStateModel) { + return state.bibliographicContributors.data; + } + + @Selector([RegistryMetadataState]) + static getBibliographicContributorsLoading(state: RegistryMetadataStateModel) { + return state.bibliographicContributors.isLoading; + } + + @Selector([RegistryMetadataState]) + static getCustomItemMetadata(state: RegistryMetadataStateModel) { + return state.customItemMetadata.data; + } + + @Selector([RegistryMetadataState]) + static getInstitutions(state: RegistryMetadataStateModel) { + return state.institutions.data; + } + + @Selector([RegistryMetadataState]) + static getInstitutionsLoading(state: RegistryMetadataStateModel) { + return state.institutions.isLoading; + } + + @Selector([RegistryMetadataState]) + static getCustomItemMetadataLoading(state: RegistryMetadataStateModel) { + return state.customItemMetadata.isLoading; + } + + @Selector([RegistryMetadataState]) + static getUserInstitutions(state: RegistryMetadataStateModel) { + return state.userInstitutions.data; + } + + @Selector([RegistryMetadataState]) + static getUserInstitutionsLoading(state: RegistryMetadataStateModel): boolean { + return state.userInstitutions.isLoading; + } + + @Selector([RegistryMetadataState]) + static getSubjects(state: RegistryMetadataStateModel) { + return state.subjects.data; + } + + @Selector([RegistryMetadataState]) + static getSubjectsLoading(state: RegistryMetadataStateModel) { + return state.subjects.isLoading; + } + + @Selector([RegistryMetadataState]) + static getCedarTemplates(state: RegistryMetadataStateModel) { + return state.cedarTemplates.data; + } + + @Selector([RegistryMetadataState]) + static getCedarTemplatesLoading(state: RegistryMetadataStateModel) { + return state.cedarTemplates.isLoading; + } + + @Selector([RegistryMetadataState]) + static getCedarRecord(state: RegistryMetadataStateModel) { + return state.cedarRecord.data; + } + + @Selector([RegistryMetadataState]) + static getCedarRecordLoading(state: RegistryMetadataStateModel) { + return state.cedarRecord.isLoading; + } + + @Selector([RegistryMetadataState]) + static getCedarRecords(state: RegistryMetadataStateModel) { + return state.cedarRecords.data; + } + + @Selector([RegistryMetadataState]) + static getCedarRecordsLoading(state: RegistryMetadataStateModel) { + return state.cedarRecords.isLoading; + } +} diff --git a/src/app/features/registry/store/registry-metadata/registry-metadata.state.ts b/src/app/features/registry/store/registry-metadata/registry-metadata.state.ts new file mode 100644 index 000000000..d21eeb8e3 --- /dev/null +++ b/src/app/features/registry/store/registry-metadata/registry-metadata.state.ts @@ -0,0 +1,429 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { catchError, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@core/handlers'; +import { CedarMetadataRecord, CedarMetadataRecordJsonApi } from '@osf/features/project/metadata/models'; +import { ResourceType } from '@shared/enums'; +import { GetAllContributors } from '@shared/stores'; + +import { RegistryMetadataMapper } from '../../mappers'; +import { CustomItemMetadataRecord } from '../../models'; +import { RegistryMetadataService } from '../../services/registry-metadata.service'; + +import { + AddCedarMetadataRecordToState, + AddRegistryContributor, + CreateCedarMetadataRecord, + GetBibliographicContributors, + GetCedarMetadataTemplates, + GetCustomItemMetadata, + GetLicenseFromUrl, + GetRegistryCedarMetadataRecords, + GetRegistryForMetadata, + GetRegistryInstitutions, + GetRegistrySubjects, + GetUserInstitutions, + UpdateCedarMetadataRecord, + UpdateCustomItemMetadata, + UpdateRegistryContributor, + UpdateRegistryDetails, + UpdateRegistryInstitutions, + UpdateRegistrySubjects, +} from './registry-metadata.actions'; +import { RegistryMetadataStateModel } from './registry-metadata.model'; + +const initialState: RegistryMetadataStateModel = { + registry: { data: null, isLoading: false, error: null }, + bibliographicContributors: { data: [], isLoading: false, error: null }, + customItemMetadata: { data: {}, isLoading: false, error: null }, + userInstitutions: { data: [], isLoading: false, error: null }, + institutions: { data: [], isLoading: false, error: null }, + subjects: { data: [], isLoading: false, error: null }, + cedarTemplates: { data: null, isLoading: false, error: null }, + cedarRecord: { data: null, isLoading: false, error: null }, + cedarRecords: { data: [], isLoading: false, error: null }, + license: { data: null, isLoading: false, error: null }, +}; + +@State({ + name: 'registryMetadata', + defaults: initialState, +}) +@Injectable() +export class RegistryMetadataState { + private readonly registryMetadataService = inject(RegistryMetadataService); + + @Action(GetRegistryForMetadata) + getRegistryForMetadata(ctx: StateContext, action: GetRegistryForMetadata) { + ctx.patchState({ + registry: { + data: null, + isLoading: true, + error: null, + }, + }); + + return this.registryMetadataService.getRegistryForMetadata(action.registryId).pipe( + tap((registry) => { + ctx.patchState({ + registry: { + data: registry, + isLoading: false, + error: null, + }, + }); + + if (registry.licenseUrl) { + ctx.dispatch(new GetLicenseFromUrl(registry.licenseUrl)); + } + }), + catchError((error) => handleSectionError(ctx, 'registry', error)) + ); + } + + @Action(GetBibliographicContributors) + getBibliographicContributors(ctx: StateContext, action: GetBibliographicContributors) { + ctx.patchState({ + bibliographicContributors: { + data: [], + isLoading: true, + error: null, + }, + }); + + return this.registryMetadataService + .getBibliographicContributors(action.registryId, action.page, action.pageSize) + .pipe( + tap((response) => { + const contributors = RegistryMetadataMapper.mapBibliographicContributors(response); + ctx.patchState({ + bibliographicContributors: { + data: contributors, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'bibliographicContributors', error)) + ); + } + + @Action(GetRegistrySubjects) + getRegistrySubjects(ctx: StateContext, action: GetRegistrySubjects) { + ctx.patchState({ + subjects: { + data: [], + isLoading: true, + error: null, + }, + }); + + return this.registryMetadataService.getRegistrySubjects(action.registryId, action.page, action.pageSize).pipe( + tap((response) => { + ctx.patchState({ + subjects: { + data: response.data, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'subjects', error)) + ); + } + + @Action(GetRegistryInstitutions) + getRegistryInstitutions(ctx: StateContext, action: GetRegistryInstitutions) { + ctx.patchState({ + institutions: { + data: [], + isLoading: true, + error: null, + }, + }); + + return this.registryMetadataService.getRegistryInstitutions(action.registryId, action.page, action.pageSize).pipe( + tap((response) => { + ctx.patchState({ + institutions: { + data: response.data, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'institutions', error)) + ); + } + + @Action(GetCustomItemMetadata) + getCustomItemMetadata(ctx: StateContext, action: GetCustomItemMetadata) { + ctx.patchState({ + customItemMetadata: { data: {}, isLoading: true, error: null }, + }); + + return this.registryMetadataService.getCustomItemMetadata(action.guid).pipe( + tap((response) => { + const metadataAttributes = response?.data?.attributes || (response as unknown as CustomItemMetadataRecord); + + ctx.patchState({ + customItemMetadata: { data: metadataAttributes, isLoading: false, error: null }, + }); + }), + catchError((error) => handleSectionError(ctx, 'customItemMetadata', error)) + ); + } + + @Action(UpdateCustomItemMetadata) + updateCustomItemMetadata(ctx: StateContext, action: UpdateCustomItemMetadata) { + ctx.patchState({ + customItemMetadata: { data: {} as CustomItemMetadataRecord, isLoading: true, error: null }, + }); + + return this.registryMetadataService.updateCustomItemMetadata(action.guid, action.metadata).pipe( + tap((response) => { + const metadataAttributes = response?.data?.attributes || (response as unknown as CustomItemMetadataRecord); + ctx.patchState({ + customItemMetadata: { + data: { ...ctx.getState().customItemMetadata.data, ...metadataAttributes }, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'customItemMetadata', error)) + ); + } + + @Action(UpdateRegistryDetails) + updateRegistryDetails(ctx: StateContext, action: UpdateRegistryDetails) { + ctx.patchState({ + registry: { + ...ctx.getState().registry, + isLoading: true, + error: null, + }, + }); + + return this.registryMetadataService.updateRegistryDetails(action.registryId, action.updates).pipe( + tap((updatedRegistry) => { + const currentRegistry = ctx.getState().registry.data; + + ctx.patchState({ + registry: { + data: { + ...currentRegistry, + ...updatedRegistry, + }, + error: null, + isLoading: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'registry', error)) + ); + } + + @Action(GetUserInstitutions) + getUserInstitutions(ctx: StateContext, action: GetUserInstitutions) { + ctx.patchState({ + userInstitutions: { + data: [], + isLoading: true, + error: null, + }, + }); + + return this.registryMetadataService.getUserInstitutions(action.userId, action.page, action.pageSize).pipe( + tap((response) => { + ctx.patchState({ + userInstitutions: { + data: response.data, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'userInstitutions', error)) + ); + } + + @Action(GetCedarMetadataTemplates) + getCedarMetadataTemplates(ctx: StateContext, action: GetCedarMetadataTemplates) { + ctx.patchState({ + cedarTemplates: { + data: null, + isLoading: true, + error: null, + }, + }); + + return this.registryMetadataService.getCedarMetadataTemplates(action.url).pipe( + tap((response) => { + ctx.patchState({ + cedarTemplates: { + data: response, + error: null, + isLoading: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'cedarTemplates', error)) + ); + } + + @Action(GetRegistryCedarMetadataRecords) + getRegistryCedarMetadataRecords( + ctx: StateContext, + action: GetRegistryCedarMetadataRecords + ) { + ctx.patchState({ + cedarRecords: { + data: [], + isLoading: true, + error: null, + }, + }); + return this.registryMetadataService.getRegistryCedarMetadataRecords(action.registryId).pipe( + tap((response: CedarMetadataRecordJsonApi) => { + ctx.patchState({ + cedarRecords: { + data: response.data, + error: null, + isLoading: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'cedarRecords', error)) + ); + } + + @Action(CreateCedarMetadataRecord) + createCedarMetadataRecord(ctx: StateContext, action: CreateCedarMetadataRecord) { + return this.registryMetadataService.createCedarMetadataRecord(action.record).pipe( + tap((response: CedarMetadataRecord) => { + ctx.dispatch(new AddCedarMetadataRecordToState(response.data)); + }) + ); + } + + @Action(UpdateCedarMetadataRecord) + updateCedarMetadataRecord(ctx: StateContext, action: UpdateCedarMetadataRecord) { + return this.registryMetadataService.updateCedarMetadataRecord(action.record, action.recordId).pipe( + tap((response: CedarMetadataRecord) => { + const state = ctx.getState(); + const updatedRecords = state.cedarRecords.data.map((record) => + record.id === action.recordId ? response.data : record + ); + ctx.patchState({ + cedarRecords: { + data: updatedRecords, + isLoading: false, + error: null, + }, + }); + }) + ); + } + + @Action(AddCedarMetadataRecordToState) + addCedarMetadataRecordToState(ctx: StateContext, action: AddCedarMetadataRecordToState) { + const state = ctx.getState(); + const updatedCedarRecords = [...state.cedarRecords.data, action.record]; + + ctx.setState({ + ...state, + cedarRecords: { + data: updatedCedarRecords, + error: null, + isLoading: false, + }, + }); + } + + @Action(UpdateRegistrySubjects) + updateRegistrySubjects(ctx: StateContext, action: UpdateRegistrySubjects) { + return this.registryMetadataService.updateRegistrySubjects(action.registryId, action.subjects); + } + + @Action(UpdateRegistryInstitutions) + updateRegistryInstitutions(ctx: StateContext, action: UpdateRegistryInstitutions) { + return this.registryMetadataService.updateRegistryInstitutions(action.registryId, action.institutions).pipe( + tap(() => { + ctx.dispatch(new GetRegistryInstitutions(action.registryId)); + }) + ); + } + + @Action(UpdateRegistryContributor) + updateRegistryContributor(ctx: StateContext, action: UpdateRegistryContributor) { + const updateRequest = { + data: action.updateData, + }; + + return this.registryMetadataService + .updateRegistryContributor(action.registryId, action.contributorId, updateRequest) + .pipe( + tap(() => { + ctx.dispatch(new GetBibliographicContributors(action.registryId)); + ctx.dispatch(new GetAllContributors(action.registryId, ResourceType.Registration)); + ctx.dispatch(new GetRegistryForMetadata(action.registryId)); + }) + ); + } + + @Action(AddRegistryContributor) + addRegistryContributor(ctx: StateContext, action: AddRegistryContributor) { + const addRequest = { + data: action.contributorData, + }; + + return this.registryMetadataService.addRegistryContributor(action.registryId, addRequest).pipe( + tap(() => { + ctx.dispatch(new GetBibliographicContributors(action.registryId)); + ctx.dispatch(new GetAllContributors(action.registryId, ResourceType.Registration)); + ctx.dispatch(new GetRegistryForMetadata(action.registryId)); + }) + ); + } + + @Action(GetLicenseFromUrl) + getLicenseFromUrl(ctx: StateContext, action: GetLicenseFromUrl) { + ctx.patchState({ + license: { + data: null, + isLoading: true, + error: null, + }, + }); + + return this.registryMetadataService.getLicenseFromUrl(action.licenseUrl).pipe( + tap((license) => { + ctx.patchState({ + license: { + data: license, + isLoading: false, + error: null, + }, + }); + + const currentRegistry = ctx.getState().registry.data; + if (currentRegistry) { + ctx.patchState({ + registry: { + ...ctx.getState().registry, + data: { + ...currentRegistry, + license: license, + }, + }, + }); + } + }), + catchError((error) => handleSectionError(ctx, 'license', error)) + ); + } +} diff --git a/src/app/shared/components/license/license.component.html b/src/app/shared/components/license/license.component.html index 8fd8dcadf..fb87b212e 100644 --- a/src/app/shared/components/license/license.component.html +++ b/src/app/shared/components/license/license.component.html @@ -3,6 +3,8 @@ [(ngModel)]="selectedLicense" optionLabel="name" styleClass="" + appendTo="body" + [class.md:w-full]="fullWidthSelect()" [placeholder]="'shared.license.selectLicense' | translate" (onChange)="onSelectLicense($event.value)" class="mt-4 w-full md:w-6" @@ -35,7 +37,7 @@

- @if (selectedLicense()!.requiredFields.length) { + @if (selectedLicense()!.requiredFields.length && showInternalButtons()) {
(null); licenses = input.required(); isSubmitting = input(false); + showInternalButtons = input(true); + fullWidthSelect = input(false); selectedLicense = model(null); createLicense = output<{ id: string; licenseOptions: LicenseOptions }>(); selectLicense = output(); diff --git a/src/app/features/project/metadata/components/cedar-template-form/cedar-template-form.component.html b/src/app/shared/components/shared-metadata/components/cedar-template-form/cedar-template-form.component.html similarity index 100% rename from src/app/features/project/metadata/components/cedar-template-form/cedar-template-form.component.html rename to src/app/shared/components/shared-metadata/components/cedar-template-form/cedar-template-form.component.html diff --git a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.scss b/src/app/shared/components/shared-metadata/components/cedar-template-form/cedar-template-form.component.scss similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.scss rename to src/app/shared/components/shared-metadata/components/cedar-template-form/cedar-template-form.component.scss diff --git a/src/app/features/project/metadata/components/cedar-template-form/cedar-template-form.component.ts b/src/app/shared/components/shared-metadata/components/cedar-template-form/cedar-template-form.component.ts similarity index 100% rename from src/app/features/project/metadata/components/cedar-template-form/cedar-template-form.component.ts rename to src/app/shared/components/shared-metadata/components/cedar-template-form/cedar-template-form.component.ts diff --git a/src/app/features/project/metadata/components/index.ts b/src/app/shared/components/shared-metadata/components/index.ts similarity index 100% rename from src/app/features/project/metadata/components/index.ts rename to src/app/shared/components/shared-metadata/components/index.ts diff --git a/src/app/features/project/metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.html b/src/app/shared/components/shared-metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.html similarity index 72% rename from src/app/features/project/metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.html rename to src/app/shared/components/shared-metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.html index 9abbe7fb7..af6b5d311 100644 --- a/src/app/features/project/metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.html +++ b/src/app/shared/components/shared-metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.html @@ -2,16 +2,18 @@

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

- + @if (!readonly()) { + + }
- @if (currentProject()?.affiliatedInstitutions?.length) { + @if (affiliatedInstitutions()) {
- @for (institution of currentProject()!.affiliatedInstitutions!; track institution.id) { + @for (institution of affiliatedInstitutions(); track institution.id) {
@if (institution.logo) { diff --git a/src/app/features/project/metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.spec.ts b/src/app/shared/components/shared-metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.spec.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.spec.ts diff --git a/src/app/features/project/metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.ts b/src/app/shared/components/shared-metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.ts similarity index 75% rename from src/app/features/project/metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.ts index d06dce556..fe0c2fa0d 100644 --- a/src/app/features/project/metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.ts +++ b/src/app/shared/components/shared-metadata/components/project-metadata-affiliated-institutions/project-metadata-affiliated-institutions.component.ts @@ -5,7 +5,7 @@ import { Card } from 'primeng/card'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { ProjectOverview } from '@osf/features/project/overview/models'; +import { ProjectAffiliatedInstitutions } from '@osf/features/project/overview/models'; @Component({ selector: 'osf-project-metadata-affiliated-institutions', @@ -16,5 +16,6 @@ import { ProjectOverview } from '@osf/features/project/overview/models'; export class ProjectMetadataAffiliatedInstitutionsComponent { openEditAffiliatedInstitutionsDialog = output(); - currentProject = input.required(); + affiliatedInstitutions = input([]); + readonly = input(false); } diff --git a/src/app/features/project/metadata/components/project-metadata-contributors/project-metadata-contributors.component.html b/src/app/shared/components/shared-metadata/components/project-metadata-contributors/project-metadata-contributors.component.html similarity index 57% rename from src/app/features/project/metadata/components/project-metadata-contributors/project-metadata-contributors.component.html rename to src/app/shared/components/shared-metadata/components/project-metadata-contributors/project-metadata-contributors.component.html index 77dbe94ba..ca74df140 100644 --- a/src/app/features/project/metadata/components/project-metadata-contributors/project-metadata-contributors.component.html +++ b/src/app/shared/components/shared-metadata/components/project-metadata-contributors/project-metadata-contributors.component.html @@ -2,16 +2,18 @@

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

- + @if (!readonly()) { + + }
- @if (currentProject()?.contributors) { + @if (contributors()) {
- @for (contributor of currentProject()?.contributors; track contributor.id) { + @for (contributor of contributors(); track contributor.id) {
{{ contributor.fullName }} {{ $last ? '' : ',' }} diff --git a/src/app/features/project/metadata/components/project-metadata-contributors/project-metadata-contributors.component.spec.ts b/src/app/shared/components/shared-metadata/components/project-metadata-contributors/project-metadata-contributors.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-contributors/project-metadata-contributors.component.spec.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-contributors/project-metadata-contributors.component.spec.ts diff --git a/src/app/features/project/metadata/components/project-metadata-contributors/project-metadata-contributors.component.ts b/src/app/shared/components/shared-metadata/components/project-metadata-contributors/project-metadata-contributors.component.ts similarity index 75% rename from src/app/features/project/metadata/components/project-metadata-contributors/project-metadata-contributors.component.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-contributors/project-metadata-contributors.component.ts index 4c7768c3e..994ace939 100644 --- a/src/app/features/project/metadata/components/project-metadata-contributors/project-metadata-contributors.component.ts +++ b/src/app/shared/components/shared-metadata/components/project-metadata-contributors/project-metadata-contributors.component.ts @@ -5,7 +5,7 @@ import { Card } from 'primeng/card'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { ProjectOverview } from '@osf/features/project/overview/models'; +import { ProjectOverviewContributor } from '@osf/features/project/overview/models'; @Component({ selector: 'osf-project-metadata-contributors', @@ -15,6 +15,6 @@ import { ProjectOverview } from '@osf/features/project/overview/models'; }) export class ProjectMetadataContributorsComponent { openEditContributorDialog = output(); - - currentProject = input.required(); + contributors = input([]); + readonly = input(false); } diff --git a/src/app/features/project/metadata/components/project-metadata-description/project-metadata-description.component.html b/src/app/shared/components/shared-metadata/components/project-metadata-description/project-metadata-description.component.html similarity index 52% rename from src/app/features/project/metadata/components/project-metadata-description/project-metadata-description.component.html rename to src/app/shared/components/shared-metadata/components/project-metadata-description/project-metadata-description.component.html index 81a97cfc0..d682bd318 100644 --- a/src/app/features/project/metadata/components/project-metadata-description/project-metadata-description.component.html +++ b/src/app/shared/components/shared-metadata/components/project-metadata-description/project-metadata-description.component.html @@ -2,15 +2,17 @@

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

- + @if (!readonly()) { + + }
- @if (currentProject()?.description) { -

{{ currentProject()?.description }}

+ @if (description()) { +

{{ description() }}

} @else {

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

} diff --git a/src/app/features/project/metadata/components/project-metadata-description/project-metadata-description.component.spec.ts b/src/app/shared/components/shared-metadata/components/project-metadata-description/project-metadata-description.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-description/project-metadata-description.component.spec.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-description/project-metadata-description.component.spec.ts diff --git a/src/app/features/project/metadata/components/project-metadata-description/project-metadata-description.component.ts b/src/app/shared/components/shared-metadata/components/project-metadata-description/project-metadata-description.component.ts similarity index 79% rename from src/app/features/project/metadata/components/project-metadata-description/project-metadata-description.component.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-description/project-metadata-description.component.ts index 8f5f35a17..d28dbd765 100644 --- a/src/app/features/project/metadata/components/project-metadata-description/project-metadata-description.component.ts +++ b/src/app/shared/components/shared-metadata/components/project-metadata-description/project-metadata-description.component.ts @@ -5,8 +5,6 @@ import { Card } from 'primeng/card'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { ProjectOverview } from '@osf/features/project/overview/models'; - @Component({ selector: 'osf-project-metadata-description', imports: [Card, Button, TranslatePipe], @@ -15,6 +13,6 @@ import { ProjectOverview } from '@osf/features/project/overview/models'; }) export class ProjectMetadataDescriptionComponent { openEditDescriptionDialog = output(); - - currentProject = input.required(); + description = input.required(); + readonly = input(false); } diff --git a/src/app/features/project/metadata/components/project-metadata-doi/project-metadata-doi.component.html b/src/app/shared/components/shared-metadata/components/project-metadata-doi/project-metadata-doi.component.html similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-doi/project-metadata-doi.component.html rename to src/app/shared/components/shared-metadata/components/project-metadata-doi/project-metadata-doi.component.html diff --git a/src/app/features/project/metadata/components/project-metadata-doi/project-metadata-doi.component.spec.ts b/src/app/shared/components/shared-metadata/components/project-metadata-doi/project-metadata-doi.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-doi/project-metadata-doi.component.spec.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-doi/project-metadata-doi.component.spec.ts diff --git a/src/app/features/project/metadata/components/project-metadata-doi/project-metadata-doi.component.ts b/src/app/shared/components/shared-metadata/components/project-metadata-doi/project-metadata-doi.component.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-doi/project-metadata-doi.component.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-doi/project-metadata-doi.component.ts diff --git a/src/app/shared/components/shared-metadata/components/project-metadata-funding/project-metadata-funding.component.html b/src/app/shared/components/shared-metadata/components/project-metadata-funding/project-metadata-funding.component.html new file mode 100644 index 000000000..b551468ff --- /dev/null +++ b/src/app/shared/components/shared-metadata/components/project-metadata-funding/project-metadata-funding.component.html @@ -0,0 +1,46 @@ + +
+

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

+ + @if (!readonly()) { + + } +
+ + @if (funders()) { +
+ @for (funder of funders(); track funder.funder_identifier) { +
+

{{ 'project.files.detail.projectMetadata.fields.funder' | translate }}: {{ funder.funder_name }}

+ +

{{ 'project.files.detail.projectMetadata.fields.awardTitle' | translate }}: {{ funder.award_title }}

+ + @if (funder.award_uri) { +

+ {{ 'project.files.detail.projectMetadata.fields.awardUri' | translate }}: + {{ funder.award_title }} +

+ } + + @if (funder.award_number) { +

+ {{ 'project.files.detail.projectMetadata.fields.awardNumber' | translate }} : {{ funder.award_number }} +

+ } + + @if (funder.award_uri) { + {{ funder.award_uri }} + } +
+ } +
+ } @else { +
+

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

+
+ } +
diff --git a/src/app/features/project/metadata/components/project-metadata-funding/project-metadata-funding.component.spec.ts b/src/app/shared/components/shared-metadata/components/project-metadata-funding/project-metadata-funding.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-funding/project-metadata-funding.component.spec.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-funding/project-metadata-funding.component.spec.ts diff --git a/src/app/features/project/metadata/components/project-metadata-funding/project-metadata-funding.component.ts b/src/app/shared/components/shared-metadata/components/project-metadata-funding/project-metadata-funding.component.ts similarity index 67% rename from src/app/features/project/metadata/components/project-metadata-funding/project-metadata-funding.component.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-funding/project-metadata-funding.component.ts index 95d90c996..434a3a783 100644 --- a/src/app/features/project/metadata/components/project-metadata-funding/project-metadata-funding.component.ts +++ b/src/app/shared/components/shared-metadata/components/project-metadata-funding/project-metadata-funding.component.ts @@ -3,19 +3,19 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { ProjectOverview } from '@osf/features/project/overview/models'; +import { Funder } from '@osf/features/project/metadata/models'; @Component({ selector: 'osf-project-metadata-funding', - imports: [Button, Card, TranslatePipe, DatePipe], + imports: [Button, Card, TranslatePipe], templateUrl: './project-metadata-funding.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProjectMetadataFundingComponent { openEditFundingDialog = output(); - currentProject = input.required(); + funders = input([]); + readonly = input(false); } diff --git a/src/app/features/project/metadata/components/project-metadata-license/project-metadata-license.component.html b/src/app/shared/components/shared-metadata/components/project-metadata-license/project-metadata-license.component.html similarity index 57% rename from src/app/features/project/metadata/components/project-metadata-license/project-metadata-license.component.html rename to src/app/shared/components/shared-metadata/components/project-metadata-license/project-metadata-license.component.html index 16df1fe0a..53bca92a9 100644 --- a/src/app/features/project/metadata/components/project-metadata-license/project-metadata-license.component.html +++ b/src/app/shared/components/shared-metadata/components/project-metadata-license/project-metadata-license.component.html @@ -2,16 +2,18 @@

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

- + @if (!hideEditLicense()) { + + }
- @if (currentProject()?.license) { + @if (license()) {
-

{{ currentProject()?.license }}

+

{{ license().name }}

} @else {
diff --git a/src/app/features/project/metadata/components/project-metadata-license/project-metadata-license.component.spec.ts b/src/app/shared/components/shared-metadata/components/project-metadata-license/project-metadata-license.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-license/project-metadata-license.component.spec.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-license/project-metadata-license.component.spec.ts diff --git a/src/app/features/project/metadata/components/project-metadata-license/project-metadata-license.component.ts b/src/app/shared/components/shared-metadata/components/project-metadata-license/project-metadata-license.component.ts similarity index 79% rename from src/app/features/project/metadata/components/project-metadata-license/project-metadata-license.component.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-license/project-metadata-license.component.ts index dcfb48bbc..23e031ff4 100644 --- a/src/app/features/project/metadata/components/project-metadata-license/project-metadata-license.component.ts +++ b/src/app/shared/components/shared-metadata/components/project-metadata-license/project-metadata-license.component.ts @@ -5,7 +5,7 @@ import { Card } from 'primeng/card'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { ProjectOverview } from '@osf/features/project/overview/models'; +import { License } from '@shared/models'; @Component({ selector: 'osf-project-metadata-license', @@ -15,6 +15,6 @@ import { ProjectOverview } from '@osf/features/project/overview/models'; }) export class ProjectMetadataLicenseComponent { openEditLicenseDialog = output(); - - currentProject = input.required(); + hideEditLicense = input(false); + license = input({} as License); } diff --git a/src/app/features/project/metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.html b/src/app/shared/components/shared-metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.html similarity index 59% rename from src/app/features/project/metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.html rename to src/app/shared/components/shared-metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.html index 1ddbe9779..928046214 100644 --- a/src/app/features/project/metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.html +++ b/src/app/shared/components/shared-metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.html @@ -2,16 +2,18 @@

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

- + @if (!hideEditDoi()) { + + }
- @if (currentProject()?.identifiers?.length) { + @if (identifiers() && identifiers().length) {
- @for (identifier of currentProject()!.identifiers!; track identifier.id) { + @for (identifier of identifiers()!; track identifier.id) { @if (identifier.category === 'doi') {

{{ identifier.value }}

} diff --git a/src/app/features/project/metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.spec.ts b/src/app/shared/components/shared-metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.spec.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.spec.ts diff --git a/src/app/features/project/metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.ts b/src/app/shared/components/shared-metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.ts similarity index 77% rename from src/app/features/project/metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.ts index f3e4f87dd..fc06cc2fe 100644 --- a/src/app/features/project/metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.ts +++ b/src/app/shared/components/shared-metadata/components/project-metadata-publication-doi/project-metadata-publication-doi.component.ts @@ -5,7 +5,7 @@ import { Card } from 'primeng/card'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { ProjectOverview } from '@osf/features/project/overview/models'; +import { ProjectIdentifiers } from '@osf/features/project/overview/models'; @Component({ selector: 'osf-project-metadata-publication-doi', @@ -16,5 +16,6 @@ import { ProjectOverview } from '@osf/features/project/overview/models'; export class ProjectMetadataPublicationDoiComponent { openEditPublicationDoiDialog = output(); - currentProject = input.required(); + identifiers = input([]); + hideEditDoi = input(false); } diff --git a/src/app/features/project/metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.html b/src/app/shared/components/shared-metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.html similarity index 59% rename from src/app/features/project/metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.html rename to src/app/shared/components/shared-metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.html index 18c70e4fc..77b345e2b 100644 --- a/src/app/features/project/metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.html +++ b/src/app/shared/components/shared-metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.html @@ -2,23 +2,25 @@

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

- + @if (!readonly()) { + + }
- @if (customItemMetadata()?.resource_type_general) { + @if (customItemMetadata().resource_type_general) {

{{ 'project.overview.metadata.resourceType' | translate }}: - {{ customItemMetadata()?.resource_type_general | titlecase }} + {{ customItemMetadata().resource_type_general | titlecase }}

{{ 'project.overview.metadata.resourceLanguage' | translate }}: - {{ getLanguageName(customItemMetadata()?.language || '') }} + {{ getLanguageName(customItemMetadata().language || '') }}

} @else { diff --git a/src/app/features/project/metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.spec.ts b/src/app/shared/components/shared-metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.spec.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.spec.ts diff --git a/src/app/features/project/metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.ts b/src/app/shared/components/shared-metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.ts similarity index 91% rename from src/app/features/project/metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.ts index 64f17e4e2..0047146bc 100644 --- a/src/app/features/project/metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.ts +++ b/src/app/shared/components/shared-metadata/components/project-metadata-resource-information/project-metadata-resource-information.component.ts @@ -19,7 +19,8 @@ import { LanguageCodeModel } from '@shared/models'; export class ProjectMetadataResourceInformationComponent { openEditResourceInformationDialog = output(); - customItemMetadata = input.required(); + customItemMetadata = input.required(); + readonly = input(false); protected readonly languageCodes = languageCodes; getLanguageName(languageCode: string): string { diff --git a/src/app/shared/components/shared-metadata/components/project-metadata-subjects/project-metadata-subjects.component.html b/src/app/shared/components/shared-metadata/components/project-metadata-subjects/project-metadata-subjects.component.html new file mode 100644 index 000000000..75aaa17af --- /dev/null +++ b/src/app/shared/components/shared-metadata/components/project-metadata-subjects/project-metadata-subjects.component.html @@ -0,0 +1,10 @@ + + + diff --git a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.spec.ts b/src/app/shared/components/shared-metadata/components/project-metadata-subjects/project-metadata-subjects.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.spec.ts rename to src/app/shared/components/shared-metadata/components/project-metadata-subjects/project-metadata-subjects.component.spec.ts diff --git a/src/app/shared/components/shared-metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts b/src/app/shared/components/shared-metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts new file mode 100644 index 000000000..a820c6915 --- /dev/null +++ b/src/app/shared/components/shared-metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts @@ -0,0 +1,22 @@ +import { Card } from 'primeng/card'; + +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +import { SubjectModel } from '@osf/shared/models'; +import { SubjectsComponent } from '@shared/components'; + +@Component({ + selector: 'osf-project-metadata-subjects', + imports: [SubjectsComponent, Card], + templateUrl: './project-metadata-subjects.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectMetadataSubjectsComponent { + selectedSubjects = input.required(); + isSubjectsUpdating = input.required(); + readonly = input(false); + + getSubjectChildren = output(); + searchSubjects = output(); + updateSelectedSubjects = output(); +} diff --git a/src/app/features/project/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.html b/src/app/shared/components/shared-metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.html similarity index 100% rename from src/app/features/project/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.html rename to src/app/shared/components/shared-metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.html diff --git a/src/app/features/project/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts b/src/app/shared/components/shared-metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts rename to src/app/shared/components/shared-metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts diff --git a/src/app/features/project/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts b/src/app/shared/components/shared-metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts rename to src/app/shared/components/shared-metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts diff --git a/src/app/features/project/metadata/dialogs/contributors-dialog/contributors-dialog.component.html b/src/app/shared/components/shared-metadata/dialogs/contributors-dialog/contributors-dialog.component.html similarity index 100% rename from src/app/features/project/metadata/dialogs/contributors-dialog/contributors-dialog.component.html rename to src/app/shared/components/shared-metadata/dialogs/contributors-dialog/contributors-dialog.component.html diff --git a/src/app/features/project/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts b/src/app/shared/components/shared-metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts rename to src/app/shared/components/shared-metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts diff --git a/src/app/features/project/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts b/src/app/shared/components/shared-metadata/dialogs/contributors-dialog/contributors-dialog.component.ts similarity index 92% rename from src/app/features/project/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts rename to src/app/shared/components/shared-metadata/dialogs/contributors-dialog/contributors-dialog.component.ts index 4621dc55d..c563599cf 100644 --- a/src/app/features/project/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts +++ b/src/app/shared/components/shared-metadata/dialogs/contributors-dialog/contributors-dialog.component.ts @@ -55,11 +55,14 @@ export class ContributorsDialogComponent implements OnInit { addContributor: AddContributor, }); + private readonly resourceType: ResourceType; private readonly projectId: string; constructor() { this.projectId = this.config.data?.projectId; + this.resourceType = this.config.data?.['isRegistry'] ? ResourceType.Registration : ResourceType.Project; + this.contributors.set(this.config.data?.contributors || []); this.isContributorsLoading.set(this.config.data?.isLoading || false); } @@ -91,7 +94,7 @@ export class ContributorsDialogComponent implements OnInit { .subscribe((res: ContributorDialogAddModel) => { if (res?.type === AddContributorType.Registered) { const addRequests = res.data.map((payload) => - this.actions.addContributor(this.projectId, ResourceType.Project, payload) + this.actions.addContributor(this.projectId, this.resourceType, payload) ); forkJoin(addRequests).subscribe(() => { @@ -104,7 +107,7 @@ export class ContributorsDialogComponent implements OnInit { removeContributor(contributor: ContributorModel): void { this.actions - .deleteContributor(this.projectId, ResourceType.Project, contributor.userId) + .deleteContributor(this.projectId, this.resourceType, contributor.userId) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { diff --git a/src/app/features/project/metadata/dialogs/description-dialog/description-dialog.component.html b/src/app/shared/components/shared-metadata/dialogs/description-dialog/description-dialog.component.html similarity index 100% rename from src/app/features/project/metadata/dialogs/description-dialog/description-dialog.component.html rename to src/app/shared/components/shared-metadata/dialogs/description-dialog/description-dialog.component.html diff --git a/src/app/features/project/metadata/dialogs/description-dialog/description-dialog.component.scss b/src/app/shared/components/shared-metadata/dialogs/description-dialog/description-dialog.component.scss similarity index 100% rename from src/app/features/project/metadata/dialogs/description-dialog/description-dialog.component.scss rename to src/app/shared/components/shared-metadata/dialogs/description-dialog/description-dialog.component.scss diff --git a/src/app/features/project/metadata/dialogs/description-dialog/description-dialog.component.spec.ts b/src/app/shared/components/shared-metadata/dialogs/description-dialog/description-dialog.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/description-dialog/description-dialog.component.spec.ts rename to src/app/shared/components/shared-metadata/dialogs/description-dialog/description-dialog.component.spec.ts diff --git a/src/app/features/project/metadata/dialogs/description-dialog/description-dialog.component.ts b/src/app/shared/components/shared-metadata/dialogs/description-dialog/description-dialog.component.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/description-dialog/description-dialog.component.ts rename to src/app/shared/components/shared-metadata/dialogs/description-dialog/description-dialog.component.ts diff --git a/src/app/features/project/metadata/dialogs/funding-dialog/funding-dialog.component.html b/src/app/shared/components/shared-metadata/dialogs/funding-dialog/funding-dialog.component.html similarity index 98% rename from src/app/features/project/metadata/dialogs/funding-dialog/funding-dialog.component.html rename to src/app/shared/components/shared-metadata/dialogs/funding-dialog/funding-dialog.component.html index 2e99915c7..0b563bc46 100644 --- a/src/app/features/project/metadata/dialogs/funding-dialog/funding-dialog.component.html +++ b/src/app/shared/components/shared-metadata/dialogs/funding-dialog/funding-dialog.component.html @@ -63,6 +63,7 @@ severity="secondary" [label]="'common.buttons.addMore' | translate" [text]="true" + [disabled]="fundingForm.invalid" (click)="addFundingEntry()" />
diff --git a/src/app/features/project/metadata/dialogs/funding-dialog/funding-dialog.component.spec.ts b/src/app/shared/components/shared-metadata/dialogs/funding-dialog/funding-dialog.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/funding-dialog/funding-dialog.component.spec.ts rename to src/app/shared/components/shared-metadata/dialogs/funding-dialog/funding-dialog.component.spec.ts diff --git a/src/app/features/project/metadata/dialogs/funding-dialog/funding-dialog.component.ts b/src/app/shared/components/shared-metadata/dialogs/funding-dialog/funding-dialog.component.ts similarity index 84% rename from src/app/features/project/metadata/dialogs/funding-dialog/funding-dialog.component.ts rename to src/app/shared/components/shared-metadata/dialogs/funding-dialog/funding-dialog.component.ts index a6b8a5b96..e0c72cb6b 100644 --- a/src/app/features/project/metadata/dialogs/funding-dialog/funding-dialog.component.ts +++ b/src/app/shared/components/shared-metadata/dialogs/funding-dialog/funding-dialog.component.ts @@ -13,18 +13,16 @@ import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { ProjectMetadataSelectors } from '@osf/features/project/metadata/store'; -import { ProjectOverview } from '@osf/features/project/overview/models'; - import { + Funder, FunderOption, FundingDialogResult, FundingEntryData, FundingEntryForm, FundingForm, SupplementData, -} from '../../models'; -import { GetFundersList } from '../../store/project-metadata.actions'; +} from '@osf/features/project/metadata/models'; +import { GetFundersList, ProjectMetadataSelectors } from '@osf/features/project/metadata/store'; @Component({ selector: 'osf-funding-dialog', @@ -71,10 +69,6 @@ export class FundingDialogComponent implements OnInit { }); } - get currentProject(): ProjectOverview | null { - return this.config.data ? this.config.data.currentProject || null : null; - } - get fundingEntries() { return this.fundingForm.get('fundingEntries') as FormArray>; } @@ -82,7 +76,21 @@ export class FundingDialogComponent implements OnInit { ngOnInit(): void { this.actions.getFundersList(); - this.addFundingEntry(); + const configFunders = this.config.data?.funders; + if (configFunders && configFunders.length > 0) { + configFunders.forEach((funder: Funder) => { + this.addFundingEntry({ + funderName: funder.funder_name || '', + funderIdentifier: funder.funder_identifier || '', + funderIdentifierType: funder.funder_identifier_type || 'DOI', + awardTitle: funder.award_title || '', + awardUri: funder.award_uri || '', + awardNumber: funder.award_number || '', + }); + }); + } else { + this.addFundingEntry(); + } this.searchSubject .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) @@ -109,6 +117,7 @@ export class FundingDialogComponent implements OnInit { }), awardTitle: new FormControl(supplement ? supplement.title || supplement.awardTitle || '' : '', { nonNullable: true, + validators: [Validators.required], }), awardUri: new FormControl(supplement ? supplement.url || supplement.awardUri || '' : '', { nonNullable: true, @@ -138,8 +147,8 @@ export class FundingDialogComponent implements OnInit { const entry = this.fundingEntries.at(index); entry.patchValue({ funderName: selectedFunder.name, - funderIdentifier: selectedFunder.id, - funderIdentifierType: 'DOI', + funderIdentifier: selectedFunder.uri, + funderIdentifierType: 'Crossref Funder ID', }); } } @@ -152,7 +161,6 @@ export class FundingDialogComponent implements OnInit { const result: FundingDialogResult = { fundingEntries: fundingData, - projectId: this.currentProject ? this.currentProject.id : undefined, }; this.dialogRef.close(result); diff --git a/src/app/features/project/metadata/dialogs/index.ts b/src/app/shared/components/shared-metadata/dialogs/index.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/index.ts rename to src/app/shared/components/shared-metadata/dialogs/index.ts diff --git a/src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.html b/src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.html new file mode 100644 index 000000000..719354bb9 --- /dev/null +++ b/src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.html @@ -0,0 +1,26 @@ +
+ @if (licensesLoading()) { + + } @else { +
+

{{ 'project.metadata.license.dialog.chooseLicense.label' | translate }}

+ + +
+ } + +
+ + +
+
diff --git a/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.scss b/src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.scss similarity index 100% rename from src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.scss rename to src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.scss diff --git a/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.spec.ts b/src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.spec.ts rename to src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.spec.ts diff --git a/src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.ts b/src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.ts new file mode 100644 index 000000000..dcd03f48e --- /dev/null +++ b/src/app/shared/components/shared-metadata/dialogs/license-dialog/license-dialog.component.ts @@ -0,0 +1,103 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, inject, OnInit, signal, viewChild } from '@angular/core'; + +import { ProjectOverview } from '@osf/features/project/overview/models'; +import { LicenseComponent, LoadingSpinnerComponent } from '@osf/shared/components'; +import { License, LicenseOptions } from '@shared/models'; +import { LicensesSelectors, LoadAllLicenses } from '@shared/stores/licenses'; + +@Component({ + selector: 'osf-license-dialog', + imports: [Button, TranslatePipe, LoadingSpinnerComponent, LicenseComponent], + templateUrl: './license-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LicenseDialogComponent implements OnInit { + protected dialogRef = inject(DynamicDialogRef); + protected config = inject(DynamicDialogConfig); + + protected actions = createDispatchMap({ + loadLicenses: LoadAllLicenses, + }); + + licenses = select(LicensesSelectors.getLicenses); + licensesLoading = select(LicensesSelectors.getLoading); + + selectedLicenseId = signal(null); + selectedLicenseOptions = signal(null); + currentProject: ProjectOverview | null = null; + isSubmitting = signal(false); + + licenseComponent = viewChild('licenseComponent'); + + ngOnInit(): void { + this.actions.loadLicenses(); + this.currentProject = this.config.data?.currentProject || null; + if (this.currentProject?.license) { + this.selectedLicenseId.set(this.currentProject.license.id || null); + if (this.currentProject.nodeLicense) { + this.selectedLicenseOptions.set({ + copyrightHolders: this.currentProject.nodeLicense.copyrightHolders?.join(', ') || '', + year: this.currentProject.nodeLicense.year || new Date().getFullYear().toString(), + }); + } + } + } + + onSelectLicense(license: License): void { + this.selectedLicenseId.set(license.id); + } + + onCreateLicense(event: { id: string; licenseOptions: LicenseOptions }): void { + const selectedLicense = this.licenses().find((license) => license.id === event.id); + + if (selectedLicense) { + this.dialogRef.close({ + licenseName: selectedLicense.name, + licenseId: selectedLicense.id, + licenseOptions: event.licenseOptions, + projectId: this.currentProject?.id, + }); + } + + this.isSubmitting.set(false); + } + + save(): void { + if ( + this.licenseComponent()?.selectedLicense()!.requiredFields.length && + this.licenseComponent()?.licenseForm.invalid + ) { + return; + } + + const selectedLicenseId = this.selectedLicenseId(); + if (!selectedLicenseId) return; + + const selectedLicense = this.licenses().find((license) => license.id === selectedLicenseId); + if (!selectedLicense) return; + + this.isSubmitting.set(true); + + if (selectedLicense.requiredFields?.length) { + this.licenseComponent()?.saveLicense(); + } else { + this.dialogRef.close({ + licenseName: selectedLicense.name, + licenseId: selectedLicense.id, + projectId: this.currentProject?.id, + }); + } + } + + cancel(): void { + this.licenseComponent()?.cancel(); + this.dialogRef.close(); + } +} diff --git a/src/app/features/project/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.html b/src/app/shared/components/shared-metadata/dialogs/resource-information-dialog/resource-information-dialog.component.html similarity index 100% rename from src/app/features/project/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.html rename to src/app/shared/components/shared-metadata/dialogs/resource-information-dialog/resource-information-dialog.component.html diff --git a/src/app/features/project/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.spec.ts b/src/app/shared/components/shared-metadata/dialogs/resource-information-dialog/resource-information-dialog.component.spec.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.spec.ts rename to src/app/shared/components/shared-metadata/dialogs/resource-information-dialog/resource-information-dialog.component.spec.ts diff --git a/src/app/features/project/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.ts b/src/app/shared/components/shared-metadata/dialogs/resource-information-dialog/resource-information-dialog.component.ts similarity index 100% rename from src/app/features/project/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.ts rename to src/app/shared/components/shared-metadata/dialogs/resource-information-dialog/resource-information-dialog.component.ts diff --git a/src/app/shared/components/shared-metadata/shared-metadata.component.html b/src/app/shared/components/shared-metadata/shared-metadata.component.html new file mode 100644 index 000000000..f6f5b42d3 --- /dev/null +++ b/src/app/shared/components/shared-metadata/shared-metadata.component.html @@ -0,0 +1,92 @@ +
+
+ +
+
+

+ {{ 'project.overview.metadata.dateCreated' | translate }} +

+ +

+ {{ currentInstance().dateCreated | date: 'MMM d, y, h:mm a' }} +

+
+ +
+

+ {{ 'project.overview.metadata.dateUpdated' | translate }} +

+ +

+ {{ currentInstance().dateModified | date: 'MMM d, y, h:mm a' }} +

+
+
+
+ + + + + + + + + + +
+ +
+ + + + + +
+

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

+ + +
+
+ + +
+
diff --git a/src/app/shared/components/shared-metadata/shared-metadata.component.spec.ts b/src/app/shared/components/shared-metadata/shared-metadata.component.spec.ts new file mode 100644 index 000000000..c86786f42 --- /dev/null +++ b/src/app/shared/components/shared-metadata/shared-metadata.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharedMetadataComponent } from './shared-metadata.component'; + +describe('SharedMetadataComponent', () => { + let component: SharedMetadataComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SharedMetadataComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SharedMetadataComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/shared-metadata/shared-metadata.component.ts b/src/app/shared/components/shared-metadata/shared-metadata.component.ts new file mode 100644 index 000000000..fb23a4152 --- /dev/null +++ b/src/app/shared/components/shared-metadata/shared-metadata.component.ts @@ -0,0 +1,63 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +import { CustomItemMetadataRecord } from '@osf/features/project/metadata/models'; +import { ProjectOverview } from '@osf/features/project/overview/models'; +import { TagsInputComponent } from '@shared/components'; +import { SubjectModel } from '@shared/models'; + +import { + ProjectMetadataAffiliatedInstitutionsComponent, + ProjectMetadataContributorsComponent, + ProjectMetadataDescriptionComponent, + ProjectMetadataFundingComponent, + ProjectMetadataLicenseComponent, + ProjectMetadataPublicationDoiComponent, + ProjectMetadataResourceInformationComponent, + ProjectMetadataSubjectsComponent, +} from './components'; + +@Component({ + selector: 'osf-shared-metadata', + imports: [ + ProjectMetadataSubjectsComponent, + TranslatePipe, + TagsInputComponent, + ProjectMetadataPublicationDoiComponent, + ProjectMetadataLicenseComponent, + ProjectMetadataAffiliatedInstitutionsComponent, + ProjectMetadataFundingComponent, + ProjectMetadataResourceInformationComponent, + ProjectMetadataDescriptionComponent, + ProjectMetadataContributorsComponent, + DatePipe, + Card, + ], + templateUrl: './shared-metadata.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SharedMetadataComponent { + currentInstance = input.required(); + customItemMetadata = input.required(); + selectedSubjects = input.required(); + isSubjectsUpdating = input.required(); + hideEditDoiAndLicence = input(false); + readonly = input(false); + + openEditContributorDialog = output(); + openEditDescriptionDialog = output(); + openEditResourceInformationDialog = output(); + openEditFundingDialog = output(); + openEditAffiliatedInstitutionsDialog = output(); + openEditLicenseDialog = output(); + handleEditDoi = output(); + tagsChanged = output(); + + getSubjectChildren = output(); + searchSubjects = output(); + updateSelectedSubjects = output(); +} diff --git a/src/app/shared/components/subjects/subjects.component.html b/src/app/shared/components/subjects/subjects.component.html index 7a7996439..39fc1c960 100644 --- a/src/app/shared/components/subjects/subjects.component.html +++ b/src/app/shared/components/subjects/subjects.component.html @@ -8,7 +8,7 @@

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

@@ -16,53 +16,55 @@

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

} - +@if (!readonly()) { + -@if (searchControl.value) { -
- @if (isSearching()) { - - - - - } @else { - @if (!searchedList().length) { -

{{ 'shared.subjects.noSubject' | translate }}

- } - @for (subjects of searchedList(); track $index) { -
- -
-} @else { - - +
+ } @else { + + + } } diff --git a/src/app/shared/components/subjects/subjects.component.ts b/src/app/shared/components/subjects/subjects.component.ts index e7bac58d6..cb7c793f5 100644 --- a/src/app/shared/components/subjects/subjects.component.ts +++ b/src/app/shared/components/subjects/subjects.component.ts @@ -33,6 +33,7 @@ export class SubjectsComponent { areSubjectsUpdating = input(false); isSearching = select(SubjectsSelectors.getSearchedSubjectsLoading); selected = input([]); + readonly = input(false); searchChanged = output(); loadChildren = output(); updateSelection = output(); @@ -64,6 +65,8 @@ export class SubjectsComponent { } selectSubject(subject: SubjectModel) { + if (this.readonly()) return; + const childrenIds = this.getChildrenIds([subject]); const updatedSelection = [...this.selected().filter((s) => !childrenIds.includes(s.id)), subject]; const parentSubjects = this.mapParentsSubject(subject.parent).filter( @@ -76,6 +79,8 @@ export class SubjectsComponent { } removeSubject(subject: SubjectModel) { + if (this.readonly()) return; + const updatedSelection = this.selected().filter( (s) => s.id !== subject.id && !this.getChildrenIds([subject]).includes(s.id) ); @@ -83,6 +88,8 @@ export class SubjectsComponent { } selectSearched(event: CheckboxChangeEvent, subjects: SubjectModel[]) { + if (this.readonly()) return; + if (event.checked) { this.updateSelection.emit([...this.selected(), ...subjects]); } else { 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 4b62ccf56..02f8c1488 100644 --- a/src/app/shared/components/tags-input/tags-input.component.html +++ b/src/app/shared/components/tags-input/tags-input.component.html @@ -1,11 +1,33 @@ -
- - {{ 'common.hint.tagSeparators' | translate }} +
+
+ @for (tag of localTags(); track $index) { + + } + + +
+ + @if (!readonly()) { + + {{ 'common.hint.tagSeparators' | translate }} + + }
diff --git a/src/app/shared/components/tags-input/tags-input.component.scss b/src/app/shared/components/tags-input/tags-input.component.scss index e69de29bb..9ed984add 100644 --- a/src/app/shared/components/tags-input/tags-input.component.scss +++ b/src/app/shared/components/tags-input/tags-input.component.scss @@ -0,0 +1,45 @@ +.tags-input-container { + min-height: 2.5rem; + border: 1px solid var(--p-inputtext-border-color); + background: var(--p-inputtext-background); + transition: + border-color 0.2s, + box-shadow 0.2s; + cursor: text; + + &:focus-within { + border-color: var(--p-inputtext-focus-border-color); + box-shadow: var(--p-inputtext-focus-ring-shadow); + } + + &:hover { + border-color: var(--p-inputtext-hover-border-color); + } + + .tag-input-field { + border: none; + outline: none; + background: transparent; + flex: 1; + min-width: 120px; + font-size: 1rem; + padding: 0; + margin: 0; + + &::placeholder { + color: var(--p-inputtext-placeholder-color); + } + } + + .p-chip-inline { + font-size: 0.875rem; + + .p-chip { + font-size: inherit; + } + } +} + +.tags-input-container:focus-within .tag-input-field { + outline: none; +} 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 67c4957df..3c8ba3678 100644 --- a/src/app/shared/components/tags-input/tags-input.component.ts +++ b/src/app/shared/components/tags-input/tags-input.component.ts @@ -1,13 +1,23 @@ import { TranslatePipe } from '@ngx-translate/core'; -import { Chips } from 'primeng/chips'; +import { Chip } from 'primeng/chip'; +import { InputText } from 'primeng/inputtext'; -import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + effect, + ElementRef, + input, + output, + signal, + viewChild, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; @Component({ selector: 'osf-tags-input', - imports: [TranslatePipe, FormsModule, Chips], + imports: [TranslatePipe, FormsModule, Chip, InputText], templateUrl: './tags-input.component.html', styleUrl: './tags-input.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -15,9 +25,88 @@ import { FormsModule } from '@angular/forms'; export class TagsInputComponent { tags = input([]); required = input(false); + readonly = input(false); tagsChanged = output(); - onTagsChange(tags: string[]): void { - this.tagsChanged.emit(tags); + inputValue = signal(''); + inputElement = viewChild>('tagInput'); + + localTags = signal([]); + private isLocalUpdate = false; + + constructor() { + effect(() => { + const incoming = this.tags(); + + if (!this.isLocalUpdate) { + this.localTags.set([...incoming]); + } + + this.isLocalUpdate = false; + }); + } + + onContainerClick(): void { + this.inputElement()?.nativeElement.focus(); + } + + onContainerKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.inputElement()?.nativeElement.focus(); + } + } + + onInputKeydown(event: KeyboardEvent): void { + const target = event.target as HTMLInputElement; + const value = target.value.trim(); + + if ((event.key === 'Enter' || event.key === ',' || event.key === ' ') && value) { + event.preventDefault(); + this.addTag(value); + target.value = ''; + this.inputValue.set(''); + } else if (event.key === 'Backspace' && !value && this.localTags().length > 0) { + this.removeTag(this.localTags().length - 1); + } + } + + onInputBlur(event: FocusEvent): void { + const target = event.target as HTMLInputElement; + const value = target.value.trim(); + + if (value) { + this.addTag(value); + target.value = ''; + this.inputValue.set(''); + } + } + + private addTag(tagValue: string): void { + if (this.readonly()) return; + + const currentTags = this.localTags(); + const normalizedValue = tagValue.replace(/[,\s]+/g, ' ').trim(); + + if (normalizedValue && !currentTags.includes(normalizedValue)) { + const updatedTags = [...currentTags, normalizedValue]; + + this.localTags.set(updatedTags); + this.isLocalUpdate = true; + + this.tagsChanged.emit(updatedTags); + } + } + + removeTag(index: number): void { + if (this.readonly()) return; + + const currentTags = this.localTags(); + const updatedTags = currentTags.filter((_, i) => i !== index); + + this.localTags.set(updatedTags); + this.isLocalUpdate = true; + + this.tagsChanged.emit(updatedTags); } } diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index 29464b607..f4d0ac2f6 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -9,6 +9,7 @@ export * from './create-project-form-controls.enum'; export * from './file-menu-type.enum'; export * from './filter-type.enum'; export * from './get-resources-request-type.enum'; +export * from './metadata-projects.enum'; export * from './profile-addons-stepper.enum'; export * from './profile-settings-key.enum'; export * from './registration-review-states.enum'; diff --git a/src/app/shared/enums/metadata-projects.enum.ts b/src/app/shared/enums/metadata-projects.enum.ts new file mode 100644 index 000000000..ee93c95fd --- /dev/null +++ b/src/app/shared/enums/metadata-projects.enum.ts @@ -0,0 +1,5 @@ +export enum MetadataProjectsEnum { + PROJECT = 'project', + CEDAR = 'cedar', + REGISTRY = 'registry', +} diff --git a/src/app/shared/models/metadata-tabs.model.ts b/src/app/shared/models/metadata-tabs.model.ts new file mode 100644 index 000000000..300ae518a --- /dev/null +++ b/src/app/shared/models/metadata-tabs.model.ts @@ -0,0 +1,7 @@ +import { MetadataProjectsEnum } from '@shared/enums'; + +export interface MetadataTabsModel { + id: string; + label: string; + type: MetadataProjectsEnum; +} diff --git a/src/app/shared/services/subjects.service.ts b/src/app/shared/services/subjects.service.ts index 643493104..98429e28b 100644 --- a/src/app/shared/services/subjects.service.ts +++ b/src/app/shared/services/subjects.service.ts @@ -25,12 +25,21 @@ export class SubjectsService { [ResourceType.DraftRegistration, 'draft_registrations'], ]); - getSubjects(resourceType: ResourceType, resourceId?: string, search?: string): Observable { - const baseUrl = + getSubjects( + resourceType: ResourceType, + resourceId?: string, + search?: string, + isMetadataRegistry = false + ): Observable { + let baseUrl = resourceType === ResourceType.Project ? `${this.apiUrl}/subjects/` : `${this.apiUrl}/providers/${this.urlMap.get(resourceType)}/${resourceId}/subjects/`; + if (isMetadataRegistry) { + baseUrl = baseUrl.replace('/providers', ''); + } + const params: Record = { 'page[size]': '100', sort: 'text', diff --git a/src/app/shared/stores/subjects/subjects.actions.ts b/src/app/shared/stores/subjects/subjects.actions.ts index 34891fe20..af46c07a5 100644 --- a/src/app/shared/stores/subjects/subjects.actions.ts +++ b/src/app/shared/stores/subjects/subjects.actions.ts @@ -7,7 +7,8 @@ export class FetchSubjects { constructor( public resourceType: ResourceType | undefined, public resourceId?: string, - public search?: string + public search?: string, + public isMetadataRegistry?: boolean ) {} } diff --git a/src/app/shared/stores/subjects/subjects.state.ts b/src/app/shared/stores/subjects/subjects.state.ts index 04a6ff62c..2e26c0fe6 100644 --- a/src/app/shared/stores/subjects/subjects.state.ts +++ b/src/app/shared/stores/subjects/subjects.state.ts @@ -42,7 +42,10 @@ export class SubjectsState { private readonly subjectsService = inject(SubjectsService); @Action(FetchSubjects) - fetchSubjects(ctx: StateContext, { resourceId, resourceType, search }: FetchSubjects) { + fetchSubjects( + ctx: StateContext, + { resourceId, resourceType, search, isMetadataRegistry }: FetchSubjects + ) { if (!resourceType) { return; } @@ -60,7 +63,7 @@ export class SubjectsState { }, }); - return this.subjectsService.getSubjects(resourceType, resourceId, search).pipe( + return this.subjectsService.getSubjects(resourceType, resourceId, search, isMetadataRegistry).pipe( tap((subjects) => { if (search) { ctx.patchState({ diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 3b14b7712..627b839a5 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -589,6 +589,8 @@ "noResourceInformation": "No resource information available", "notSpecified": "Not specified", "loading": "Loading metadata...", + "cedarRecordCreatedSuccessfully": "CEDAR record created successfully", + "failedToCreateCedarRecord": "Failed to create CEDAR record", "placeholders": { "edit": "Edit description here", "add": "Add description here" diff --git a/src/assets/styles/overrides/chip.scss b/src/assets/styles/overrides/chip.scss index 661d1a9f7..fd31e98a5 100644 --- a/src/assets/styles/overrides/chip.scss +++ b/src/assets/styles/overrides/chip.scss @@ -32,8 +32,8 @@ } } -p-chips.ng-invalid.ng-touched { - .p-inputchips-input { - border-color: var(--p-inputchips-invalid-border-color); +.osf-tags-input { + input.ng-invalid.ng-touched { + border-color: var(--p-inputtext-invalid-border-color); } }