diff --git a/src/app/features/moderation/models/moderator-json-api.model.ts b/src/app/features/moderation/models/moderator-json-api.model.ts index 68b3a10df..583702bf1 100644 --- a/src/app/features/moderation/models/moderator-json-api.model.ts +++ b/src/app/features/moderation/models/moderator-json-api.model.ts @@ -27,14 +27,4 @@ export interface ModeratorAddRequestModel { full_name?: string; email?: string; }; - // relationships: { - // users?: { - // data?: RelationshipUsersData; - // }; - // }; -} - -interface RelationshipUsersData { - id?: string; - type?: 'users'; } diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html new file mode 100644 index 000000000..b5557a65f --- /dev/null +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html @@ -0,0 +1,48 @@ +@if (isCurrentResourceLoading() || isResourceConfirming()) { + +} @else if (isPreviewMode() && currentResource()) { +
+

{{ 'resources.check' | translate }}

+ + @let resourceType = currentResource()?.type; + @let iconName = resourceType === 'analytic_code' ? 'code' : resourceType; + @let icon = `assets/icons/colored/${iconName}-colored.svg`; + @let resourceName = resourceType === RegistryResourceType.Code ? 'Analytic Code' : resourceType; + +
+ resource-type icon +
+
+

{{ resourceName }}

+ https://doi/{{ currentResource()?.pid }} +
+

{{ currentResource()?.description }}

+
+
+ +
+ + +
+
+} @else { + +} diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.scss b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.scss new file mode 100644 index 000000000..9b376173d --- /dev/null +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.scss @@ -0,0 +1,8 @@ +.content { + overflow: hidden; + text-overflow: clip; + white-space: wrap; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; +} diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts new file mode 100644 index 000000000..0d45a41b3 --- /dev/null +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AddResourceDialogComponent } from './add-resource-dialog.component'; + +describe('AddResourceDialogComponent', () => { + let component: AddResourceDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddResourceDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AddResourceDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts new file mode 100644 index 000000000..fd76dbbee --- /dev/null +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts @@ -0,0 +1,121 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { finalize, take } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { LoadingSpinnerComponent } from '@osf/shared/components'; +import { InputLimits } from '@osf/shared/constants'; +import { RegistryResourceType } from '@osf/shared/enums'; +import { SelectOption } from '@osf/shared/models'; +import { CustomValidators } from '@osf/shared/utils'; + +import { resourceTypeOptions } from '../../constants'; +import { AddResource, ConfirmAddResource } from '../../models'; +import { + ConfirmAddRegistryResource, + PreviewRegistryResource, + RegistryResourcesSelectors, + SilentDelete, +} from '../../store/registry-resources'; +import { ResourceFormComponent } from '../resource-form/resource-form.component'; + +@Component({ + selector: 'osf-add-resource-dialog', + imports: [Button, TranslatePipe, ReactiveFormsModule, LoadingSpinnerComponent, ResourceFormComponent], + templateUrl: './add-resource-dialog.component.html', + styleUrl: './add-resource-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddResourceDialogComponent { + protected readonly dialogRef = inject(DynamicDialogRef); + protected readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); + protected readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); + private translateService = inject(TranslateService); + + private dialogConfig = inject(DynamicDialogConfig); + private registryId: string = this.dialogConfig.data.id; + + protected inputLimits = InputLimits; + protected isResourceConfirming = signal(false); + + protected form = new FormGroup({ + pid: new FormControl('', [CustomValidators.requiredTrimmed(), CustomValidators.doiValidator]), + resourceType: new FormControl('', [Validators.required]), + description: new FormControl(''), + }); + + private readonly actions = createDispatchMap({ + previewResource: PreviewRegistryResource, + confirmAddResource: ConfirmAddRegistryResource, + deleteResource: SilentDelete, + }); + + public resourceOptions = signal(resourceTypeOptions); + public isPreviewMode = signal(false); + + protected readonly RegistryResourceType = RegistryResourceType; + + previewResource(): void { + if (this.form.invalid) { + return; + } + + const addResource: AddResource = { + pid: this.form.controls['pid'].value ?? '', + resource_type: this.form.controls['resourceType'].value ?? '', + description: this.form.controls['description'].value ?? '', + }; + + const currentResource = this.currentResource(); + if (!currentResource) { + throw new Error(this.translateService.instant('resources.errors.noCurrentResource')); + } + + this.actions.previewResource(currentResource.id, addResource).subscribe(() => { + this.isPreviewMode.set(true); + }); + } + + backToEdit() { + this.isPreviewMode.set(false); + } + + onAddResource() { + const addResource: ConfirmAddResource = { + finalized: true, + }; + const currentResource = this.currentResource(); + + if (!currentResource) { + throw new Error(this.translateService.instant('resources.errors.noRegistryId')); + } + + this.isResourceConfirming.set(true); + this.actions + .confirmAddResource(addResource, currentResource.id, this.registryId) + .pipe( + take(1), + finalize(() => { + this.dialogRef.close(true); + this.isResourceConfirming.set(false); + }) + ) + .subscribe({}); + } + + closeDialog(): void { + this.dialogRef.close(); + const currentResource = this.currentResource(); + if (!currentResource) { + throw new Error(this.translateService.instant('resources.errors.noRegistryId')); + } + this.actions.deleteResource(currentResource.id); + } +} diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.html b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.html new file mode 100644 index 000000000..0c1ec74d8 --- /dev/null +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.html @@ -0,0 +1,13 @@ +@if (isCurrentResourceLoading()) { + +} @else { + +} diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.scss b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts new file mode 100644 index 000000000..b1aa17bf4 --- /dev/null +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditResourceDialogComponent } from './edit-resource-dialog.component'; + +describe('EditResourceDialogComponent', () => { + let component: EditResourceDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EditResourceDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(EditResourceDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts new file mode 100644 index 000000000..df455a026 --- /dev/null +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts @@ -0,0 +1,78 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { finalize, take } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { LoadingSpinnerComponent } from '@osf/shared/components'; +import { CustomValidators } from '@osf/shared/utils'; + +import { AddResource, RegistryResource } from '../../models'; +import { RegistryResourcesSelectors, UpdateResource } from '../../store/registry-resources'; +import { ResourceFormComponent } from '../resource-form/resource-form.component'; + +@Component({ + selector: 'osf-edit-resource-dialog', + imports: [LoadingSpinnerComponent, ReactiveFormsModule, ResourceFormComponent], + templateUrl: './edit-resource-dialog.component.html', + styleUrl: './edit-resource-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EditResourceDialogComponent { + protected readonly dialogRef = inject(DynamicDialogRef); + protected readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); + private translateService = inject(TranslateService); + + private dialogConfig = inject(DynamicDialogConfig); + private registryId: string = this.dialogConfig.data.id; + private resource: RegistryResource = this.dialogConfig.data.resource as RegistryResource; + + protected form = new FormGroup({ + pid: new FormControl('', [CustomValidators.requiredTrimmed(), CustomValidators.doiValidator]), + resourceType: new FormControl('', [Validators.required]), + description: new FormControl(''), + }); + + private readonly actions = createDispatchMap({ + updateResource: UpdateResource, + }); + + constructor() { + this.form.patchValue({ + pid: this.resource.pid || '', + resourceType: this.resource.type || '', + description: this.resource.description || '', + }); + } + + save() { + if (this.form.invalid) { + return; + } + + const addResource: AddResource = { + pid: this.form.controls['pid'].value ?? '', + resource_type: this.form.controls['resourceType'].value ?? '', + description: this.form.controls['description'].value ?? '', + }; + + if (!this.resource.id) { + throw new Error(this.translateService.instant('resources.errors.noRegistryId')); + } + + this.actions + .updateResource(this.registryId, this.resource.id, addResource) + .pipe( + take(1), + finalize(() => { + this.dialogRef.close(true); + }) + ) + .subscribe(); + } +} diff --git a/src/app/features/registry/components/index.ts b/src/app/features/registry/components/index.ts index ac682c343..0ee64872d 100644 --- a/src/app/features/registry/components/index.ts +++ b/src/app/features/registry/components/index.ts @@ -1,4 +1,7 @@ +export * from './add-resource-dialog/add-resource-dialog.component'; +export * from './edit-resource-dialog/edit-resource-dialog.component'; export * from './registration-links-card/registration-links-card.component'; export * from './registry-revisions/registry-revisions.component'; export * from './registry-statuses/registry-statuses.component'; +export * from './resource-form/resource-form.component'; export * from './withdraw-dialog/withdraw-dialog.component'; diff --git a/src/app/features/registry/components/resource-form/resource-form.component.html b/src/app/features/registry/components/resource-form/resource-form.component.html new file mode 100644 index 000000000..0aceb3622 --- /dev/null +++ b/src/app/features/registry/components/resource-form/resource-form.component.html @@ -0,0 +1,42 @@ +
+ + @if (getControl('pid').hasError('invalidDoi')) { + {{ 'resources.errors.doiValidation' | translate }} + } + + + + +
+ + +
+ +
+ @if (showCancelButton()) { + + } + +
+ diff --git a/src/app/features/registry/components/resource-form/resource-form.component.scss b/src/app/features/registry/components/resource-form/resource-form.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registry/components/resource-form/resource-form.component.spec.ts b/src/app/features/registry/components/resource-form/resource-form.component.spec.ts new file mode 100644 index 000000000..aa8f7b61a --- /dev/null +++ b/src/app/features/registry/components/resource-form/resource-form.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResourceFormComponent } from './resource-form.component'; + +describe('ResourceFormComponent', () => { + let component: ResourceFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourceFormComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ResourceFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registry/components/resource-form/resource-form.component.ts b/src/app/features/registry/components/resource-form/resource-form.component.ts new file mode 100644 index 000000000..e0259c76c --- /dev/null +++ b/src/app/features/registry/components/resource-form/resource-form.component.ts @@ -0,0 +1,51 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Textarea } from 'primeng/textarea'; + +import { ChangeDetectionStrategy, Component, input, output, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { resourceTypeOptions } from '@osf/features/registry/constants'; +import { FormSelectComponent, TextInputComponent } from '@shared/components'; +import { InputLimits } from '@shared/constants'; +import { SelectOption } from '@shared/models'; + +interface ResourceForm { + pid: FormControl; + resourceType: FormControl; + description: FormControl; +} + +@Component({ + selector: 'osf-resource-form', + imports: [TextInputComponent, TranslatePipe, ReactiveFormsModule, Textarea, FormSelectComponent, Button], + templateUrl: './resource-form.component.html', + styleUrl: './resource-form.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResourceFormComponent { + formGroup = input.required>(); + showCancelButton = input(true); + showPreviewButton = input(false); + cancelButtonLabel = input('common.buttons.cancel'); + primaryButtonLabel = input('common.buttons.save'); + + cancelClicked = output(); + submitClicked = output(); + + protected inputLimits = InputLimits; + public resourceOptions = signal(resourceTypeOptions); + + protected getControl(controlName: keyof ResourceForm): FormControl { + return this.formGroup().get(controlName) as FormControl; + } + + handleCancel(): void { + this.cancelClicked.emit(); + } + + handleSubmit(): void { + this.submitClicked.emit(); + } +} diff --git a/src/app/features/registry/constants/index.ts b/src/app/features/registry/constants/index.ts new file mode 100644 index 000000000..fbbc5bdfd --- /dev/null +++ b/src/app/features/registry/constants/index.ts @@ -0,0 +1 @@ +export * from './resource-type-options.constant'; diff --git a/src/app/features/registry/constants/resource-type-options.constant.ts b/src/app/features/registry/constants/resource-type-options.constant.ts new file mode 100644 index 000000000..4de962591 --- /dev/null +++ b/src/app/features/registry/constants/resource-type-options.constant.ts @@ -0,0 +1,25 @@ +import { RegistryResourceType } from '@shared/enums'; +import { SelectOption } from '@shared/models'; + +export const resourceTypeOptions: SelectOption[] = [ + { + label: 'resources.typeOptions.data', + value: RegistryResourceType.Data, + }, + { + label: 'resources.typeOptions.code', + value: RegistryResourceType.Code, + }, + { + label: 'resources.typeOptions.materials', + value: RegistryResourceType.Materials, + }, + { + label: 'resources.typeOptions.papers', + value: RegistryResourceType.Papers, + }, + { + label: 'resources.typeOptions.supplements', + value: RegistryResourceType.Supplements, + }, +]; diff --git a/src/app/features/registry/mappers/add-resource-request.mapper.ts b/src/app/features/registry/mappers/add-resource-request.mapper.ts new file mode 100644 index 000000000..82fa14a66 --- /dev/null +++ b/src/app/features/registry/mappers/add-resource-request.mapper.ts @@ -0,0 +1,39 @@ +import { AddResourceRequest } from '@osf/features/registry/models/resources/add-resource-request.model'; + +export interface AddResourcePayload { + data: AddResourceRequest; +} + +export function MapAddResourceRequest( + resourceId: string, + resource: T, + type = 'resources', + relationships: object = {} +): AddResourcePayload { + const resourceData: AddResourceRequest = { + attributes: resource, + id: resourceId, + relationships, + type, + }; + + return { + data: resourceData, + }; +} + +export function toAddResourceRequestBody(registryId: string) { + return { + data: { + relationships: { + registration: { + data: { + type: 'registrations', + id: registryId, + }, + }, + }, + type: 'resources', + }, + }; +} diff --git a/src/app/features/registry/mappers/index.ts b/src/app/features/registry/mappers/index.ts index 39347fb3c..ab8e8924f 100644 --- a/src/app/features/registry/mappers/index.ts +++ b/src/app/features/registry/mappers/index.ts @@ -1,7 +1,9 @@ +export * from './add-resource-request.mapper'; export * from './bibliographic-contributors.mapper'; export * from './cedar-form.mapper'; export * from './linked-nodes.mapper'; export * from './linked-registrations.mapper'; export * from './registry-metadata.mapper'; export * from './registry-overview.mapper'; +export * from './registry-resource.mapper'; export * from './registry-schema-block.mapper'; diff --git a/src/app/features/registry/mappers/registry-resource.mapper.ts b/src/app/features/registry/mappers/registry-resource.mapper.ts new file mode 100644 index 000000000..0ac344183 --- /dev/null +++ b/src/app/features/registry/mappers/registry-resource.mapper.ts @@ -0,0 +1,12 @@ +import { RegistryResourceDataJsonApi } from '@osf/features/registry/models/resources/add-resource-response-json-api.model'; +import { RegistryResource } from '@osf/features/registry/models/resources/registry-resource.model'; + +export function MapRegistryResource(resource: RegistryResourceDataJsonApi): RegistryResource { + return { + id: resource.id, + description: resource.attributes.description, + finalized: resource.attributes.finalized, + type: resource.attributes.resource_type, + pid: resource.attributes.pid, + }; +} diff --git a/src/app/features/registry/models/index.ts b/src/app/features/registry/models/index.ts index 67a11f3e0..853f8b006 100644 --- a/src/app/features/registry/models/index.ts +++ b/src/app/features/registry/models/index.ts @@ -14,4 +14,12 @@ export * from './registry-metadata.models'; export * from './registry-overview.models'; export * from './registry-schema-block.model'; export * from './registry-subject.model'; +export * from './resources/add-resource.model'; +export * from './resources/add-resource-request.model'; +export * from './resources/add-resource-response-json-api.model'; +export * from './resources/confirm-add-resource.model'; +export * from './resources/get-registry-resources-json-api.model'; +export * from './resources/get-registry-resources-json-api.model'; +export * from './resources/registry-resource.model'; +export * from './resources/registry-resource.model'; export * from './view-schema-block.model'; diff --git a/src/app/features/registry/models/resources/add-resource-request.model.ts b/src/app/features/registry/models/resources/add-resource-request.model.ts new file mode 100644 index 000000000..9a00ec435 --- /dev/null +++ b/src/app/features/registry/models/resources/add-resource-request.model.ts @@ -0,0 +1,6 @@ +export interface AddResourceRequest { + attributes: T; + id: string; + relationships?: object; + type: string; +} diff --git a/src/app/features/registry/models/resources/add-resource-response-json-api.model.ts b/src/app/features/registry/models/resources/add-resource-response-json-api.model.ts new file mode 100644 index 000000000..9ab922349 --- /dev/null +++ b/src/app/features/registry/models/resources/add-resource-response-json-api.model.ts @@ -0,0 +1,16 @@ +import { ApiData, JsonApiResponse } from '@core/models'; +import { RegistryResourceType } from '@shared/enums'; + +export type AddResourceJsonApi = JsonApiResponse; + +export type RegistryResourceDataJsonApi = ApiData< + { + description: string; + finalized: true; + resource_type: RegistryResourceType; + pid: string; + }, + null, + null, + null +>; diff --git a/src/app/features/registry/models/resources/add-resource.model.ts b/src/app/features/registry/models/resources/add-resource.model.ts new file mode 100644 index 000000000..451c81520 --- /dev/null +++ b/src/app/features/registry/models/resources/add-resource.model.ts @@ -0,0 +1,5 @@ +export interface AddResource { + pid: string; + resource_type: string; + description?: string; +} diff --git a/src/app/features/registry/models/resources/confirm-add-resource.model.ts b/src/app/features/registry/models/resources/confirm-add-resource.model.ts new file mode 100644 index 000000000..80e806194 --- /dev/null +++ b/src/app/features/registry/models/resources/confirm-add-resource.model.ts @@ -0,0 +1,3 @@ +export interface ConfirmAddResource { + finalized: boolean; +} diff --git a/src/app/features/registry/models/resources/get-registry-resources-json-api.model.ts b/src/app/features/registry/models/resources/get-registry-resources-json-api.model.ts new file mode 100644 index 000000000..45e803e1c --- /dev/null +++ b/src/app/features/registry/models/resources/get-registry-resources-json-api.model.ts @@ -0,0 +1,4 @@ +import { JsonApiResponse } from '@core/models'; +import { RegistryResourceDataJsonApi } from '@osf/features/registry/models/resources/add-resource-response-json-api.model'; + +export type GetRegistryResourcesJsonApi = JsonApiResponse; diff --git a/src/app/features/registry/models/resources/registry-resource.model.ts b/src/app/features/registry/models/resources/registry-resource.model.ts new file mode 100644 index 000000000..5ed0e36f1 --- /dev/null +++ b/src/app/features/registry/models/resources/registry-resource.model.ts @@ -0,0 +1,9 @@ +import { RegistryResourceType } from '@shared/enums'; + +export interface RegistryResource { + id: string; + description: string; + finalized: boolean; + type: RegistryResourceType; + pid: string; +} diff --git a/src/app/features/registry/pages/index.ts b/src/app/features/registry/pages/index.ts index 369382d7f..3e6d110d8 100644 --- a/src/app/features/registry/pages/index.ts +++ b/src/app/features/registry/pages/index.ts @@ -2,3 +2,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'; +export * from '@osf/features/registry/pages/registry-resources/registry-resources.component'; 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 index b51634051..fbeec0772 100644 --- 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 @@ -3,7 +3,7 @@ import { provideStore, Store } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { MockComponent, MockPipe } from 'ng-mocks'; -import { of, throwError } from 'rxjs'; +import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -400,8 +400,7 @@ describe('RegistryMetadataAddComponent', () => { }); it('should successfully create cedar record', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of({})); - const routerSpy = jest.spyOn(router, 'navigate'); + const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of()); store.reset({ registryMetadata: { @@ -422,7 +421,6 @@ describe('RegistryMetadataAddComponent', () => { it('should handle submission success', (done) => { const routerSpy = jest.spyOn(router, 'navigate'); - const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of({})); store.reset({ registryMetadata: { @@ -436,7 +434,6 @@ describe('RegistryMetadataAddComponent', () => { component.onSubmit(mockSubmissionData); - // Use setTimeout to allow the subscription to complete setTimeout(() => { expect(component.isSubmitting()).toBe(false); expect(toastService.showSuccess).toHaveBeenCalledWith( @@ -450,11 +447,8 @@ describe('RegistryMetadataAddComponent', () => { }); 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'); diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index 51ca490db..f8b565ed3 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -46,7 +46,7 @@ import { }) export class RegistryOverviewComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; - private readonly route = inject(ActivatedRoute); + protected readonly route = inject(ActivatedRoute); private readonly router = inject(Router); protected readonly registry = select(RegistryOverviewSelectors.getRegistry); diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.html b/src/app/features/registry/pages/registry-resources/registry-resources.component.html new file mode 100644 index 000000000..da98378a9 --- /dev/null +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.html @@ -0,0 +1,51 @@ + + +@if (isResourcesLoading()) { + +} @else { +
+

{{ 'resources.description' | translate }}

+ +
+ @for (resource of resources(); track resource.id) { +
+
+ @let iconName = resource.type === 'analytic_code' ? 'code' : resource.type; + @let icon = `assets/icons/colored/${iconName}-colored.svg`; + @let resourceName = resource.type === 'analytic_code' ? 'analytic code' : resource.type; + data-resource + +
+

{{ resourceName }}

+ https://doi.org/{{ resource.pid }} +

{{ resource.description }}

+
+
+ +
+ + +
+
+ } +
+
+} diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.scss b/src/app/features/registry/pages/registry-resources/registry-resources.component.scss new file mode 100644 index 000000000..916e85804 --- /dev/null +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.scss @@ -0,0 +1,11 @@ +@use "/assets/styles/variables" as var; +@use "assets/styles/mixins" as mix; + +.resource-block { + display: flex; + align-items: center; + width: 100%; + height: max-content; + border: 1px solid var.$grey-2; + border-radius: mix.rem(12px); +} diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts b/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts new file mode 100644 index 000000000..a67016691 --- /dev/null +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistryResourcesComponent } from './registry-resources.component'; + +describe('RegistryResourcesComponent', () => { + let component: RegistryResourcesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistryResourcesComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistryResourcesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts new file mode 100644 index 000000000..4d52eb605 --- /dev/null +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts @@ -0,0 +1,147 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DialogService } from 'primeng/dynamicdialog'; + +import { finalize, take } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, HostBinding, inject, signal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { AddResourceDialogComponent } from '@osf/features/registry/components/add-resource-dialog/add-resource-dialog.component'; +import { EditResourceDialogComponent } from '@osf/features/registry/components/edit-resource-dialog/edit-resource-dialog.component'; +import { RegistryResource } from '@osf/features/registry/models'; +import { + AddRegistryResource, + DeleteResource, + GetRegistryResources, + RegistryResourcesSelectors, + SilentDelete, +} from '@osf/features/registry/store/registry-resources'; +import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; +import { CustomConfirmationService, ToastService } from '@shared/services'; + +@Component({ + selector: 'osf-registry-resources', + imports: [SubHeaderComponent, TranslatePipe, Button, LoadingSpinnerComponent], + templateUrl: './registry-resources.component.html', + styleUrl: './registry-resources.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService], +}) +export class RegistryResourcesComponent { + @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; + private readonly route = inject(ActivatedRoute); + private dialogService = inject(DialogService); + private translateService = inject(TranslateService); + private toastService = inject(ToastService); + private customConfirmationService = inject(CustomConfirmationService); + + protected readonly resources = select(RegistryResourcesSelectors.getResources); + protected readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); + private registryId = ''; + protected addingResource = signal(false); + private readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); + + private readonly actions = createDispatchMap({ + getResources: GetRegistryResources, + addResource: AddRegistryResource, + deleteResource: DeleteResource, + silentDelete: SilentDelete, + }); + + constructor() { + this.route.parent?.params.subscribe((params) => { + this.registryId = params['id']; + if (this.registryId) { + this.actions.getResources(this.registryId); + } + }); + } + + addResource() { + if (!this.registryId) { + throw new Error(this.translateService.instant('resources.errors.noRegistryId')); + } + + this.addingResource.set(true); + + this.actions + .addResource(this.registryId) + .pipe( + take(1), + finalize(() => this.addingResource.set(false)) + ) + .subscribe(() => { + const dialogRef = this.dialogService.open(AddResourceDialogComponent, { + header: this.translateService.instant('resources.add'), + width: '500px', + focusOnShow: false, + closeOnEscape: true, + modal: true, + closable: true, + data: { id: this.registryId }, + }); + + dialogRef.onClose.subscribe({ + next: (res) => { + if (res) { + this.toastService.showSuccess('resources.toastMessages.addResourceSuccess'); + } else { + const currentResource = this.currentResource(); + if (!currentResource) { + throw new Error(this.translateService.instant('resources.errors.noCurrentResource')); + } + this.actions.silentDelete(currentResource.id); + } + }, + error: () => this.toastService.showError('resources.toastMessages.addResourceError'), + }); + }); + } + + updateResource(resource: RegistryResource) { + if (!this.registryId) { + throw new Error(this.translateService.instant('resources.errors.noRegistryId')); + } + + const dialogRef = this.dialogService.open(EditResourceDialogComponent, { + header: this.translateService.instant('resources.edit'), + width: '500px', + focusOnShow: false, + closeOnEscape: true, + modal: true, + closable: true, + data: { id: this.registryId, resource: resource }, + }); + + dialogRef.onClose.subscribe({ + next: (res) => { + if (res) { + this.toastService.showSuccess('resources.toastMessages.updatedResourceSuccess'); + } + }, + error: () => this.toastService.showError('resources.toastMessages.updateResourceError'), + }); + } + + deleteResource(id: string) { + if (!this.registryId) return; + + this.customConfirmationService.confirmDelete({ + headerKey: 'resources.delete', + messageKey: 'resources.deleteText', + acceptLabelKey: 'common.buttons.remove', + onConfirm: () => { + this.actions + .deleteResource(id, this.registryId) + .pipe(take(1)) + .subscribe(() => { + this.toastService.showSuccess('resources.toastMessages.deletedResourceSuccess'); + }); + }, + }); + } +} diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index 04f8585b1..e83315faa 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -11,6 +11,7 @@ import { ContributorsState, ViewOnlyLinkState } from '@osf/shared/stores'; import { AnalyticsState } from '../project/analytics/store'; +import { RegistryResourcesState } from './store/registry-resources/registry-resources.state'; import { RegistryComponent } from './registry.component'; export const registryRoutes: Routes = [ @@ -77,6 +78,14 @@ export const registryRoutes: Routes = [ context: ResourceType.Registration, }, }, + { + path: 'resources', + loadComponent: () => + import('./pages/registry-resources/registry-resources.component').then( + (mod) => mod.RegistryResourcesComponent + ), + providers: [provideStates([RegistryResourcesState])], + }, ], }, ]; diff --git a/src/app/features/registry/services/index.ts b/src/app/features/registry/services/index.ts index fbe00055a..f1f11c008 100644 --- a/src/app/features/registry/services/index.ts +++ b/src/app/features/registry/services/index.ts @@ -1,3 +1,4 @@ export * from './registry-links.service'; export * from './registry-metadata.service'; export * from './registry-overview.service'; +export * from './registry-resources.service'; diff --git a/src/app/features/registry/services/registry-resources.service.ts b/src/app/features/registry/services/registry-resources.service.ts new file mode 100644 index 000000000..e63b8ae3f --- /dev/null +++ b/src/app/features/registry/services/registry-resources.service.ts @@ -0,0 +1,76 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@core/services'; +import { MapAddResourceRequest, MapRegistryResource, toAddResourceRequestBody } from '@osf/features/registry/mappers'; +import { GetRegistryResourcesJsonApi, RegistryResource } from '@osf/features/registry/models'; +import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; +import { + AddResourceJsonApi, + RegistryResourceDataJsonApi, +} from '@osf/features/registry/models/resources/add-resource-response-json-api.model'; +import { ConfirmAddResource } from '@osf/features/registry/models/resources/confirm-add-resource.model'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class RegistryResourcesService { + private jsonApiService = inject(JsonApiService); + + getResources(registryId: string): Observable { + const params = { + 'fields[resources]': 'description,finalized,resource_type,pid', + }; + + return this.jsonApiService + .get(`${environment.apiUrl}/registrations/${registryId}/resources/?page=1`, params) + .pipe(map((response) => response.data.map((resource) => MapRegistryResource(resource)))); + } + + addRegistryResource(registryId: string): Observable { + const body = toAddResourceRequestBody(registryId); + + return this.jsonApiService.post(`${environment.apiUrl}/resources/`, body).pipe( + map((response) => { + return MapRegistryResource(response.data); + }) + ); + } + + previewRegistryResource(resourceId: string, resource: AddResource): Observable { + const payload = MapAddResourceRequest(resourceId, resource); + + return this.jsonApiService + .patch(`${environment.apiUrl}/resources/${resourceId}/`, payload) + .pipe( + map((response) => { + return MapRegistryResource(response); + }) + ); + } + + confirmAddingResource(resourceId: string, resource: ConfirmAddResource): Observable { + const payload = MapAddResourceRequest(resourceId, resource); + + return this.jsonApiService + .patch(`${environment.apiUrl}/resources/${resourceId}/`, payload) + .pipe( + map((response) => { + return MapRegistryResource(response); + }) + ); + } + + deleteResource(resourceId: string): Observable { + return this.jsonApiService.delete(`${environment.apiUrl}/resources/${resourceId}/`); + } + + updateResource(resourceId: string, resource: AddResource) { + const payload = MapAddResourceRequest(resourceId, resource); + + return this.jsonApiService.patch(`${environment.apiUrl}/resources/${resourceId}/`, payload); + } +} diff --git a/src/app/features/registry/store/registry-resources/index.ts b/src/app/features/registry/store/registry-resources/index.ts new file mode 100644 index 000000000..21de66780 --- /dev/null +++ b/src/app/features/registry/store/registry-resources/index.ts @@ -0,0 +1,4 @@ +export * from './registry-resources.actions'; +export * from './registry-resources.model'; +export * from './registry-resources.selectors'; +export * from './registry-resources.state'; diff --git a/src/app/features/registry/store/registry-resources/registry-resources.actions.ts b/src/app/features/registry/store/registry-resources/registry-resources.actions.ts new file mode 100644 index 000000000..14ed0dad5 --- /dev/null +++ b/src/app/features/registry/store/registry-resources/registry-resources.actions.ts @@ -0,0 +1,58 @@ +import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; +import { ConfirmAddResource } from '@osf/features/registry/models/resources/confirm-add-resource.model'; + +export class GetRegistryResources { + static readonly type = '[Registry Resources] Get Registry Resources'; + + constructor(public registryId: string) {} +} + +export class AddRegistryResource { + static readonly type = '[Registry Resources] Add Registry Resources'; + + constructor(public registryId: string) {} +} + +export class PreviewRegistryResource { + static readonly type = '[Registry Resources] Preview Registry Resources'; + + constructor( + public resourceId: string, + public resource: AddResource + ) {} +} + +export class ConfirmAddRegistryResource { + static readonly type = '[Registry Resources] Confirm Add Registry Resources'; + + constructor( + public resource: ConfirmAddResource, + public resourceId: string, + public registryId: string + ) {} +} + +export class DeleteResource { + static readonly type = '[Registry Resources] Delete Registry Resources'; + + constructor( + public resourceId: string, + public registryId: string + ) {} +} + +export class SilentDelete { + static readonly type = '[Registry Resources] Silent Delete'; + + constructor(public resourceId: string) {} +} + +export class UpdateResource { + static readonly type = '[Registry Resources] Update Registry Resources'; + + constructor( + public registryId: string, + public resourceId: string, + public resource: AddResource + ) {} +} diff --git a/src/app/features/registry/store/registry-resources/registry-resources.model.ts b/src/app/features/registry/store/registry-resources/registry-resources.model.ts new file mode 100644 index 000000000..5f8bc44dc --- /dev/null +++ b/src/app/features/registry/store/registry-resources/registry-resources.model.ts @@ -0,0 +1,7 @@ +import { RegistryResource } from '@osf/features/registry/models/resources/registry-resource.model'; +import { AsyncStateModel } from '@shared/models'; + +export interface RegistryResourcesStateModel { + resources: AsyncStateModel; + currentResource: AsyncStateModel; +} diff --git a/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts b/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts new file mode 100644 index 000000000..6675ecc3a --- /dev/null +++ b/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts @@ -0,0 +1,28 @@ +import { Selector } from '@ngxs/store'; + +import { RegistryResource } from '@osf/features/registry/models'; +import { RegistryResourcesState } from '@osf/features/registry/store/registry-resources/registry-resources.state'; + +import { RegistryResourcesStateModel } from './registry-resources.model'; + +export class RegistryResourcesSelectors { + @Selector([RegistryResourcesState]) + static getResources(state: RegistryResourcesStateModel): RegistryResource[] | null { + return state.resources.data; + } + + @Selector([RegistryResourcesState]) + static isResourcesLoading(state: RegistryResourcesStateModel): boolean { + return state.resources.isLoading; + } + + @Selector([RegistryResourcesState]) + static getCurrentResource(state: RegistryResourcesStateModel): RegistryResource | null { + return state.currentResource.data; + } + + @Selector([RegistryResourcesState]) + static isCurrentResourceLoading(state: RegistryResourcesStateModel): boolean { + return state.currentResource.isLoading ?? false; + } +} diff --git a/src/app/features/registry/store/registry-resources/registry-resources.state.ts b/src/app/features/registry/store/registry-resources/registry-resources.state.ts new file mode 100644 index 000000000..d480b1d93 --- /dev/null +++ b/src/app/features/registry/store/registry-resources/registry-resources.state.ts @@ -0,0 +1,169 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { tap } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@core/handlers'; +import { RegistryResourcesService } from '@osf/features/registry/services'; +import { + AddRegistryResource, + ConfirmAddRegistryResource, + DeleteResource, + GetRegistryResources, + PreviewRegistryResource, + RegistryResourcesStateModel, + SilentDelete, + UpdateResource, +} from '@osf/features/registry/store/registry-resources'; + +@Injectable() +@State({ + name: 'registryResources', + defaults: { + resources: { + data: null, + isLoading: false, + error: null, + }, + currentResource: { + data: null, + isLoading: false, + error: null, + }, + }, +}) +export class RegistryResourcesState { + private readonly registryResourcesService = inject(RegistryResourcesService); + + @Action(GetRegistryResources) + getRegistryResources(ctx: StateContext, action: GetRegistryResources) { + const state = ctx.getState(); + ctx.patchState({ + resources: { + ...state.resources, + isLoading: true, + }, + }); + + return this.registryResourcesService.getResources(action.registryId).pipe( + tap((resources) => { + ctx.patchState({ + resources: { + data: resources, + isLoading: false, + error: null, + }, + }); + }), + catchError((err) => handleSectionError(ctx, 'resources', err)) + ); + } + + @Action(AddRegistryResource) + addRegistryResource(ctx: StateContext, action: AddRegistryResource) { + const state = ctx.getState(); + ctx.patchState({ + currentResource: { + ...state.currentResource, + isSubmitting: true, + }, + }); + + return this.registryResourcesService.addRegistryResource(action.registryId).pipe( + tap((resource) => { + ctx.patchState({ + currentResource: { + data: resource, + isSubmitting: false, + isLoading: false, + error: null, + }, + }); + }), + catchError((err) => handleSectionError(ctx, 'currentResource', err)) + ); + } + + @Action(PreviewRegistryResource) + previewRegistryResource(ctx: StateContext, action: PreviewRegistryResource) { + const state = ctx.getState(); + ctx.patchState({ + currentResource: { + ...state.currentResource, + isLoading: true, + }, + }); + + return this.registryResourcesService.previewRegistryResource(action.resourceId, action.resource).pipe( + tap((resource) => { + ctx.patchState({ + currentResource: { + data: resource, + isLoading: false, + error: null, + }, + }); + }), + catchError((err) => handleSectionError(ctx, 'resources', err)) + ); + } + + @Action(ConfirmAddRegistryResource) + confirmAddRegistryResource(ctx: StateContext, action: ConfirmAddRegistryResource) { + return this.registryResourcesService.confirmAddingResource(action.resourceId, action.resource).pipe( + tap(() => { + ctx.dispatch(new GetRegistryResources(action.registryId)); + }), + catchError((err) => handleSectionError(ctx, 'resources', err)) + ); + } + + @Action(DeleteResource) + deleteResource(ctx: StateContext, action: DeleteResource) { + const state = ctx.getState(); + ctx.patchState({ + resources: { + ...state.resources, + isLoading: true, + }, + }); + + return this.registryResourcesService.deleteResource(action.resourceId).pipe( + tap(() => { + ctx.dispatch(new GetRegistryResources(action.registryId)); + }), + catchError((err) => handleSectionError(ctx, 'resources', err)) + ); + } + + @Action(SilentDelete) + silentDelete(ctx: StateContext, action: SilentDelete) { + return this.registryResourcesService.deleteResource(action.resourceId); + } + + @Action(UpdateResource) + updateResource(ctx: StateContext, action: UpdateResource) { + const state = ctx.getState(); + ctx.patchState({ + currentResource: { + ...state.currentResource, + isLoading: true, + }, + }); + + return this.registryResourcesService.updateResource(action.resourceId, action.resource).pipe( + tap(() => { + ctx.patchState({ + currentResource: { + ...state.currentResource, + isLoading: false, + }, + }); + ctx.dispatch(new GetRegistryResources(action.registryId)); + }), + catchError((err) => handleSectionError(ctx, 'resources', err)) + ); + } +} diff --git a/src/app/shared/components/data-resources/data-resources.component.html b/src/app/shared/components/data-resources/data-resources.component.html index 66649cdeb..88a2da15b 100644 --- a/src/app/shared/components/data-resources/data-resources.component.html +++ b/src/app/shared/components/data-resources/data-resources.component.html @@ -1,5 +1,5 @@ @if (showButton()) { -
- +
+
} diff --git a/src/app/shared/components/sub-header/sub-header.component.ts b/src/app/shared/components/sub-header/sub-header.component.ts index 3e2fb91b0..135ae1c2b 100644 --- a/src/app/shared/components/sub-header/sub-header.component.ts +++ b/src/app/shared/components/sub-header/sub-header.component.ts @@ -20,5 +20,6 @@ export class SubHeaderComponent { tooltip = input(''); description = input(''); isLoading = input(false); + isButtonDisabled = input(false); buttonClick = output(); } diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index f4d0ac2f6..bbb8ad422 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -13,6 +13,7 @@ export * from './metadata-projects.enum'; export * from './profile-addons-stepper.enum'; export * from './profile-settings-key.enum'; export * from './registration-review-states.enum'; +export * from './registry-resource.enum'; export * from './registry-status.enum'; export * from './resource-search-mode.enum'; export * from './resource-tab.enum'; diff --git a/src/app/shared/enums/registry-resource.enum.ts b/src/app/shared/enums/registry-resource.enum.ts new file mode 100644 index 000000000..e31d20de7 --- /dev/null +++ b/src/app/shared/enums/registry-resource.enum.ts @@ -0,0 +1,7 @@ +export enum RegistryResourceType { + Data = 'data', + Code = 'analytic_code', + Materials = 'materials', + Papers = 'papers', + Supplements = 'supplements', +} diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index 2dfe49cfa..e1e7b2a46 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -179,7 +179,7 @@ export class FilesService { getProjectShortInfo(resourceId: string): Observable { const params = { - 'field[nodes]': 'title,description,date_created,date_mofified', + 'fields[nodes]': 'title,description,date_created,date_modified', embed: 'bibliographic_contributors', }; return this.#jsonApiService.get(`${environment.apiUrl}/nodes/${resourceId}/`, params); diff --git a/src/app/shared/utils/custom-form-validators.helper.ts b/src/app/shared/utils/custom-form-validators.helper.ts index 7afc05632..e3881aa2f 100644 --- a/src/app/shared/utils/custom-form-validators.helper.ts +++ b/src/app/shared/utils/custom-form-validators.helper.ts @@ -62,4 +62,13 @@ export class CustomValidators { return endDate > startDate ? null : { dateRangeInvalid: true }; }; + + static doiValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (!value) return null; + + const DOIRegex = /\b(10\.\d{4,}(?:\.\d+)*\/\S+(?:(?!["&'<>])\S))\b/; + const isValid = DOIRegex.test(value); + return isValid ? null : { invalidDoi: true }; + }; } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 38dfc1e3f..e14d85615 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -40,6 +40,7 @@ "view": "View", "review": "Review", "upload": "Upload", + "preview": "Preview", "continueUpdate": "Continue Update" }, "search": { @@ -92,7 +93,8 @@ "none": "None", "learnMore": "Learn More", "and": "and", - "more": "more" + "more": "more", + "data": "Data" }, "deleteConfirmation": { "header": "Delete", @@ -2302,5 +2304,35 @@ "downloadsLastDays": "Downloads (last 30 days)", "noData": "No preprints found" } + }, + "resources": { + "title": "Resources", + "description": "Link a DOI from a repository to your registration by clicking “Add resource” button. Contributors affirmed to adhere to the criteria for each badge.", + "add": "Add Resource", + "edit": "Edit Resource", + "delete": "Delete Resource", + "check": "Check your DOI for accuracy", + "deleteText": "Are you sure you want to delete resource", + "selectAResourceType": "Select A Resource Type", + "descriptionLabel": "Description(Optional)", + "typeOptions": { + "data": "Data", + "code": "Analytic Code", + "materials": "Materials", + "papers": "Papers", + "supplements": "Supplements" + }, + "toastMessages": { + "addResourceSuccess": "Added new resource", + "addResourceError": "Error occurred while adding new resource.", + "updatedResourceSuccess": "Updated resource.", + "updateResourceError": "Error occurred while updating new resource.", + "deletedResourceSuccess": "Resource is deleted successfully." + }, + "errors": { + "noRegistryId": "No registry ID found.", + "noCurrentResource": "No current resource.", + "doiValidation": "Please enter a valid DOI in the format: 10.xxxx/xxxxx" + } } }