From 60fd2680010fd0231c83520727d076b3ffe2a109 Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Tue, 22 Jul 2025 15:13:18 +0300 Subject: [PATCH 1/8] feat(registry-resources): resources ui draft --- src/app/features/registry/mappers/index.ts | 1 + .../mappers/registry-resource.mapper.ts | 12 +++++ src/app/features/registry/models/index.ts | 2 + .../get-registry-resources-json-api.model.ts | 16 ++++++ .../resources/registry-resource.model.ts | 9 ++++ src/app/features/registry/pages/index.ts | 1 + .../registry-resources.component.html | 34 +++++++++++++ .../registry-resources.component.scss | 11 ++++ .../registry-resources.component.spec.ts | 22 ++++++++ .../registry-resources.component.ts | 39 ++++++++++++++ src/app/features/registry/registry.routes.ts | 9 ++++ .../services/registry-resources.service.ts | 22 ++++++++ .../store/registry-resources/index.ts | 4 ++ .../registry-resources.actions.ts | 5 ++ .../registry-resources.model.ts | 6 +++ .../registry-resources.selectors.ts | 18 +++++++ .../registry-resources.state.ts | 51 +++++++++++++++++++ src/app/shared/enums/index.ts | 1 + .../shared/enums/registry-resource.enum.ts | 7 +++ src/assets/i18n/en.json | 11 +++- 20 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/app/features/registry/mappers/registry-resource.mapper.ts create mode 100644 src/app/features/registry/models/resources/get-registry-resources-json-api.model.ts create mode 100644 src/app/features/registry/models/resources/registry-resource.model.ts create mode 100644 src/app/features/registry/pages/registry-resources/registry-resources.component.html create mode 100644 src/app/features/registry/pages/registry-resources/registry-resources.component.scss create mode 100644 src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts create mode 100644 src/app/features/registry/pages/registry-resources/registry-resources.component.ts create mode 100644 src/app/features/registry/services/registry-resources.service.ts create mode 100644 src/app/features/registry/store/registry-resources/index.ts create mode 100644 src/app/features/registry/store/registry-resources/registry-resources.actions.ts create mode 100644 src/app/features/registry/store/registry-resources/registry-resources.model.ts create mode 100644 src/app/features/registry/store/registry-resources/registry-resources.selectors.ts create mode 100644 src/app/features/registry/store/registry-resources/registry-resources.state.ts create mode 100644 src/app/shared/enums/registry-resource.enum.ts diff --git a/src/app/features/registry/mappers/index.ts b/src/app/features/registry/mappers/index.ts index 2d7d7308c..0cdb15f6d 100644 --- a/src/app/features/registry/mappers/index.ts +++ b/src/app/features/registry/mappers/index.ts @@ -1,2 +1,3 @@ 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..047afb7b6 --- /dev/null +++ b/src/app/features/registry/mappers/registry-resource.mapper.ts @@ -0,0 +1,12 @@ +import { RegistryResourceDataJsonApi } from '@osf/features/registry/models/resources/get-registry-resources-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 05202a94a..3cabb4fe0 100644 --- a/src/app/features/registry/models/index.ts +++ b/src/app/features/registry/models/index.ts @@ -6,4 +6,6 @@ export * from './registry-institution.model'; export * from './registry-overview.models'; export * from './registry-schema-block.model'; export * from './registry-subject.model'; +export * from './resources/get-registry-resources-json-api.model'; +export * from './resources/registry-resource.model'; export * from './view-schema-block.model'; 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..7ca656187 --- /dev/null +++ b/src/app/features/registry/models/resources/get-registry-resources-json-api.model.ts @@ -0,0 +1,16 @@ +import { ApiData, JsonApiResponse } from '@core/models'; +import { RegistryResourceType } from '@shared/enums'; + +export type GetRegistryResourcesJsonApi = 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/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 de079877f..f6e90c529 100644 --- a/src/app/features/registry/pages/index.ts +++ b/src/app/features/registry/pages/index.ts @@ -1,2 +1,3 @@ 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-resources/registry-resources.component.html b/src/app/features/registry/pages/registry-resources/registry-resources.component.html new file mode 100644 index 000000000..fa17e032d --- /dev/null +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.html @@ -0,0 +1,34 @@ + + +@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/{{ resource.pid }} +
+ +
+ + +
+
+ } +
+
+} 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..fd90a6249 --- /dev/null +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts @@ -0,0 +1,39 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; + +import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { GetRegistryResources, RegistryResourcesSelectors } from '@osf/features/registry/store/registry-resources'; +import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; + +@Component({ + selector: 'osf-registry-resources', + imports: [SubHeaderComponent, TranslatePipe, Button, LoadingSpinnerComponent], + templateUrl: './registry-resources.component.html', + styleUrl: './registry-resources.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistryResourcesComponent { + @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; + private readonly route = inject(ActivatedRoute); + + protected readonly resources = select(RegistryResourcesSelectors.getResources); + protected readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); + + private readonly actions = createDispatchMap({ + getResources: GetRegistryResources, + }); + + constructor() { + this.route.parent?.params.subscribe((params) => { + const id = params['id']; + if (id) { + this.actions.getResources(id); + } + }); + } +} diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index fef5eec8f..dc6f635ac 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -9,6 +9,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 = [ @@ -49,6 +50,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/registry-resources.service.ts b/src/app/features/registry/services/registry-resources.service.ts new file mode 100644 index 000000000..2e5b01296 --- /dev/null +++ b/src/app/features/registry/services/registry-resources.service.ts @@ -0,0 +1,22 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@core/services'; +import { MapRegistryResource } from '@osf/features/registry/mappers'; +import { GetRegistryResourcesJsonApi, RegistryResource } from '@osf/features/registry/models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class RegistryResourcesService { + private jsonApiService = inject(JsonApiService); + + getResources(registryId: string): Observable { + return this.jsonApiService + .get(`${environment.apiUrl}/registrations/${registryId}/resources/?page=1`) + .pipe(map((response) => response.data.map((resource) => MapRegistryResource(resource)))); + } +} 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..35f590374 --- /dev/null +++ b/src/app/features/registry/store/registry-resources/registry-resources.actions.ts @@ -0,0 +1,5 @@ +export class GetRegistryResources { + static readonly type = '[Registry Resources] Get Registry Resources'; + + constructor(public registryId: string) {} +} 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..81bb0c47d --- /dev/null +++ b/src/app/features/registry/store/registry-resources/registry-resources.model.ts @@ -0,0 +1,6 @@ +import { RegistryResource } from '@osf/features/registry/models/resources/registry-resource.model'; +import { AsyncStateModel } from '@shared/models'; + +export interface RegistryResourcesStateModel { + resources: 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..af5894eb4 --- /dev/null +++ b/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts @@ -0,0 +1,18 @@ +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; + } +} 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..0cd8e5571 --- /dev/null +++ b/src/app/features/registry/store/registry-resources/registry-resources.state.ts @@ -0,0 +1,51 @@ +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/registry-resources.service'; +import { GetRegistryResources, RegistryResourcesStateModel } from '@osf/features/registry/store/registry-resources'; + +@Injectable() +@State({ + name: 'registryResources', + defaults: { + resources: { + 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({ + next: (resources) => { + ctx.patchState({ + resources: { + data: resources, + isLoading: false, + error: null, + }, + }); + }, + }), + catchError((err) => handleSectionError(ctx, 'resources', err)) + ); + } +} diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index 64dbb2988..974ca92fe 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -11,6 +11,7 @@ export * from './filter-type.enum'; export * from './get-resources-request-type.enum'; export * from './profile-addons-stepper.enum'; export * from './registration-review-states.enum'; +export * from './registry-resource.enum'; export * from './registry-status.enum'; export * from './resource-tab.enum'; export * from './resource-type.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/assets/i18n/en.json b/src/assets/i18n/en.json index 56a20cb47..ef0d203a3 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -91,7 +91,8 @@ "none": "None", "learnMore": "Learn More", "and": "and", - "more": "more" + "more": "more", + "data": "Data" }, "deleteConfirmation": { "header": "Delete", @@ -2214,5 +2215,13 @@ "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", + "delete": "Delete Resource", + "check": "Check your DOI for accuracy", + "deleteText": "Are you sure you want to delete resource" } } From 5f64001e9275e6993926c494e127d5b66307e95a Mon Sep 17 00:00:00 2001 From: Andrii Pasternak Date: Thu, 24 Jul 2025 11:26:54 +0300 Subject: [PATCH 2/8] feat(registry-resources): added resource dialog --- .../add-resource-dialog.component.html | 65 ++++++++++++ .../add-resource-dialog.component.scss | 0 .../add-resource-dialog.component.spec.ts | 22 +++++ .../add-resource-dialog.component.ts | 98 +++++++++++++++++++ src/app/features/registry/components/index.ts | 1 + .../resource-type-options.constant.ts | 25 +++++ .../resources/add-resource-request.model.ts | 6 ++ .../add-resource-response-json-api.model.ts | 16 +++ .../models/resources/add-resource.model.ts | 5 + .../resources/confirm-add-resource.model.ts | 3 + .../registry-resources.component.html | 1 + .../registry-resources.component.ts | 43 +++++++- .../services/registry-resources.service.ts | 24 +++++ .../registry-resources.actions.ts | 16 +++ .../registry-resources.state.ts | 66 ++++++++++++- src/assets/i18n/en.json | 18 +++- src/assets/styles/overrides/button.scss | 12 +++ 17 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html create mode 100644 src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.scss create mode 100644 src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts create mode 100644 src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts create mode 100644 src/app/features/registry/constants/resource-type-options.constant.ts create mode 100644 src/app/features/registry/models/resources/add-resource-request.model.ts create mode 100644 src/app/features/registry/models/resources/add-resource-response-json-api.model.ts create mode 100644 src/app/features/registry/models/resources/add-resource.model.ts create mode 100644 src/app/features/registry/models/resources/confirm-add-resource.model.ts 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..128777f52 --- /dev/null +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html @@ -0,0 +1,65 @@ +@if (isPreviewMode()) { +
+

Check your DOI for accuracy.

+ + @let resourceType = form.controls['resourceType'].value; + @let iconName = resourceType === 'analytic_code' ? 'code' : resourceType; + @let icon = `assets/icons/colored/${iconName}-colored.svg`; + @let resourceName = resourceType === 'analytic_code' ? 'Analytic Code' : resourceType; + @let description = form.controls['description'].value; + +
+ resource-type icon +
+ +

{{ 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..e69de29bb 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..03bee9033 --- /dev/null +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts @@ -0,0 +1,98 @@ +import { createDispatchMap } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Textarea } from 'primeng/textarea'; + +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { Primitive } from '@core/helpers'; +import { resourceTypeOptions } from '@osf/features/registry/constants/resource-type-options.constant'; +import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; +import { AddResourceRequest } from '@osf/features/registry/models/resources/add-resource-request.model'; +import { ConfirmAddResource } from '@osf/features/registry/models/resources/confirm-add-resource.model'; +import { AddRegistryResource, ConfirmAddRegistryResource } from '@osf/features/registry/store/registry-resources'; +import { SelectComponent, TextInputComponent } from '@shared/components'; +import { InputLimits } from '@shared/constants'; +import { RegistryResourceType } from '@shared/enums'; +import { SelectOption } from '@shared/models'; + +@Component({ + selector: 'osf-add-resource-dialog', + imports: [Button, TextInputComponent, TranslatePipe, ReactiveFormsModule, Textarea, SelectComponent], + templateUrl: './add-resource-dialog.component.html', + styleUrl: './add-resource-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddResourceDialogComponent { + private dialogConfig = inject(DynamicDialogConfig); + private registryId: string = this.dialogConfig.data.id; + protected readonly dialogRef = inject(DynamicDialogRef); + + protected inputLimits = InputLimits; + + protected form = new FormGroup({ + pid: new FormControl('', [Validators.required]), + resourceType: new FormControl('', [Validators.required]), + description: new FormControl(''), + }); + + private readonly actions = createDispatchMap({ + addResource: AddRegistryResource, + confirmAddResource: ConfirmAddRegistryResource, + }); + + public selectedResourceType = signal(null); + public resourceOptions = signal(resourceTypeOptions); + public isPreviewMode = signal(false); + + previewResource(): void { + if (this.form.invalid) { + return; + } + + this.isPreviewMode.set(true); + const addResource: AddResource = { + pid: this.form.controls['pid'].value ?? '', + resourceType: this.form.controls['resourceType'].value ?? '', + description: this.form.controls['description'].value ?? '', + }; + const request: AddResourceRequest = { + attributes: addResource, + id: this.registryId, + relationships: {}, + type: 'resources', + }; + this.actions.addResource(request); + } + + backToEdit() { + this.isPreviewMode.set(false); + } + + onAddResource() { + const addResource: ConfirmAddResource = { + finalized: true, + }; + const request: AddResourceRequest = { + attributes: addResource, + id: this.registryId, + relationships: {}, + type: 'resources', + }; + this.actions.confirmAddResource(request).subscribe({ + next: () => { + this.dialogRef.close(); + }, + }); + } + + changeType($event: Primitive) { + this.form.patchValue({ + resourceType: $event?.toString(), + }); + } +} diff --git a/src/app/features/registry/components/index.ts b/src/app/features/registry/components/index.ts index 3136382aa..14bdae137 100644 --- a/src/app/features/registry/components/index.ts +++ b/src/app/features/registry/components/index.ts @@ -1,3 +1,4 @@ +export * from './add-resource-dialog/add-resource-dialog.component'; export * from './registry-revisions/registry-revisions.component'; export * from './registry-statuses/registry-statuses.component'; export * from './withdraw-dialog/withdraw-dialog.component'; 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/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..d1c9c0958 --- /dev/null +++ b/src/app/features/registry/models/resources/add-resource.model.ts @@ -0,0 +1,5 @@ +export interface AddResource { + pid: string; + resourceType: 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/pages/registry-resources/registry-resources.component.html b/src/app/features/registry/pages/registry-resources/registry-resources.component.html index fa17e032d..7f9d087c6 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.html +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.html @@ -2,6 +2,7 @@ [title]="'resources.title' | translate" [showButton]="true" [buttonLabel]="'resources.add' | translate" + (buttonClick)="addResource()" /> @if (isResourcesLoading()) { 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 index fd90a6249..dd1d50118 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts @@ -1,14 +1,21 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; +import { DialogService } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { GetRegistryResources, RegistryResourcesSelectors } from '@osf/features/registry/store/registry-resources'; +import { AddResourceDialogComponent } from '@osf/features/registry/components/add-resource-dialog/add-resource-dialog.component'; +import { + AddRegistryResource, + GetRegistryResources, + RegistryResourcesSelectors, +} from '@osf/features/registry/store/registry-resources'; import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; +import { ToastService } from '@shared/services'; @Component({ selector: 'osf-registry-resources', @@ -16,24 +23,50 @@ import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components' 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); protected readonly resources = select(RegistryResourcesSelectors.getResources); protected readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); + private registryId = ''; private readonly actions = createDispatchMap({ getResources: GetRegistryResources, + addResource: AddRegistryResource, }); constructor() { this.route.parent?.params.subscribe((params) => { - const id = params['id']; - if (id) { - this.actions.getResources(id); + this.registryId = params['id']; + if (this.registryId) { + this.actions.getResources(this.registryId); } }); } + + addResource() { + const dialogRef = this.dialogService.open(AddResourceDialogComponent, { + header: this.translateService.instant('resource.add'), + width: '500px', + focusOnShow: false, + closeOnEscape: true, + modal: true, + closable: true, + data: { id: this.registryId }, + }); + + dialogRef.onClose.subscribe({ + next: () => { + this.toastService.showSuccess(this.translateService.instant('resource.toastMessages.addResourceSuccess')); + }, + error: () => + this.toastService.showError(this.translateService.instant('resource.toastMessages.addResourceError')), + }); + } } diff --git a/src/app/features/registry/services/registry-resources.service.ts b/src/app/features/registry/services/registry-resources.service.ts index 2e5b01296..785d1d27d 100644 --- a/src/app/features/registry/services/registry-resources.service.ts +++ b/src/app/features/registry/services/registry-resources.service.ts @@ -5,6 +5,10 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services'; import { MapRegistryResource } 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 { AddResourceRequest } from '@osf/features/registry/models/resources/add-resource-request.model'; +import { AddResourceJsonApi } 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'; @@ -19,4 +23,24 @@ export class RegistryResourcesService { .get(`${environment.apiUrl}/registrations/${registryId}/resources/?page=1`) .pipe(map((response) => response.data.map((resource) => MapRegistryResource(resource)))); } + + addRegistryResource(resource: AddResourceRequest): Observable { + return this.jsonApiService + .patch(`${environment.apiUrl}/resources/${resource.id}`, resource) + .pipe( + map((response) => { + return MapRegistryResource(response.data); + }) + ); + } + + confirmAddingResource(resource: AddResourceRequest): Observable { + return this.jsonApiService + .patch(`${environment.apiUrl}/resources/${resource.id}`, resource) + .pipe( + map((response) => { + return MapRegistryResource(response.data); + }) + ); + } } 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 index 35f590374..233474929 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.actions.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.actions.ts @@ -1,5 +1,21 @@ +import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; +import { AddResourceRequest } from '@osf/features/registry/models/resources/add-resource-request.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 resource: AddResourceRequest) {} +} + +export class ConfirmAddRegistryResource { + static readonly type = '[Registry Resources] Confirm Add Registry Resources'; + + constructor(public resource: AddResourceRequest) {} +} 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 index 0cd8e5571..3889d8ef7 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.state.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.state.ts @@ -7,7 +7,12 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@core/handlers'; import { RegistryResourcesService } from '@osf/features/registry/services/registry-resources.service'; -import { GetRegistryResources, RegistryResourcesStateModel } from '@osf/features/registry/store/registry-resources'; +import { + AddRegistryResource, + ConfirmAddRegistryResource, + GetRegistryResources, + RegistryResourcesStateModel, +} from '@osf/features/registry/store/registry-resources'; @Injectable() @State({ @@ -48,4 +53,63 @@ export class RegistryResourcesState { catchError((err) => handleSectionError(ctx, 'resources', err)) ); } + + @Action(AddRegistryResource) + addRegistryResource(ctx: StateContext, action: AddRegistryResource) { + const state = ctx.getState(); + ctx.patchState({ + resources: { + ...state.resources, + isLoading: true, + }, + }); + + return this.registryResourcesService.addRegistryResource(action.resource).pipe( + tap({ + next: (resource) => { + const { resources } = ctx.getState(); + + const oldData = resources.data ?? []; + + ctx.patchState({ + resources: { + data: [...oldData, resource], + isLoading: false, + error: null, + }, + }); + }, + }), + catchError((err) => handleSectionError(ctx, 'resources', err)) + ); + } + @Action(ConfirmAddRegistryResource) + confirmAddRegistryResource(ctx: StateContext, action: ConfirmAddRegistryResource) { + const state = ctx.getState(); + ctx.patchState({ + resources: { + ...state.resources, + isLoading: true, + }, + }); + + return this.registryResourcesService.confirmAddingResource(action.resource).pipe( + tap({ + next: (resource) => { + const { resources } = ctx.getState(); + + const oldData = resources.data ?? []; + + ctx.patchState({ + resources: { + data: [...oldData, resource], + isLoading: false, + error: null, + }, + }); + }, + }), + catchError((err) => handleSectionError(ctx, 'resources', err)) + ); + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index ef0d203a3..89809e467 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -39,7 +39,8 @@ "submit": "Submit", "view": "View", "review": "Review", - "upload": "Upload" + "upload": "Upload", + "preview": "Preview" }, "search": { "title": "Search", @@ -2222,6 +2223,19 @@ "add": "Add Resource", "delete": "Delete Resource", "check": "Check your DOI for accuracy", - "deleteText": "Are you sure you want to delete resource" + "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" + } } } diff --git a/src/assets/styles/overrides/button.scss b/src/assets/styles/overrides/button.scss index cbd3f8fa1..af8d0699f 100644 --- a/src/assets/styles/overrides/button.scss +++ b/src/assets/styles/overrides/button.scss @@ -50,3 +50,15 @@ --p-button-text-danger-color: var(--red-3); } } + +.widen-buttons { + p-button { + width: 100%; + .p-button { + width: 100%; + } + button { + width: 100%; + } + } +} From d43328028df4d28006a464b851144ce0303d6c13 Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Mon, 28 Jul 2025 17:46:11 +0300 Subject: [PATCH 3/8] feat(registry-resources): add, delete, update, responsivness --- .../add-resource-dialog.component.html | 20 ++-- .../add-resource-dialog.component.scss | 8 ++ .../add-resource-dialog.component.ts | 71 +++++++++--- .../edit-resource-dialog.component.html | 38 ++++++ .../edit-resource-dialog.component.scss | 0 .../edit-resource-dialog.component.spec.ts | 22 ++++ .../edit-resource-dialog.component.ts | 108 ++++++++++++++++++ .../models/resources/add-resource.model.ts | 2 +- .../registry-overview.component.ts | 2 +- .../registry-resources.component.html | 38 ++++-- .../registry-resources.component.ts | 83 ++++++++++++-- .../services/registry-resources.service.ts | 69 +++++++++-- .../registry-resources.actions.ts | 29 ++++- .../registry-resources.model.ts | 1 + .../registry-resources.selectors.ts | 10 ++ .../registry-resources.state.ts | 98 ++++++++++++---- .../data-resources.component.html | 10 +- .../sub-header/sub-header.component.html | 10 +- .../sub-header/sub-header.component.ts | 1 + src/app/shared/services/files.service.ts | 2 +- src/assets/i18n/en.json | 5 +- 21 files changed, 542 insertions(+), 85 deletions(-) create mode 100644 src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.html create mode 100644 src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.scss create mode 100644 src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts create mode 100644 src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts 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 index 128777f52..1749b45a4 100644 --- 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 @@ -1,21 +1,22 @@ -@if (isPreviewMode()) { +@if (isCurrentResourceLoading() || isResourceConfirming()) { + +} @else if (isPreviewMode() && currentResource()) {
-

Check your DOI for accuracy.

+

{{ 'resources.check' | translate }}

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

{{ description }}

+

{{ currentResource()?.description }}

@@ -32,7 +33,7 @@

{{ resourceName }}

[control]="form.controls['pid']" [label]="'DOI'" [placeholder]="'https://doi.org/'" - [maxLength]="inputLimits.link.maxLength" + [maxLength]="inputLimits.name.maxLength" > @@ -56,7 +57,6 @@

{{ resourceName }}

>
-
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 index e69de29bb..9b376173d 100644 --- 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 @@ -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.ts b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts index 03bee9033..dd38f64f0 100644 --- 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 @@ -1,4 +1,4 @@ -import { createDispatchMap } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -6,6 +6,8 @@ import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { Textarea } from 'primeng/textarea'; +import { finalize, take } from 'rxjs'; + import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -14,25 +16,41 @@ import { resourceTypeOptions } from '@osf/features/registry/constants/resource-t import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; import { AddResourceRequest } from '@osf/features/registry/models/resources/add-resource-request.model'; import { ConfirmAddResource } from '@osf/features/registry/models/resources/confirm-add-resource.model'; -import { AddRegistryResource, ConfirmAddRegistryResource } from '@osf/features/registry/store/registry-resources'; -import { SelectComponent, TextInputComponent } from '@shared/components'; +import { + ConfirmAddRegistryResource, + PreviewRegistryResource, + RegistryResourcesSelectors, +} from '@osf/features/registry/store/registry-resources'; +import { LoadingSpinnerComponent, SelectComponent, TextInputComponent } from '@shared/components'; import { InputLimits } from '@shared/constants'; import { RegistryResourceType } from '@shared/enums'; import { SelectOption } from '@shared/models'; @Component({ selector: 'osf-add-resource-dialog', - imports: [Button, TextInputComponent, TranslatePipe, ReactiveFormsModule, Textarea, SelectComponent], + imports: [ + Button, + TextInputComponent, + TranslatePipe, + ReactiveFormsModule, + Textarea, + SelectComponent, + LoadingSpinnerComponent, + ], 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 dialogConfig = inject(DynamicDialogConfig); private registryId: string = this.dialogConfig.data.id; - protected readonly dialogRef = inject(DynamicDialogRef); protected inputLimits = InputLimits; + protected isResourceConfirming = signal(false); protected form = new FormGroup({ pid: new FormControl('', [Validators.required]), @@ -41,7 +59,7 @@ export class AddResourceDialogComponent { }); private readonly actions = createDispatchMap({ - addResource: AddRegistryResource, + previewResource: PreviewRegistryResource, confirmAddResource: ConfirmAddRegistryResource, }); @@ -54,19 +72,27 @@ export class AddResourceDialogComponent { return; } - this.isPreviewMode.set(true); const addResource: AddResource = { pid: this.form.controls['pid'].value ?? '', - resourceType: this.form.controls['resourceType'].value ?? '', + resource_type: this.form.controls['resourceType'].value ?? '', description: this.form.controls['description'].value ?? '', }; + + const currentResource = this.currentResource(); + if (!currentResource) { + throw new Error('No current resource.'); + } + const request: AddResourceRequest = { attributes: addResource, - id: this.registryId, + id: currentResource?.id, relationships: {}, type: 'resources', }; - this.actions.addResource(request); + + this.actions.previewResource(request).subscribe(() => { + this.isPreviewMode.set(true); + }); } backToEdit() { @@ -77,17 +103,28 @@ export class AddResourceDialogComponent { const addResource: ConfirmAddResource = { finalized: true, }; + const currentResource = this.currentResource(); + if (!currentResource) { + throw new Error('No current resource.'); + } + + this.isResourceConfirming.set(true); const request: AddResourceRequest = { attributes: addResource, - id: this.registryId, + id: currentResource.id, relationships: {}, type: 'resources', }; - this.actions.confirmAddResource(request).subscribe({ - next: () => { - this.dialogRef.close(); - }, - }); + this.actions + .confirmAddResource(request, this.registryId) + .pipe( + take(1), + finalize(() => { + this.dialogRef.close(true); + this.isResourceConfirming.set(false); + }) + ) + .subscribe({}); } changeType($event: Primitive) { @@ -95,4 +132,6 @@ export class AddResourceDialogComponent { resourceType: $event?.toString(), }); } + + protected readonly RegistryResourceType = RegistryResourceType; } 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..70b30dd0d --- /dev/null +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.html @@ -0,0 +1,38 @@ +@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..20af05108 --- /dev/null +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts @@ -0,0 +1,108 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Textarea } from 'primeng/textarea'; + +import { finalize, take } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { Primitive } from '@core/helpers'; +import { resourceTypeOptions } from '@osf/features/registry/constants/resource-type-options.constant'; +import { RegistryResource } from '@osf/features/registry/models'; +import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; +import { AddResourceRequest } from '@osf/features/registry/models/resources/add-resource-request.model'; +import { RegistryResourcesSelectors, UpdateResource } from '@osf/features/registry/store/registry-resources'; +import { LoadingSpinnerComponent, SelectComponent, TextInputComponent } from '@shared/components'; +import { InputLimits } from '@shared/constants'; +import { RegistryResourceType } from '@shared/enums'; +import { SelectOption } from '@shared/models'; + +@Component({ + selector: 'osf-edit-resource-dialog', + imports: [ + LoadingSpinnerComponent, + TextInputComponent, + SelectComponent, + Textarea, + ReactiveFormsModule, + Button, + TranslatePipe, + ], + 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 dialogConfig = inject(DynamicDialogConfig); + private registryId: string = this.dialogConfig.data.id; + private resource: RegistryResource = this.dialogConfig.data.resource as RegistryResource; + protected inputLimits = InputLimits; + public selectedResourceType = signal(null); + public resourceOptions = signal(resourceTypeOptions); + + protected form = new FormGroup({ + pid: new FormControl('', [Validators.required]), + 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 || '', + }); + + this.selectedResourceType.set(this.resource.type || null); + } + + 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('No current resource id.'); + } + + const request: AddResourceRequest = { + attributes: addResource, + id: this.resource.id, + relationships: {}, + type: 'resources', + }; + + this.actions + .updateResource(this.registryId, request) + .pipe( + take(1), + finalize(() => { + this.dialogRef.close(true); + }) + ) + .subscribe(); + } + changeType($event: Primitive) { + this.form.patchValue({ + resourceType: $event?.toString(), + }); + } +} diff --git a/src/app/features/registry/models/resources/add-resource.model.ts b/src/app/features/registry/models/resources/add-resource.model.ts index d1c9c0958..451c81520 100644 --- a/src/app/features/registry/models/resources/add-resource.model.ts +++ b/src/app/features/registry/models/resources/add-resource.model.ts @@ -1,5 +1,5 @@ export interface AddResource { pid: string; - resourceType: string; + resource_type: string; description?: string; } 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 index 7f9d087c6..c9f73bfe8 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.html +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.html @@ -1,6 +1,7 @@ @@ -13,20 +14,35 @@
@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 +
+
+ @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/{{ resource.pid }} +
+

{{ resourceName }}

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

{{ resource.description }}

+
-
- - +
+ +
} 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 index dd1d50118..c9513f4bc 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts @@ -5,17 +5,22 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DialogService } from 'primeng/dynamicdialog'; -import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core'; +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, } from '@osf/features/registry/store/registry-resources'; import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; -import { ToastService } from '@shared/services'; +import { CustomConfirmationService, ToastService } from '@shared/services'; @Component({ selector: 'osf-registry-resources', @@ -31,14 +36,17 @@ export class RegistryResourcesComponent { 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 actions = createDispatchMap({ getResources: GetRegistryResources, addResource: AddRegistryResource, + deleteResource: DeleteResource, }); constructor() { @@ -51,22 +59,81 @@ export class RegistryResourcesComponent { } addResource() { - const dialogRef = this.dialogService.open(AddResourceDialogComponent, { - header: this.translateService.instant('resource.add'), + if (!this.registryId) { + throw new Error('No registry ID found.'); + } + + 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( + this.translateService.instant('resources.toastMessages.addResourceSuccess') + ); + } + }, + error: () => + this.toastService.showError(this.translateService.instant('resources.toastMessages.addResourceError')), + }); + }); + } + + updateResource(resource: RegistryResource) { + if (!this.registryId) { + throw new Error('No registry ID found.'); + } + + 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 }, + data: { id: this.registryId, resource: resource }, }); dialogRef.onClose.subscribe({ - next: () => { - this.toastService.showSuccess(this.translateService.instant('resource.toastMessages.addResourceSuccess')); + next: (res) => { + if (res) { + this.toastService.showSuccess( + this.translateService.instant('resources.toastMessages.updatedResourceSuccess') + ); + } }, error: () => - this.toastService.showError(this.translateService.instant('resource.toastMessages.addResourceError')), + this.toastService.showError(this.translateService.instant('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); + }, }); } } diff --git a/src/app/features/registry/services/registry-resources.service.ts b/src/app/features/registry/services/registry-resources.service.ts index 785d1d27d..0f626cbf5 100644 --- a/src/app/features/registry/services/registry-resources.service.ts +++ b/src/app/features/registry/services/registry-resources.service.ts @@ -7,7 +7,10 @@ import { MapRegistryResource } 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 { AddResourceRequest } from '@osf/features/registry/models/resources/add-resource-request.model'; -import { AddResourceJsonApi } from '@osf/features/registry/models/resources/add-resource-response-json-api.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'; @@ -19,28 +22,80 @@ 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`) + .get(`${environment.apiUrl}/registrations/${registryId}/resources/?page=1`, params) .pipe(map((response) => response.data.map((resource) => MapRegistryResource(resource)))); } - addRegistryResource(resource: AddResourceRequest): Observable { + addRegistryResource(registryId: string): Observable { + const body = { + data: { + relationships: { + registration: { + data: { + type: 'registrations', + id: registryId, + }, + }, + }, + type: 'resources', + }, + }; + + return this.jsonApiService.post(`${environment.apiUrl}/resources/`, body).pipe( + map((response) => { + return MapRegistryResource(response.data); + }) + ); + } + + previewRegistryResource(resource: AddResourceRequest): Observable { + const payload = { + data: { + ...resource, + }, + }; + return this.jsonApiService - .patch(`${environment.apiUrl}/resources/${resource.id}`, resource) + .patch(`${environment.apiUrl}/resources/${resource.id}`, payload) .pipe( map((response) => { - return MapRegistryResource(response.data); + return MapRegistryResource(response); }) ); } confirmAddingResource(resource: AddResourceRequest): Observable { + const payload = { + data: { + ...resource, + }, + }; + return this.jsonApiService - .patch(`${environment.apiUrl}/resources/${resource.id}`, resource) + .patch(`${environment.apiUrl}/resources/${resource.id}`, payload) .pipe( map((response) => { - return MapRegistryResource(response.data); + return MapRegistryResource(response); }) ); } + + deleteResource(resourceId: string): Observable { + return this.jsonApiService.delete(`${environment.apiUrl}/resources/${resourceId}`); + } + + updateResource(resource: AddResourceRequest) { + const payload = { + data: { + ...resource, + }, + }; + + return this.jsonApiService.patch(`${environment.apiUrl}/resources/${resource.id}`, payload); + } } 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 index 233474929..54752ec91 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.actions.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.actions.ts @@ -11,11 +11,38 @@ export class GetRegistryResources { 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 resource: AddResourceRequest) {} } export class ConfirmAddRegistryResource { static readonly type = '[Registry Resources] Confirm Add Registry Resources'; - constructor(public resource: AddResourceRequest) {} + constructor( + public resource: AddResourceRequest, + public registryId: string + ) {} +} + +export class DeleteResource { + static readonly type = '[Registry Resources] Delete Registry Resources'; + + constructor( + public resourceId: string, + public registryId: string + ) {} +} + +export class UpdateResource { + static readonly type = '[Registry Resources] Update Registry Resources'; + + constructor( + public registryId: string, + public resource: AddResourceRequest + ) {} } 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 index 81bb0c47d..5f8bc44dc 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.model.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.model.ts @@ -3,4 +3,5 @@ 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 index af5894eb4..6675ecc3a 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts @@ -15,4 +15,14 @@ export class RegistryResourcesSelectors { 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 index 3889d8ef7..a3aba56af 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.state.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.state.ts @@ -10,8 +10,11 @@ import { RegistryResourcesService } from '@osf/features/registry/services/regist import { AddRegistryResource, ConfirmAddRegistryResource, + DeleteResource, GetRegistryResources, + PreviewRegistryResource, RegistryResourcesStateModel, + UpdateResource, } from '@osf/features/registry/store/registry-resources'; @Injectable() @@ -23,6 +26,11 @@ import { isLoading: false, error: null, }, + currentResource: { + data: null, + isLoading: false, + error: null, + }, }, }) export class RegistryResourcesState { @@ -58,22 +66,45 @@ export class RegistryResourcesState { addRegistryResource(ctx: StateContext, action: AddRegistryResource) { const state = ctx.getState(); ctx.patchState({ - resources: { - ...state.resources, - isLoading: true, + currentResource: { + ...state.currentResource, + isSubmitting: true, }, }); - return this.registryResourcesService.addRegistryResource(action.resource).pipe( + return this.registryResourcesService.addRegistryResource(action.registryId).pipe( tap({ next: (resource) => { - const { resources } = ctx.getState(); + ctx.patchState({ + currentResource: { + data: resource, + isSubmitting: false, + isLoading: false, + error: null, + }, + }); + }, + }), + catchError((err) => handleSectionError(ctx, 'currentResource', err)) + ); + } - const oldData = resources.data ?? []; + @Action(PreviewRegistryResource) + previewRegistryResource(ctx: StateContext, action: PreviewRegistryResource) { + const state = ctx.getState(); + ctx.patchState({ + currentResource: { + ...state.currentResource, + isLoading: true, + }, + }); + return this.registryResourcesService.previewRegistryResource(action.resource).pipe( + tap({ + next: (resource) => { ctx.patchState({ - resources: { - data: [...oldData, resource], + currentResource: { + data: resource, isLoading: false, error: null, }, @@ -83,8 +114,21 @@ export class RegistryResourcesState { catchError((err) => handleSectionError(ctx, 'resources', err)) ); } + @Action(ConfirmAddRegistryResource) confirmAddRegistryResource(ctx: StateContext, action: ConfirmAddRegistryResource) { + return this.registryResourcesService.confirmAddingResource(action.resource).pipe( + tap({ + next: () => { + 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: { @@ -93,21 +137,33 @@ export class RegistryResourcesState { }, }); - return this.registryResourcesService.confirmAddingResource(action.resource).pipe( - tap({ - next: (resource) => { - const { resources } = ctx.getState(); + return this.registryResourcesService.deleteResource(action.resourceId).pipe( + tap(() => { + ctx.dispatch(new GetRegistryResources(action.registryId)); + }), + catchError((err) => handleSectionError(ctx, 'resources', err)) + ); + } - const oldData = resources.data ?? []; + @Action(UpdateResource) + updateResource(ctx: StateContext, action: UpdateResource) { + const state = ctx.getState(); + ctx.patchState({ + currentResource: { + ...state.currentResource, + isLoading: true, + }, + }); - ctx.patchState({ - resources: { - data: [...oldData, resource], - isLoading: false, - error: null, - }, - }); - }, + return this.registryResourcesService.updateResource(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 ec31469bb..3a9ab4ea7 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/services/files.service.ts b/src/app/shared/services/files.service.ts index fd33304ab..e31e55d9a 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -154,7 +154,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/assets/i18n/en.json b/src/assets/i18n/en.json index 89809e467..270164b2b 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2221,6 +2221,7 @@ "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", @@ -2235,7 +2236,9 @@ }, "toastMessages": { "addResourceSuccess": "Added new resource", - "addResourceError": "Error occurred while adding new resource" + "addResourceError": "Error occurred while adding new resource", + "updatedResourceSuccess": "Updated new resource", + "updateResourceError": "Error occurred while updating new resource" } } } From db9700e664b115390826df1fa6d8665a45ab7e65 Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Mon, 28 Jul 2025 17:53:39 +0300 Subject: [PATCH 4/8] fix(registry-resources): minor fix --- .../add-resource-dialog.component.html | 27 ++++++++++++++----- .../edit-resource-dialog.component.html | 11 +++++--- 2 files changed, 29 insertions(+), 9 deletions(-) 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 index 49efa65f6..6fe2763de 100644 --- 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 @@ -20,9 +20,19 @@

{{ resourceName }}

-
- - +
+ +
} @else { @@ -56,9 +66,14 @@

{{ resourceName }}

>
-
- - +
+ +
} 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 index 488b1dba0..e2d15e422 100644 --- 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 @@ -30,9 +30,14 @@ >
-
- - +
+ +
} From eb8b1d78c57daa10b66749e41d70cbbbe5cb5fcd Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Tue, 29 Jul 2025 10:56:41 +0300 Subject: [PATCH 5/8] fix(registry-resources): fixes --- .../add-resource-dialog.component.html | 8 +-- .../add-resource-dialog.component.ts | 44 ++++-------- .../edit-resource-dialog.component.html | 7 +- .../edit-resource-dialog.component.ts | 31 ++------ src/app/features/registry/constants/index.ts | 1 + .../mappers/add-resource-request.mapper.ts | 23 ++++++ src/app/features/registry/mappers/index.ts | 1 + .../mappers/registry-resource.mapper.ts | 2 +- src/app/features/registry/models/index.ts | 6 ++ .../get-registry-resources-json-api.model.ts | 16 +---- .../registry-resources.component.ts | 25 ++++--- src/app/features/registry/services/index.ts | 1 + .../services/registry-resources.service.ts | 33 +++------ .../registry-resources.actions.ts | 12 ++-- .../registry-resources.state.ts | 70 ++++++++----------- .../data-resources.component.html | 10 +-- .../data-resources.component.ts | 4 ++ src/assets/i18n/en.json | 11 ++- src/assets/styles/overrides/button.scss | 2 +- 19 files changed, 139 insertions(+), 168 deletions(-) create mode 100644 src/app/features/registry/constants/index.ts create mode 100644 src/app/features/registry/mappers/add-resource-request.mapper.ts 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 index 6fe2763de..ddbe3fd76 100644 --- 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 @@ -37,7 +37,6 @@

{{ resourceName }}

} @else {
- {{ resourceName }} > -
- + -
- -
- - -
- + } 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 index 3fdd03e34..1922afc1f 100644 --- 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 @@ -11,6 +11,7 @@ import { finalize, take } from 'rxjs'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ResourceFormComponent } from '@osf/features/registry/components'; import { resourceTypeOptions } from '@osf/features/registry/constants'; import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; import { ConfirmAddResource } from '@osf/features/registry/models/resources/confirm-add-resource.model'; @@ -18,6 +19,7 @@ import { ConfirmAddRegistryResource, PreviewRegistryResource, RegistryResourcesSelectors, + SilentDelete, } from '@osf/features/registry/store/registry-resources'; import { FormSelectComponent, LoadingSpinnerComponent, TextInputComponent } from '@shared/components'; import { InputLimits } from '@shared/constants'; @@ -34,6 +36,7 @@ import { SelectOption } from '@shared/models'; Textarea, LoadingSpinnerComponent, FormSelectComponent, + ResourceFormComponent, ], templateUrl: './add-resource-dialog.component.html', styleUrl: './add-resource-dialog.component.scss', @@ -52,14 +55,15 @@ export class AddResourceDialogComponent { protected isResourceConfirming = signal(false); protected form = new FormGroup({ - pid: new FormControl('', [Validators.required]), - resourceType: new FormControl('', [Validators.required]), - description: new FormControl(''), + pid: new FormControl('', [Validators.required]), + resourceType: new FormControl('', [Validators.required]), + description: new FormControl(''), }); private readonly actions = createDispatchMap({ previewResource: PreviewRegistryResource, confirmAddResource: ConfirmAddRegistryResource, + deleteResource: SilentDelete, }); public resourceOptions = signal(resourceTypeOptions); @@ -114,4 +118,13 @@ export class AddResourceDialogComponent { ) .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 index dfa27df42..0c1ec74d8 100644 --- 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 @@ -1,42 +1,13 @@ @if (isCurrentResourceLoading()) { } @else { -
- - - - - -
- - -
- -
- - -
- + } 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 index aaa110057..fe85a8be4 100644 --- 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 @@ -1,35 +1,23 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslateService } from '@ngx-translate/core'; -import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { Textarea } from 'primeng/textarea'; import { finalize, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { resourceTypeOptions } from '@osf/features/registry/constants'; +import { ResourceFormComponent } from '@osf/features/registry/components/resource-form/resource-form.component'; import { RegistryResource } from '@osf/features/registry/models'; import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; import { RegistryResourcesSelectors, UpdateResource } from '@osf/features/registry/store/registry-resources'; -import { FormSelectComponent, LoadingSpinnerComponent, TextInputComponent } from '@shared/components'; -import { InputLimits } from '@shared/constants'; -import { SelectOption } from '@shared/models'; +import { LoadingSpinnerComponent } from '@shared/components'; @Component({ selector: 'osf-edit-resource-dialog', - imports: [ - LoadingSpinnerComponent, - TextInputComponent, - Textarea, - ReactiveFormsModule, - Button, - TranslatePipe, - FormSelectComponent, - ], + imports: [LoadingSpinnerComponent, ReactiveFormsModule, ResourceFormComponent], templateUrl: './edit-resource-dialog.component.html', styleUrl: './edit-resource-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -42,13 +30,11 @@ export class EditResourceDialogComponent { private dialogConfig = inject(DynamicDialogConfig); private registryId: string = this.dialogConfig.data.id; private resource: RegistryResource = this.dialogConfig.data.resource as RegistryResource; - protected inputLimits = InputLimits; - public resourceOptions = signal(resourceTypeOptions); protected form = new FormGroup({ - pid: new FormControl('', [Validators.required]), - resourceType: new FormControl('', [Validators.required]), - description: new FormControl(''), + pid: new FormControl('', [Validators.required]), + resourceType: new FormControl('', [Validators.required]), + description: new FormControl(''), }); private readonly actions = createDispatchMap({ diff --git a/src/app/features/registry/components/index.ts b/src/app/features/registry/components/index.ts index 14bdae137..c5bfd57f3 100644 --- a/src/app/features/registry/components/index.ts +++ b/src/app/features/registry/components/index.ts @@ -1,4 +1,6 @@ export * from './add-resource-dialog/add-resource-dialog.component'; +export * from './edit-resource-dialog/edit-resource-dialog.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..60a41e700 --- /dev/null +++ b/src/app/features/registry/components/resource-form/resource-form.component.html @@ -0,0 +1,34 @@ +
+ + + + + +
+ + +
+ +
+ @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/mappers/add-resource-request.mapper.ts b/src/app/features/registry/mappers/add-resource-request.mapper.ts index a8f0c3033..82fa14a66 100644 --- a/src/app/features/registry/mappers/add-resource-request.mapper.ts +++ b/src/app/features/registry/mappers/add-resource-request.mapper.ts @@ -21,3 +21,19 @@ export function MapAddResourceRequest( 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/pages/registry-resources/registry-resources.component.html b/src/app/features/registry/pages/registry-resources/registry-resources.component.html index c9f73bfe8..da98378a9 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.html +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.html @@ -23,7 +23,7 @@
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 index b3f1b5a67..4d52eb605 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts @@ -18,6 +18,7 @@ import { DeleteResource, GetRegistryResources, RegistryResourcesSelectors, + SilentDelete, } from '@osf/features/registry/store/registry-resources'; import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; import { CustomConfirmationService, ToastService } from '@shared/services'; @@ -42,11 +43,13 @@ export class RegistryResourcesComponent { 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() { @@ -86,6 +89,12 @@ export class RegistryResourcesComponent { 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'), diff --git a/src/app/features/registry/services/registry-resources.service.ts b/src/app/features/registry/services/registry-resources.service.ts index a60eacc29..e63b8ae3f 100644 --- a/src/app/features/registry/services/registry-resources.service.ts +++ b/src/app/features/registry/services/registry-resources.service.ts @@ -3,7 +3,7 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services'; -import { MapAddResourceRequest, MapRegistryResource } from '@osf/features/registry/mappers'; +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 { @@ -31,19 +31,7 @@ export class RegistryResourcesService { } addRegistryResource(registryId: string): Observable { - const body = { - data: { - relationships: { - registration: { - data: { - type: 'registrations', - id: registryId, - }, - }, - }, - type: 'resources', - }, - }; + const body = toAddResourceRequestBody(registryId); return this.jsonApiService.post(`${environment.apiUrl}/resources/`, body).pipe( map((response) => { @@ -56,7 +44,7 @@ export class RegistryResourcesService { const payload = MapAddResourceRequest(resourceId, resource); return this.jsonApiService - .patch(`${environment.apiUrl}/resources/${resourceId}`, payload) + .patch(`${environment.apiUrl}/resources/${resourceId}/`, payload) .pipe( map((response) => { return MapRegistryResource(response); @@ -68,7 +56,7 @@ export class RegistryResourcesService { const payload = MapAddResourceRequest(resourceId, resource); return this.jsonApiService - .patch(`${environment.apiUrl}/resources/${resourceId}`, payload) + .patch(`${environment.apiUrl}/resources/${resourceId}/`, payload) .pipe( map((response) => { return MapRegistryResource(response); @@ -77,7 +65,7 @@ export class RegistryResourcesService { } deleteResource(resourceId: string): Observable { - return this.jsonApiService.delete(`${environment.apiUrl}/resources/${resourceId}`); + return this.jsonApiService.delete(`${environment.apiUrl}/resources/${resourceId}/`); } updateResource(resourceId: string, resource: AddResource) { 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 index 01863f603..14ed0dad5 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.actions.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.actions.ts @@ -41,6 +41,12 @@ export class DeleteResource { ) {} } +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'; 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 index 736862c7a..d480b1d93 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.state.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.state.ts @@ -14,6 +14,7 @@ import { GetRegistryResources, PreviewRegistryResource, RegistryResourcesStateModel, + SilentDelete, UpdateResource, } from '@osf/features/registry/store/registry-resources'; @@ -137,6 +138,11 @@ export class RegistryResourcesState { ); } + @Action(SilentDelete) + silentDelete(ctx: StateContext, action: SilentDelete) { + return this.registryResourcesService.deleteResource(action.resourceId); + } + @Action(UpdateResource) updateResource(ctx: StateContext, action: UpdateResource) { const state = ctx.getState(); From 906919f2ec2f2148210a305e96a038ef126225f6 Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Tue, 29 Jul 2025 23:35:31 +0300 Subject: [PATCH 7/8] fix(registry-resources): doi validation --- .../add-resource-dialog.component.ts | 35 +++++++++++-------- .../edit-resource-dialog.component.ts | 3 +- .../resource-form.component.html | 3 ++ src/assets/i18n/en.json | 3 +- 4 files changed, 28 insertions(+), 16 deletions(-) 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 index 1922afc1f..b461a977f 100644 --- 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 @@ -4,12 +4,19 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { Textarea } from 'primeng/textarea'; import { finalize, take } from 'rxjs'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { + AbstractControl, + FormControl, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, + Validators, +} from '@angular/forms'; import { ResourceFormComponent } from '@osf/features/registry/components'; import { resourceTypeOptions } from '@osf/features/registry/constants'; @@ -21,23 +28,23 @@ import { RegistryResourcesSelectors, SilentDelete, } from '@osf/features/registry/store/registry-resources'; -import { FormSelectComponent, LoadingSpinnerComponent, TextInputComponent } from '@shared/components'; +import { LoadingSpinnerComponent } from '@shared/components'; import { InputLimits } from '@shared/constants'; import { RegistryResourceType } from '@shared/enums'; import { SelectOption } from '@shared/models'; +export const doiValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (!value) return null; + + const DOIRegex = /\b(10\.\d{4,}(?:\.\d+)*\/\S+(?:(?!["&'<>])\S))\b/; // value for example: 10.1234/abcd1234 or https://doi.org/10.1234/abcd1234 + const isValid = DOIRegex.test(value); + return isValid ? null : { invalidDoi: true }; +}; + @Component({ selector: 'osf-add-resource-dialog', - imports: [ - Button, - TextInputComponent, - TranslatePipe, - ReactiveFormsModule, - Textarea, - LoadingSpinnerComponent, - FormSelectComponent, - ResourceFormComponent, - ], + imports: [Button, TranslatePipe, ReactiveFormsModule, LoadingSpinnerComponent, ResourceFormComponent], templateUrl: './add-resource-dialog.component.html', styleUrl: './add-resource-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -55,7 +62,7 @@ export class AddResourceDialogComponent { protected isResourceConfirming = signal(false); protected form = new FormGroup({ - pid: new FormControl('', [Validators.required]), + pid: new FormControl('', [Validators.required, doiValidator]), resourceType: new FormControl('', [Validators.required]), description: new FormControl(''), }); 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 index fe85a8be4..637949a80 100644 --- 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 @@ -9,6 +9,7 @@ import { finalize, take } from 'rxjs'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { doiValidator } from '@osf/features/registry/components'; import { ResourceFormComponent } from '@osf/features/registry/components/resource-form/resource-form.component'; import { RegistryResource } from '@osf/features/registry/models'; import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; @@ -32,7 +33,7 @@ export class EditResourceDialogComponent { private resource: RegistryResource = this.dialogConfig.data.resource as RegistryResource; protected form = new FormGroup({ - pid: new FormControl('', [Validators.required]), + pid: new FormControl('', [Validators.required, doiValidator]), resourceType: new FormControl('', [Validators.required]), description: new FormControl(''), }); 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 index 60a41e700..e8e19d08b 100644 --- a/src/app/features/registry/components/resource-form/resource-form.component.html +++ b/src/app/features/registry/components/resource-form/resource-form.component.html @@ -5,6 +5,9 @@ [placeholder]="'https://doi.org/'" [maxLength]="inputLimits.name.maxLength" > + @if (getControl('pid').hasError('invalidDoi')) { + {{ 'resources.errors.doiValidation' | translate }} + } Date: Wed, 30 Jul 2025 11:18:09 +0300 Subject: [PATCH 8/8] fix(resources): fixed some code --- .../models/moderator-json-api.model.ts | 10 ----- .../add-resource-dialog.component.ts | 42 ++++++------------- .../edit-resource-dialog.component.ts | 14 +++---- .../resource-form.component.html | 7 +++- .../registry-metadata-add.component.spec.ts | 10 +---- .../utils/custom-form-validators.helper.ts | 9 ++++ 6 files changed, 37 insertions(+), 55 deletions(-) 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.ts b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts index b461a977f..fd76dbbee 100644 --- 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 @@ -8,39 +8,23 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { finalize, take } from 'rxjs'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; -import { - AbstractControl, - FormControl, - FormGroup, - ReactiveFormsModule, - ValidationErrors, - ValidatorFn, - Validators, -} from '@angular/forms'; - -import { ResourceFormComponent } from '@osf/features/registry/components'; -import { resourceTypeOptions } from '@osf/features/registry/constants'; -import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; -import { ConfirmAddResource } from '@osf/features/registry/models/resources/confirm-add-resource.model'; +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 '@osf/features/registry/store/registry-resources'; -import { LoadingSpinnerComponent } from '@shared/components'; -import { InputLimits } from '@shared/constants'; -import { RegistryResourceType } from '@shared/enums'; -import { SelectOption } from '@shared/models'; - -export const doiValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { - const value = control.value; - if (!value) return null; - - const DOIRegex = /\b(10\.\d{4,}(?:\.\d+)*\/\S+(?:(?!["&'<>])\S))\b/; // value for example: 10.1234/abcd1234 or https://doi.org/10.1234/abcd1234 - const isValid = DOIRegex.test(value); - return isValid ? null : { invalidDoi: true }; -}; +} from '../../store/registry-resources'; +import { ResourceFormComponent } from '../resource-form/resource-form.component'; @Component({ selector: 'osf-add-resource-dialog', @@ -62,7 +46,7 @@ export class AddResourceDialogComponent { protected isResourceConfirming = signal(false); protected form = new FormGroup({ - pid: new FormControl('', [Validators.required, doiValidator]), + pid: new FormControl('', [CustomValidators.requiredTrimmed(), CustomValidators.doiValidator]), resourceType: new FormControl('', [Validators.required]), description: new FormControl(''), }); 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 index 637949a80..df455a026 100644 --- 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 @@ -9,12 +9,12 @@ import { finalize, take } from 'rxjs'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { doiValidator } from '@osf/features/registry/components'; -import { ResourceFormComponent } from '@osf/features/registry/components/resource-form/resource-form.component'; -import { RegistryResource } from '@osf/features/registry/models'; -import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; -import { RegistryResourcesSelectors, UpdateResource } from '@osf/features/registry/store/registry-resources'; -import { LoadingSpinnerComponent } from '@shared/components'; +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', @@ -33,7 +33,7 @@ export class EditResourceDialogComponent { private resource: RegistryResource = this.dialogConfig.data.resource as RegistryResource; protected form = new FormGroup({ - pid: new FormControl('', [Validators.required, doiValidator]), + pid: new FormControl('', [CustomValidators.requiredTrimmed(), CustomValidators.doiValidator]), resourceType: new FormControl('', [Validators.required]), description: new FormControl(''), }); 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 index e8e19d08b..0aceb3622 100644 --- a/src/app/features/registry/components/resource-form/resource-form.component.html +++ b/src/app/features/registry/components/resource-form/resource-form.component.html @@ -32,6 +32,11 @@ (click)="handleCancel()" /> } - +
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/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 }; + }; }