@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);
}
}