From 330531f214a36bb17714870fdceb5ca5bf510abd Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 9 Jul 2025 16:10:54 +0300 Subject: [PATCH 1/3] fix(subjects state): updated subjects state --- .../browse-by-subjects.component.ts | 4 +- .../metadata-step.component.html | 2 +- .../preprints-subjects.component.ts | 39 ++- .../mappers/preprint-providers.mapper.ts | 4 +- .../features/preprints/preprints.routes.ts | 6 - src/app/features/preprints/services/index.ts | 1 - .../services/preprint-providers.service.ts | 4 +- .../services/preprint-subjects.service.ts | 78 ----- .../preprint-providers.model.ts | 4 +- .../submit-preprint.actions.ts | 12 +- .../submit-preprint/submit-preprint.model.ts | 3 +- .../submit-preprint.selectors.ts | 10 - .../submit-preprint/submit-preprint.state.ts | 51 --- .../project-metadata-subjects.component.html | 109 +----- .../project-metadata-subjects.component.scss | 14 - .../project-metadata-subjects.component.ts | 330 ++---------------- .../metadata/project-metadata.component.html | 8 +- .../metadata/project-metadata.component.scss | 2 - .../metadata/project-metadata.component.ts | 15 - .../metadata/services/metadata.service.ts | 14 +- src/app/features/project/project.routes.ts | 4 +- .../components/metadata/metadata.component.ts | 10 +- .../registries-subjects.component.ts | 36 +- .../features/registries/registries.routes.ts | 11 +- src/app/features/registries/services/index.ts | 1 - .../services/registration-subjects.service.ts | 76 ---- .../registries/store/default.state.ts | 5 - .../registries/store/handlers/index.ts | 1 - .../store/handlers/subjects.handlers.ts | 64 ---- .../registries/store/registries.actions.ts | 15 +- .../registries/store/registries.model.ts | 3 +- .../registries/store/registries.selectors.ts | 12 +- .../registries/store/registries.state.ts | 17 - .../settings-container.component.spec.ts | 6 - .../components/subjects/subjects.component.ts | 32 +- .../shared/mappers/subjects/subject-mapper.ts | 103 +----- src/app/shared/models/index.ts | 1 - src/app/shared/models/node-subject.model.ts | 68 ---- .../models/subject/subject-service.model.ts | 6 +- .../shared/models/subject/subject.model.ts | 6 +- src/app/shared/services/subjects.service.ts | 77 +++- .../stores/subjects/subjects.actions.ts | 32 +- .../shared/stores/subjects/subjects.model.ts | 8 +- .../stores/subjects/subjects.selectors.ts | 26 +- .../shared/stores/subjects/subjects.state.ts | 140 ++++---- 45 files changed, 298 insertions(+), 1172 deletions(-) delete mode 100644 src/app/features/preprints/services/preprint-subjects.service.ts delete mode 100644 src/app/features/registries/services/registration-subjects.service.ts delete mode 100644 src/app/features/registries/store/handlers/subjects.handlers.ts delete mode 100644 src/app/shared/models/node-subject.model.ts diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts index eb01669e0..4f90e4d73 100644 --- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts +++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts @@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co import { RouterLink } from '@angular/router'; import { ResourceTab } from '@shared/enums'; -import { Subject } from '@shared/models'; +import { SubjectModel } from '@shared/models'; @Component({ selector: 'osf-browse-by-subjects', @@ -17,7 +17,7 @@ import { Subject } from '@shared/models'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class BrowseBySubjectsComponent { - subjects = input.required(); + subjects = input.required(); linksToSearchPageForSubject = computed(() => { return this.subjects().map((subject) => ({ resourceTab: ResourceTab.Preprints, diff --git a/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.html b/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.html index cdb5f99ac..83d557535 100644 --- a/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.html +++ b/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.html @@ -42,7 +42,7 @@

Publication DOI

- +
diff --git a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts index 23a7556d2..8ca98f103 100644 --- a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts +++ b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts @@ -1,15 +1,18 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input, OnInit } from '@angular/core'; -import { - FetchPreprintsSubjects, - SubmitPreprintSelectors, - UpdatePreprintsSubjects, -} from '@osf/features/preprints/store/submit-preprint'; +import { SubmitPreprintSelectors } from '@osf/features/preprints/store/submit-preprint'; import { SubjectsComponent } from '@osf/shared/components'; -import { Subject } from '@osf/shared/models'; -import { FetchChildrenSubjects, FetchSubjects } from '@osf/shared/stores'; +import { ResourceType } from '@osf/shared/enums'; +import { SubjectModel } from '@osf/shared/models'; +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores'; @Component({ selector: 'osf-preprints-subjects', @@ -19,20 +22,22 @@ import { FetchChildrenSubjects, FetchSubjects } from '@osf/shared/stores'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintsSubjectsComponent implements OnInit { + preprintId = input(); + private readonly selectedProviderId = select(SubmitPreprintSelectors.getSelectedProviderId); - protected selectedSubjects = select(SubmitPreprintSelectors.getSelectedSubjects); - protected isSubjectsUpdating = select(SubmitPreprintSelectors.isSubjectsUpdating); + protected selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + protected isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); protected actions = createDispatchMap({ fetchSubjects: FetchSubjects, - fetchPreprintsSubjects: FetchPreprintsSubjects, + fetchSelectedSubjects: FetchSelectedSubjects, fetchChildrenSubjects: FetchChildrenSubjects, - updatePreprintsSubjects: UpdatePreprintsSubjects, + updateResourceSubjects: UpdateResourceSubjects, }); ngOnInit(): void { - this.actions.fetchSubjects(this.selectedProviderId()!); - this.actions.fetchPreprintsSubjects(); + this.actions.fetchSubjects(ResourceType.Preprint, this.selectedProviderId()!); + this.actions.fetchSelectedSubjects(this.preprintId()!, ResourceType.Preprint); } getSubjectChildren(parentId: string) { @@ -40,10 +45,10 @@ export class PreprintsSubjectsComponent implements OnInit { } searchSubjects(search: string) { - this.actions.fetchSubjects(search); + this.actions.fetchSubjects(ResourceType.Preprint, this.selectedProviderId()!, search); } - updateSelectedSubjects(subjects: Subject[]) { - this.actions.updatePreprintsSubjects(subjects); + updateSelectedSubjects(subjects: SubjectModel[]) { + this.actions.updateResourceSubjects(this.preprintId()!, ResourceType.Preprint, subjects); } } diff --git a/src/app/features/preprints/mappers/preprint-providers.mapper.ts b/src/app/features/preprints/mappers/preprint-providers.mapper.ts index 29dd82270..ff286541a 100644 --- a/src/app/features/preprints/mappers/preprint-providers.mapper.ts +++ b/src/app/features/preprints/mappers/preprint-providers.mapper.ts @@ -3,7 +3,7 @@ import { PreprintProviderDetailsJsonApi, PreprintProviderShortInfo, } from '@osf/features/preprints/models'; -import { Subject, SubjectDataJsonApi } from '@shared/models'; +import { SubjectDataJsonApi, SubjectModel } from '@shared/models'; export class PreprintProvidersMapper { static fromPreprintProviderDetailsGetResponse(response: PreprintProviderDetailsJsonApi): PreprintProviderDetails { @@ -44,7 +44,7 @@ export class PreprintProvidersMapper { })); } - static fromSubjectsGetResponse(data: SubjectDataJsonApi[]): Subject[] { + static fromSubjectsGetResponse(data: SubjectDataJsonApi[]): SubjectModel[] { return data.map((subject) => ({ id: subject.id, name: subject.attributes.text, diff --git a/src/app/features/preprints/preprints.routes.ts b/src/app/features/preprints/preprints.routes.ts index f2caae2b0..eb1411c3d 100644 --- a/src/app/features/preprints/preprints.routes.ts +++ b/src/app/features/preprints/preprints.routes.ts @@ -3,14 +3,12 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { PreprintsComponent } from '@osf/features/preprints/preprints.component'; -import { PreprintSubjectsService } from '@osf/features/preprints/services'; import { PreprintProvidersState } from '@osf/features/preprints/store/preprint-providers'; import { PreprintsDiscoverState } from '@osf/features/preprints/store/preprints-discover'; import { PreprintsResourcesFiltersState } from '@osf/features/preprints/store/preprints-resources-filters'; import { PreprintsResourcesFiltersOptionsState } from '@osf/features/preprints/store/preprints-resources-filters-options'; import { SubmitPreprintState } from '@osf/features/preprints/store/submit-preprint'; import { ContributorsState, SubjectsState } from '@shared/stores'; -import { SUBJECTS_SERVICE } from '@shared/tokens/subjects.token'; export const preprintsRoutes: Routes = [ { @@ -26,10 +24,6 @@ export const preprintsRoutes: Routes = [ ContributorsState, SubjectsState, ]), - { - provide: SUBJECTS_SERVICE, - useClass: PreprintSubjectsService, - }, ], children: [ { diff --git a/src/app/features/preprints/services/index.ts b/src/app/features/preprints/services/index.ts index 8d103600e..0fbae73a5 100644 --- a/src/app/features/preprints/services/index.ts +++ b/src/app/features/preprints/services/index.ts @@ -1,7 +1,6 @@ export { PreprintFilesService } from './preprint-files.service'; export { PreprintLicensesService } from './preprint-licenses.service'; export { PreprintProvidersService } from './preprint-providers.service'; -export { PreprintSubjectsService } from './preprint-subjects.service'; export { PreprintsService } from './preprints.service'; export { PreprintsProjectsService } from './preprints-projects.service'; export { PreprintsFiltersOptionsService } from './preprints-resource-filters.service'; diff --git a/src/app/features/preprints/services/preprint-providers.service.ts b/src/app/features/preprints/services/preprint-providers.service.ts index 044e5ddfb..f3b30b62a 100644 --- a/src/app/features/preprints/services/preprint-providers.service.ts +++ b/src/app/features/preprints/services/preprint-providers.service.ts @@ -10,7 +10,7 @@ import { PreprintProviderDetailsJsonApi, PreprintProviderShortInfo, } from '@osf/features/preprints/models'; -import { Subject, SubjectsResponseJsonApi } from '@shared/models'; +import { SubjectModel, SubjectsResponseJsonApi } from '@shared/models'; import { environment } from 'src/environments/environment'; @@ -55,7 +55,7 @@ export class PreprintProvidersService { ); } - getHighlightedSubjectsByProviderId(providerId: string): Observable { + getHighlightedSubjectsByProviderId(providerId: string): Observable { return this.jsonApiService .get(`${this.baseUrl}${providerId}/subjects/highlighted/?page[size]=20`) .pipe( diff --git a/src/app/features/preprints/services/preprint-subjects.service.ts b/src/app/features/preprints/services/preprint-subjects.service.ts deleted file mode 100644 index d41c3421a..000000000 --- a/src/app/features/preprints/services/preprint-subjects.service.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { map, Observable } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { JsonApiService } from '@osf/core/services'; -import { SubjectMapper } from '@osf/shared/mappers'; -import { ISubjectsService, Subject, SubjectsResponseJsonApi } from '@osf/shared/models'; - -import { environment } from 'src/environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class PreprintSubjectsService implements ISubjectsService { - private apiUrl = environment.apiUrl; - private readonly jsonApiService = inject(JsonApiService); - - getSubjects(providerId: string, search?: string): Observable { - const params: Record = { - 'page[size]': '100', - sort: 'text', - related_counts: 'children', - 'filter[parent]': 'null', - }; - if (search) { - delete params['filter[parent]']; - params['filter[text]'] = search; - params['embed'] = 'parent'; - } - return this.jsonApiService - .get(`${this.apiUrl}/providers/preprints/${providerId}/subjects/`, params) - .pipe( - map((response) => { - return SubjectMapper.fromSubjectsResponseJsonApi(response); - }) - ); - } - - getChildrenSubjects(parentId: string): Observable { - const params: Record = { - 'page[size]': '100', - page: '1', - sort: 'text', - related_counts: 'children', - }; - - return this.jsonApiService - .get(`${this.apiUrl}/subjects/${parentId}/children/`, params) - .pipe( - map((response) => { - return SubjectMapper.fromSubjectsResponseJsonApi(response); - }) - ); - } - - getPreprintSubjects(preprintId: string): Observable { - const params: Record = { - 'page[size]': '100', - page: '1', - }; - - return this.jsonApiService - .get(`${this.apiUrl}/preprints/${preprintId}/subjects/`, params) - .pipe( - map((response) => { - return SubjectMapper.fromSubjectsResponseJsonApi(response); - }) - ); - } - - updatePreprintSubjects(preprintId: string, subjects: Subject[]): Observable { - const payload = { - data: subjects.map((item) => ({ id: item.id, type: 'subjects' })), - }; - - return this.jsonApiService.put(`${this.apiUrl}/preprints/${preprintId}/relationships/subjects/`, payload); - } -} diff --git a/src/app/features/preprints/store/preprint-providers/preprint-providers.model.ts b/src/app/features/preprints/store/preprint-providers/preprint-providers.model.ts index b14ee13cb..8b65d946b 100644 --- a/src/app/features/preprints/store/preprint-providers/preprint-providers.model.ts +++ b/src/app/features/preprints/store/preprint-providers/preprint-providers.model.ts @@ -1,9 +1,9 @@ import { PreprintProviderDetails, PreprintProviderShortInfo } from '@osf/features/preprints/models'; -import { AsyncStateModel, Subject } from '@shared/models'; +import { AsyncStateModel, SubjectModel } from '@shared/models'; export interface PreprintProvidersStateModel { preprintProvidersDetails: AsyncStateModel; preprintProvidersToAdvertise: AsyncStateModel; preprintProvidersAllowingSubmissions: AsyncStateModel; - highlightedSubjectsForProvider: AsyncStateModel; + highlightedSubjectsForProvider: AsyncStateModel; } diff --git a/src/app/features/preprints/store/submit-preprint/submit-preprint.actions.ts b/src/app/features/preprints/store/submit-preprint/submit-preprint.actions.ts index f379ce1bb..5aae16de1 100644 --- a/src/app/features/preprints/store/submit-preprint/submit-preprint.actions.ts +++ b/src/app/features/preprints/store/submit-preprint/submit-preprint.actions.ts @@ -1,7 +1,7 @@ import { StringOrNull } from '@core/helpers'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { Preprint } from '@osf/features/preprints/models'; -import { LicenseOptions, OsfFile, Subject } from '@shared/models'; +import { LicenseOptions, OsfFile } from '@shared/models'; export class SetSelectedPreprintProviderId { static readonly type = '[Submit Preprint] Set Selected Preprint Provider Id'; @@ -91,16 +91,6 @@ export class SaveLicense { ) {} } -export class FetchPreprintsSubjects { - static readonly type = '[Submit Preprint] Fetch Registration Subjects'; -} - -export class UpdatePreprintsSubjects { - static readonly type = '[Submit Preprint] Update Registration Subject'; - - constructor(public subjects: Subject[]) {} -} - export class DisconnectProject { static readonly type = '[Submit Preprint] Disconnect Preprint Project'; } diff --git a/src/app/features/preprints/store/submit-preprint/submit-preprint.model.ts b/src/app/features/preprints/store/submit-preprint/submit-preprint.model.ts index 8d43c442c..77ec26538 100644 --- a/src/app/features/preprints/store/submit-preprint/submit-preprint.model.ts +++ b/src/app/features/preprints/store/submit-preprint/submit-preprint.model.ts @@ -1,7 +1,7 @@ import { StringOrNull } from '@core/helpers'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { Preprint, PreprintFilesLinks } from '@osf/features/preprints/models'; -import { AsyncStateModel, IdName, OsfFile, Subject } from '@shared/models'; +import { AsyncStateModel, IdName, OsfFile } from '@shared/models'; import { License } from '@shared/models/license.model'; export interface SubmitPreprintStateModel { @@ -13,6 +13,5 @@ export interface SubmitPreprintStateModel { availableProjects: AsyncStateModel; projectFiles: AsyncStateModel; licenses: AsyncStateModel; - subjects: AsyncStateModel; preprintProject: AsyncStateModel; } diff --git a/src/app/features/preprints/store/submit-preprint/submit-preprint.selectors.ts b/src/app/features/preprints/store/submit-preprint/submit-preprint.selectors.ts index f26023140..63e3e71e7 100644 --- a/src/app/features/preprints/store/submit-preprint/submit-preprint.selectors.ts +++ b/src/app/features/preprints/store/submit-preprint/submit-preprint.selectors.ts @@ -63,16 +63,6 @@ export class SubmitPreprintSelectors { return state.licenses.data; } - @Selector([SubmitPreprintState]) - static getSelectedSubjects(state: SubmitPreprintStateModel) { - return state.subjects.data; - } - - @Selector([SubmitPreprintState]) - static isSubjectsUpdating(state: SubmitPreprintStateModel) { - return state.subjects.isLoading; - } - @Selector([SubmitPreprintState]) static getPreprintProject(state: SubmitPreprintStateModel) { return state.preprintProject.data; diff --git a/src/app/features/preprints/store/submit-preprint/submit-preprint.state.ts b/src/app/features/preprints/store/submit-preprint/submit-preprint.state.ts index 1cfd51b7c..b4ae0ecae 100644 --- a/src/app/features/preprints/store/submit-preprint/submit-preprint.state.ts +++ b/src/app/features/preprints/store/submit-preprint/submit-preprint.state.ts @@ -15,7 +15,6 @@ import { PreprintLicensesService, PreprintsProjectsService, PreprintsService, - PreprintSubjectsService, } from '@osf/features/preprints/services'; import { OsfFile } from '@shared/models'; import { FilesService } from '@shared/services'; @@ -28,7 +27,6 @@ import { DisconnectProject, FetchLicenses, FetchPreprintProject, - FetchPreprintsSubjects, GetAvailableProjects, GetPreprintFiles, GetPreprintFilesLinks, @@ -41,7 +39,6 @@ import { SetSelectedPreprintProviderId, SubmitPreprintStateModel, UpdatePreprint, - UpdatePreprintsSubjects, UploadFile, } from './'; @@ -79,11 +76,6 @@ const DefaultState: SubmitPreprintStateModel = { isLoading: false, error: null, }, - subjects: { - data: [], - isLoading: false, - error: null, - }, preprintProject: { data: null, isLoading: false, @@ -101,7 +93,6 @@ export class SubmitPreprintState { private preprintFilesService = inject(PreprintFilesService); private fileService = inject(FilesService); private licensesService = inject(PreprintLicensesService); - private subjectsService = inject(PreprintSubjectsService); private preprintProjectsService = inject(PreprintsProjectsService); @Action(SetSelectedPreprintProviderId) @@ -370,48 +361,6 @@ export class SubmitPreprintState { ); } - @Action(FetchPreprintsSubjects) - fetchPreprintsSubjects(ctx: StateContext) { - const createdPreprintId = ctx.getState().createdPreprint.data!.id; - if (!createdPreprintId) return EMPTY; - - ctx.setState(patch({ subjects: patch({ isLoading: true }) })); - - return this.subjectsService.getPreprintSubjects(createdPreprintId).pipe( - tap((subjects) => { - ctx.patchState({ - subjects: { - data: subjects, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'subjects', error)) - ); - } - - @Action(UpdatePreprintsSubjects) - updatePreprintsSubjects(ctx: StateContext, { subjects }: UpdatePreprintsSubjects) { - const createdPreprintId = ctx.getState().createdPreprint.data?.id; - if (!createdPreprintId) return EMPTY; - - ctx.setState(patch({ subjects: patch({ isLoading: true }) })); - - return this.subjectsService.updatePreprintSubjects(createdPreprintId, subjects).pipe( - tap(() => { - ctx.patchState({ - subjects: { - data: subjects, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'subjects', error)) - ); - } - @Action(DisconnectProject) disconnectProject(ctx: StateContext) { const createdPreprintId = ctx.getState().createdPreprint.data?.id; diff --git a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html index f6d492ace..4b926a1c0 100644 --- a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html +++ b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html @@ -1,102 +1,7 @@ - -
-

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

- -
-
-
- @for (subject of getUniqueSubjects(); track subject.id) { - - } - - @if (getUniqueSubjects().length === 0) { - {{ 'project.metadata.addSubjects' | translate }} - } -
-
- - - - @if (filteredOptions().length > 0) { -
-
- @for (option of filteredOptions(); track option.id) { -
-
- @if (option.children?.length) { - - - } @else { - - } - - - - - {{ option.label }} - -
- - @if (option.expanded && option.children) { - @for (child of option.children; track child.id) { -
- - - - - - {{ child.label }} - -
- } - } -
- } -
-
- } -
-
-
+ diff --git a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.scss b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.scss index dd3c06c81..e69de29bb 100644 --- a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.scss +++ b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.scss @@ -1,14 +0,0 @@ -.removable-chip { - transition: all 0.2s ease; - - &:hover { - transform: scale(0.95); - opacity: 0.8; - } - - &::after { - content: " ×"; - font-weight: bold; - margin-left: 4px; - } -} diff --git a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts index ba8411df9..f1839c943 100644 --- a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts +++ b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts @@ -1,319 +1,51 @@ -import { TranslatePipe } from '@ngx-translate/core'; +import { createDispatchMap, select } from '@ngxs/store'; -import { Card } from 'primeng/card'; -import { Checkbox } from 'primeng/checkbox'; -import { Chip } from 'primeng/chip'; +import { ChangeDetectionStrategy, Component, input, OnInit } from '@angular/core'; -import { NgClass } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core'; -import { FormControl, FormsModule } from '@angular/forms'; - -import { ProjectOverview, ProjectOverviewSubject } from '@osf/features/project/overview/models'; -import { SearchInputComponent } from '@shared/components'; -import { NodeSubjectModel } from '@shared/models'; - -interface SubjectOption { - id: string; - label: string; - children?: SubjectOption[]; - expanded?: boolean; - selected?: boolean; - indeterminate?: boolean; - level?: number; -} +import { ResourceType } from '@osf/shared/enums'; +import { SubjectModel } from '@osf/shared/models'; +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores'; +import { SubjectsComponent } from '@shared/components'; @Component({ selector: 'osf-project-metadata-subjects', - imports: [Card, Chip, TranslatePipe, FormsModule, Checkbox, NgClass, SearchInputComponent], + imports: [SubjectsComponent], templateUrl: './project-metadata-subjects.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProjectMetadataSubjectsComponent { - subjectsChanged = output(); - - currentProject = input.required(); - subjectsList = input([]); +export class ProjectMetadataSubjectsComponent implements OnInit { + projectId = input(); - searchControl = new FormControl(''); + protected selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + protected isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); - selectedSubjects = signal([]); - searchValue = signal(''); - filteredOptions = signal([]); - - subjectOptions = computed(() => { - const subjects = this.subjectsList(); - return this.convertMetadataSubjectsToOptions(subjects); + protected actions = createDispatchMap({ + fetchSubjects: FetchSubjects, + fetchSelectedSubjects: FetchSelectedSubjects, + fetchChildrenSubjects: FetchChildrenSubjects, + updateResourceSubjects: UpdateResourceSubjects, }); - constructor() { - effect(() => { - const project = this.currentProject(); - if (project?.subjects) { - this.selectedSubjects.set([...project.subjects]); - this.updateSelectionState(); - } - }); - - effect(() => { - const options = this.subjectOptions(); - if (options.length > 0) { - this.filterOptions(); - } - }); - - this.searchControl.valueChanges.subscribe((value) => { - this.searchValue.set(value || ''); - this.filterOptions(); - }); - } - - onInputFocus() { - this.filterOptions(); - } - - filterOptions() { - const search = this.searchValue().toLowerCase(); - const filtered = this.filterOptionsRecursive(this.subjectOptions(), search); - this.filteredOptions.set(filtered); - } - - private filterOptionsRecursive(options: SubjectOption[], search: string): SubjectOption[] { - const filtered: SubjectOption[] = []; - - for (const option of options) { - const matchesSearch = !search || option.label.toLowerCase().includes(search); - const filteredChildren = option.children ? this.filterOptionsRecursive(option.children, search) : []; - - if (matchesSearch || filteredChildren.length > 0) { - const originalOption = this.findOptionById(this.subjectOptions(), option.id); - - filtered.push({ - ...option, - selected: originalOption ? originalOption.selected || false : false, - indeterminate: originalOption ? originalOption.indeterminate || false : false, - expanded: search ? true : originalOption ? originalOption.expanded || false : false, - children: filteredChildren.length > 0 ? filteredChildren : option.children, - }); - } - } - - return filtered; - } - - toggleExpand(option: SubjectOption, event: Event) { - event.stopPropagation(); - const originalOption = this.findOptionById(this.subjectOptions(), option.id); - if (originalOption) { - originalOption.expanded = !originalOption.expanded; - } - this.filterOptions(); - } - - private findOptionById(options: SubjectOption[], id: string): SubjectOption | null { - for (const option of options) { - if (option.id === id) { - return option; - } - if (option.children) { - const found = this.findOptionById(option.children, id); - if (found) return found; - } - } - return null; - } - - private findAllChildrenIds(option: SubjectOption): string[] { - let ids: string[] = []; - if (option.children) { - option.children.forEach((child) => { - ids.push(child.id); - ids = ids.concat(this.findAllChildrenIds(child)); - }); - } - return ids; - } - - toggleSelection(option: SubjectOption) { - const originalOption = this.findOptionById(this.subjectOptions(), option.id); - if (!originalOption) return; - - const newSelectedState = !originalOption.selected; - - const expandedStates = this.getExpandedStates(); - - originalOption.selected = newSelectedState; - originalOption.indeterminate = false; - - let updatedSubjects = [...this.selectedSubjects()]; - - if (newSelectedState) { - updatedSubjects.push({ id: originalOption.id, text: originalOption.label }); - if (originalOption.children) { - this.addChildrenToSelection(originalOption.children, updatedSubjects); - this.updateChildrenSelection(originalOption.children, true); - } - } else { - const idsToRemove = [originalOption.id, ...this.findAllChildrenIds(originalOption)]; - updatedSubjects = updatedSubjects.filter((s) => !idsToRemove.includes(s.id)); - if (originalOption.children) { - this.updateChildrenSelection(originalOption.children, false); - } - } - - this.selectedSubjects.set(this.getUniqueSubjects(updatedSubjects)); - - this.updateParentSelectionState(); - - this.restoreExpandedStates(expandedStates); - - if (originalOption.children && originalOption.children.length > 0) { - originalOption.expanded = true; - } - - this.filterOptions(); - - this.subjectsChanged.emit(this.selectedSubjects()); - } - - private updateChildrenSelection(children: SubjectOption[], selected: boolean) { - children.forEach((child) => { - child.selected = selected; - child.indeterminate = false; - if (child.children) { - this.updateChildrenSelection(child.children, selected); - } - }); - } - - private convertMetadataSubjectsToOptions(subjects: NodeSubjectModel[]): SubjectOption[] { - const selectedIds = this.selectedSubjects().map((s) => s.id); - - const convertSubject = (subject: NodeSubjectModel): SubjectOption => { - const isSelected = selectedIds.includes(subject.id); - const option: SubjectOption = { - id: subject.id, - label: subject.text, - expanded: false, - selected: isSelected, - level: subject.level, - }; - - if (subject.children && subject.children.length > 0) { - option.children = subject.children.map(convertSubject); - const allChildrenSelected = option.children.every((child) => child.selected); - const someChildrenSelected = option.children.some((child) => child.selected || child.indeterminate); - - if (someChildrenSelected) { - option.expanded = true; - } - - option.selected = allChildrenSelected; - option.indeterminate = !allChildrenSelected && someChildrenSelected; - } - - return option; - }; - - return subjects.map(convertSubject); - } - - private addChildrenToSelection(children: SubjectOption[], selectedSubjects: ProjectOverviewSubject[]) { - children.forEach((child) => { - selectedSubjects.push({ id: child.id, text: child.label }); - if (child.children) { - this.addChildrenToSelection(child.children, selectedSubjects); - } - }); - } - - removeSubject(subject: ProjectOverviewSubject) { - const updatedSubjects = this.selectedSubjects().filter((s) => s.id !== subject.id); - this.selectedSubjects.set(updatedSubjects); - this.updateSelectionState(); - this.subjectsChanged.emit(updatedSubjects); - } - - private updateSelectionState() { - const selectedIds = this.selectedSubjects().map((s) => s.id); - this.updateOptionsSelection(this.subjectOptions(), selectedIds); - this.updateParentSelectionState(); - this.filterOptions(); - } - - private updateOptionsSelection(options: SubjectOption[], selectedIds: string[]) { - options.forEach((option) => { - option.selected = selectedIds.includes(option.id); - option.indeterminate = false; - if (option.children) { - this.updateOptionsSelection(option.children, selectedIds); - } - }); - } - - private updateParentSelectionState() { - const updateParent = (options: SubjectOption[]) => { - options.forEach((parent) => { - if (parent.children) { - const selectedChildren = parent.children.filter((child) => child.selected).length; - const indeterminateChildren = parent.children.filter((child) => child.indeterminate).length; - const totalChildren = parent.children.length; - - if (selectedChildren === 0 && indeterminateChildren === 0) { - parent.selected = false; - parent.indeterminate = false; - } else if (selectedChildren === totalChildren) { - parent.selected = true; - parent.indeterminate = false; - } else { - parent.selected = false; - parent.indeterminate = true; - } - - updateParent(parent.children); - } - }); - }; - - updateParent(this.subjectOptions()); - } - - getUniqueSubjects(subjects?: ProjectOverviewSubject[]): ProjectOverviewSubject[] { - const seen = new Set(); - const subjectList = subjects ? subjects : this.selectedSubjects(); - return subjectList.filter((subject) => { - if (seen.has(subject.id)) { - return false; - } - seen.add(subject.id); - return true; - }); - } - - private getExpandedStates(): Map { - const expandedStates = new Map(); - this.collectExpandedStates(this.subjectOptions(), expandedStates); - return expandedStates; + ngOnInit(): void { + this.actions.fetchSubjects(ResourceType.Project); + this.actions.fetchSelectedSubjects(this.projectId()!, ResourceType.Project); } - private collectExpandedStates(options: SubjectOption[], expandedStates: Map) { - options.forEach((option) => { - expandedStates.set(option.id, option.expanded || false); - if (option.children) { - this.collectExpandedStates(option.children, expandedStates); - } - }); + getSubjectChildren(parentId: string) { + this.actions.fetchChildrenSubjects(parentId); } - private restoreExpandedStates(expandedStates: Map) { - this.applyExpandedStates(this.subjectOptions(), expandedStates); + searchSubjects(search: string) { + this.actions.fetchSubjects(ResourceType.Project, this.projectId()!, search); } - private applyExpandedStates(options: SubjectOption[], expandedStates: Map) { - options.forEach((option) => { - option.expanded = expandedStates.get(option.id) || false; - if (option.children) { - this.applyExpandedStates(option.children, expandedStates); - } - }); + updateSelectedSubjects(subjects: SubjectModel[]) { + this.actions.updateResourceSubjects(this.projectId()!, ResourceType.Project, subjects); } } diff --git a/src/app/features/project/metadata/project-metadata.component.html b/src/app/features/project/metadata/project-metadata.component.html index 2f451d195..1d7f32b0d 100644 --- a/src/app/features/project/metadata/project-metadata.component.html +++ b/src/app/features/project/metadata/project-metadata.component.html @@ -24,7 +24,7 @@ @for (tab of tabs(); track $index) { @if (tab.type === 'project') { - @if (highlightedSubjectsLoading() && currentProjectLoading()) { + @if (currentProjectLoading()) {
@@ -103,11 +103,7 @@

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

- + } diff --git a/src/app/features/project/metadata/project-metadata.component.scss b/src/app/features/project/metadata/project-metadata.component.scss index cad8088a3..c17a30806 100644 --- a/src/app/features/project/metadata/project-metadata.component.scss +++ b/src/app/features/project/metadata/project-metadata.component.scss @@ -1,5 +1,3 @@ -@use "assets/styles/variables" as var; - .metadata { border: 1px solid var(--grey-2); border-radius: 12px; diff --git a/src/app/features/project/metadata/project-metadata.component.ts b/src/app/features/project/metadata/project-metadata.component.ts index a69df88c3..a7228f831 100644 --- a/src/app/features/project/metadata/project-metadata.component.ts +++ b/src/app/features/project/metadata/project-metadata.component.ts @@ -52,12 +52,10 @@ import { UpdateCustomItemMetadata, UpdateProjectDetails, } from '@osf/features/project/metadata/store'; -import { ProjectOverviewSubject } from '@osf/features/project/overview/models'; import { ResourceType } from '@osf/shared/enums'; import { ContributorsSelectors, GetAllContributors } from '@osf/shared/stores'; import { LoadingSpinnerComponent, SubHeaderComponent, TagsInputComponent } from '@shared/components'; import { CustomConfirmationService, ToastService } from '@shared/services'; -import { GetSubjects, SubjectsSelectors, UpdateProjectSubjects } from '@shared/stores/subjects'; @Component({ selector: 'osf-project-metadata', @@ -107,13 +105,11 @@ export class ProjectMetadataComponent implements OnInit { protected actions = createDispatchMap({ getProject: GetProjectForMetadata, updateProjectDetails: UpdateProjectDetails, - updateProjectSubjects: UpdateProjectSubjects, getCustomItemMetadata: GetCustomItemMetadata, updateCustomItemMetadata: UpdateCustomItemMetadata, getFundersList: GetFundersList, getContributors: GetAllContributors, getUserInstitutions: GetUserInstitutions, - getHighlightedSubjects: GetSubjects, getCedarRecords: GetCedarMetadataRecords, getCedarTemplates: GetCedarMetadataTemplates, createCedarRecord: CreateCedarMetadataRecord, @@ -126,8 +122,6 @@ export class ProjectMetadataComponent implements OnInit { protected fundersList = select(ProjectMetadataSelectors.getFundersList); protected contributors = select(ContributorsSelectors.getContributors); protected isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); - protected highlightedSubjects = select(SubjectsSelectors.getHighlightedSubjects); - protected highlightedSubjectsLoading = select(SubjectsSelectors.getHighlightedSubjectsLoading); protected currentUser = select(UserSelectors.getCurrentUser); protected cedarRecords = select(ProjectMetadataSelectors.getCedarRecords); protected cedarTemplates = select(ProjectMetadataSelectors.getCedarTemplates); @@ -171,7 +165,6 @@ export class ProjectMetadataComponent implements OnInit { const projectId = this.route.parent?.parent?.snapshot.params['id']; if (projectId) { - this.actions.getHighlightedSubjects(); this.actions.getProject(projectId); this.actions.getCustomItemMetadata(projectId); this.actions.getContributors(projectId, ResourceType.Project); @@ -217,14 +210,6 @@ export class ProjectMetadataComponent implements OnInit { this.router.navigate(['add'], { relativeTo: this.route }); } - onSubjectsChanged(subjects: ProjectOverviewSubject[]): void { - const projectId = this.currentProject()?.id; - if (projectId) { - const subjectIds = subjects.map((subject) => subject.id); - this.actions.updateProjectSubjects(projectId, subjectIds); - } - } - openEditContributorDialog(): void { const dialogRef = this.dialogService.open(ContributorsDialogComponent, { width: '800px', diff --git a/src/app/features/project/metadata/services/metadata.service.ts b/src/app/features/project/metadata/services/metadata.service.ts index 6839919d4..0515535d8 100644 --- a/src/app/features/project/metadata/services/metadata.service.ts +++ b/src/app/features/project/metadata/services/metadata.service.ts @@ -4,20 +4,16 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@osf/core/services'; -import { ProjectMetadataMapper } from '@osf/features/project/metadata/mappers/project-metadata.mapper'; -import { ProjectMetadataUpdateMapper } from '@osf/features/project/metadata/mappers/project-metadata-update.mapper'; -import { - CedarMetadataRecord, - CedarMetadataRecordJsonApi, - CedarMetadataTemplateJsonApi, - ProjectMetadata, -} from '@osf/features/project/metadata/models'; -import { ProjectOverview } from '@osf/features/project/overview/models'; +import { ProjectOverview } from '../../overview/models'; +import { ProjectMetadataMapper } from '../mappers'; +import { ProjectMetadataUpdateMapper } from '../mappers/project-metadata-update.mapper'; +import { CedarMetadataRecord, CedarMetadataRecordJsonApi, CedarMetadataTemplateJsonApi } from '../models'; import { CrossRefFundersResponse, CustomItemMetadataRecord, CustomItemMetadataResponse, + ProjectMetadata, UserInstitutionsResponse, } from '../models/metadata.models'; diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 35e628155..4c5f037ba 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -3,7 +3,7 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { ResourceType } from '@osf/shared/enums'; -import { ContributorsState, ViewOnlyLinkState } from '@osf/shared/stores'; +import { ContributorsState, SubjectsState, ViewOnlyLinkState } from '@osf/shared/stores'; import { AnalyticsState } from './analytics/store'; import { ProjectFilesState } from './files/store'; @@ -28,7 +28,7 @@ export const projectRoutes: Routes = [ path: 'metadata', loadChildren: () => import('../project/metadata/project-metadata.routes').then((mod) => mod.projectMetadataRoutes), - providers: [provideStates([ContributorsState])], + providers: [provideStates([ContributorsState, SubjectsState])], }, { path: 'files', diff --git a/src/app/features/registries/components/metadata/metadata.component.ts b/src/app/features/registries/components/metadata/metadata.component.ts index e17999dc5..55a52a0fa 100644 --- a/src/app/features/registries/components/metadata/metadata.component.ts +++ b/src/app/features/registries/components/metadata/metadata.component.ts @@ -15,8 +15,9 @@ import { ActivatedRoute, Router } from '@angular/router'; import { TextInputComponent } from '@osf/shared/components'; import { INPUT_VALIDATION_MESSAGES, InputLimits } from '@osf/shared/constants'; -import { Subject } from '@osf/shared/models'; -import { CustomConfirmationService, ToastService } from '@osf/shared/services'; +import { SubjectModel } from '@osf/shared/models'; +import { CustomConfirmationService } from '@osf/shared/services'; +import { SubjectsSelectors } from '@osf/shared/stores'; import { CustomValidators, findChangedFields } from '@osf/shared/utils'; import { Registration } from '../../models'; @@ -48,14 +49,13 @@ import { RegistriesTagsComponent } from './registries-tags/registries-tags.compo }) export class MetadataComponent implements OnDestroy { private readonly fb = inject(FormBuilder); - private readonly toastService = inject(ToastService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly customConfirmationService = inject(CustomConfirmationService); private readonly draftId = this.route.snapshot.params['id']; protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); - protected selectedSubjects = select(RegistriesSelectors.getSelectedSubjects); + protected selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); protected actions = createDispatchMap({ deleteDraft: DeleteDraft, @@ -69,7 +69,7 @@ export class MetadataComponent implements OnDestroy { title: ['', CustomValidators.requiredTrimmed()], description: ['', CustomValidators.requiredTrimmed()], // contributors: [[], Validators.required], - subjects: [[] as Subject[], Validators.required], + subjects: [[] as SubjectModel[], Validators.required], tags: [[]], license: this.fb.group({ id: ['', Validators.required], diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts index 6ee971da7..1fd51f4bb 100644 --- a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts +++ b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts @@ -4,14 +4,16 @@ import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; -import { - FetchRegistrationSubjects, - RegistriesSelectors, - UpdateRegistrationSubjects, -} from '@osf/features/registries/store'; import { SubjectsComponent } from '@osf/shared/components'; -import { Subject } from '@osf/shared/models'; -import { FetchChildrenSubjects, FetchSubjects } from '@osf/shared/stores'; +import { ResourceType } from '@osf/shared/enums'; +import { SubjectModel } from '@osf/shared/models'; +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores'; @Component({ selector: 'osf-registries-subjects', @@ -26,19 +28,19 @@ export class RegistriesSubjectsComponent { private readonly draftId = this.route.snapshot.params['id']; private readonly OSF_PROVIDER_ID = 'osf'; - protected selectedSubjects = select(RegistriesSelectors.getSelectedSubjects); - protected isSubjectsUpdating = select(RegistriesSelectors.isSubjectsUpdating); + protected selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + protected isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); protected actions = createDispatchMap({ fetchSubjects: FetchSubjects, - fetchRegistrationSubjects: FetchRegistrationSubjects, + fetchSelectedSubjects: FetchSelectedSubjects, fetchChildrenSubjects: FetchChildrenSubjects, - updateRegistrationSubjects: UpdateRegistrationSubjects, + updateResourceSubjects: UpdateResourceSubjects, }); constructor() { - this.actions.fetchSubjects(this.OSF_PROVIDER_ID); - this.actions.fetchRegistrationSubjects(this.draftId); + this.actions.fetchSubjects(ResourceType.Registration, this.OSF_PROVIDER_ID); + this.actions.fetchSelectedSubjects(this.draftId, ResourceType.DraftRegistration); } getSubjectChildren(parentId: string) { @@ -46,12 +48,12 @@ export class RegistriesSubjectsComponent { } searchSubjects(search: string) { - this.actions.fetchSubjects(search); + this.actions.fetchSubjects(ResourceType.Registration, this.OSF_PROVIDER_ID, search); } - updateSelectedSubjects(subjects: Subject[]) { + updateSelectedSubjects(subjects: SubjectModel[]) { this.updateControlState(subjects); - this.actions.updateRegistrationSubjects(this.draftId, subjects); + this.actions.updateResourceSubjects(this.draftId, ResourceType.DraftRegistration, subjects); } onFocusOut() { @@ -62,7 +64,7 @@ export class RegistriesSubjectsComponent { } } - updateControlState(value: Subject[]) { + updateControlState(value: SubjectModel[]) { if (this.control()) { this.control().setValue(value); this.control().markAsTouched(); diff --git a/src/app/features/registries/registries.routes.ts b/src/app/features/registries/registries.routes.ts index a77a2d0e9..dc995a80c 100644 --- a/src/app/features/registries/registries.routes.ts +++ b/src/app/features/registries/registries.routes.ts @@ -5,12 +5,11 @@ import { Routes } from '@angular/router'; import { RegistriesComponent } from '@osf/features/registries/registries.component'; import { RegistriesState } from '@osf/features/registries/store'; import { ContributorsState, SubjectsState } from '@osf/shared/stores'; -import { SUBJECTS_SERVICE } from '@osf/shared/tokens'; import { ModerationState } from '../moderation/store'; -import { LicensesHandlers, ProjectsHandlers, ProvidersHandlers, SubjectsHandlers } from './store/handlers'; -import { LicensesService, RegistrationSubjectsService } from './services'; +import { LicensesHandlers, ProjectsHandlers, ProvidersHandlers } from './store/handlers'; +import { LicensesService } from './services'; export const registriesRoutes: Routes = [ { @@ -21,13 +20,7 @@ export const registriesRoutes: Routes = [ ProvidersHandlers, ProjectsHandlers, LicensesHandlers, - SubjectsHandlers, - RegistrationSubjectsService, LicensesService, - { - provide: SUBJECTS_SERVICE, - useClass: RegistrationSubjectsService, - }, ], children: [ { diff --git a/src/app/features/registries/services/index.ts b/src/app/features/registries/services/index.ts index 99fc8de16..b6471bbb9 100644 --- a/src/app/features/registries/services/index.ts +++ b/src/app/features/registries/services/index.ts @@ -1,5 +1,4 @@ export * from './licenses.service'; export * from './projects.service'; export * from './providers.service'; -export * from './registration-subjects.service'; export * from './registries.service'; diff --git a/src/app/features/registries/services/registration-subjects.service.ts b/src/app/features/registries/services/registration-subjects.service.ts deleted file mode 100644 index 697c5b656..000000000 --- a/src/app/features/registries/services/registration-subjects.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { map, Observable } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { JsonApiService } from '@osf/core/services'; -import { SubjectMapper } from '@osf/shared/mappers'; -import { ISubjectsService, Subject, SubjectsResponseJsonApi } from '@osf/shared/models'; - -import { environment } from 'src/environments/environment'; - -@Injectable() -export class RegistrationSubjectsService implements ISubjectsService { - private apiUrl = environment.apiUrl; - private readonly jsonApiService = inject(JsonApiService); - - getSubjects(providerId: string, search?: string): Observable { - const params: Record = { - 'page[size]': '100', - sort: 'text', - related_counts: 'children', - 'filter[parent]': 'null', - }; - if (search) { - delete params['filter[parent]']; - params['filter[text]'] = search; - params['embed'] = 'parent'; - } - return this.jsonApiService - .get(`${this.apiUrl}/providers/registrations/${providerId}/subjects/`, params) - .pipe( - map((response) => { - return SubjectMapper.fromSubjectsResponseJsonApi(response); - }) - ); - } - - getChildrenSubjects(parentId: string): Observable { - const params: Record = { - 'page[size]': '100', - page: '1', - sort: 'text', - related_counts: 'children', - }; - - return this.jsonApiService - .get(`${this.apiUrl}/subjects/${parentId}/children/`, params) - .pipe( - map((response) => { - return SubjectMapper.fromSubjectsResponseJsonApi(response); - }) - ); - } - - getRegistrationSubjects(draftId: string): Observable { - const params: Record = { - 'page[size]': '100', - page: '1', - }; - - return this.jsonApiService - .get(`${this.apiUrl}/draft_registrations/${draftId}/subjects/`, params) - .pipe( - map((response) => { - return SubjectMapper.fromSubjectsResponseJsonApi(response); - }) - ); - } - - updateRegistrationSubjects(draftId: string, subjects: Subject[]): Observable { - const payload = { - data: subjects.map((item) => ({ id: item.id, type: 'subjects' })), - }; - - return this.jsonApiService.put(`${this.apiUrl}/draft_registrations/${draftId}/relationships/subjects/`, payload); - } -} diff --git a/src/app/features/registries/store/default.state.ts b/src/app/features/registries/store/default.state.ts index c267bd943..68ba15eec 100644 --- a/src/app/features/registries/store/default.state.ts +++ b/src/app/features/registries/store/default.state.ts @@ -27,11 +27,6 @@ export const DefaultState: RegistriesStateModel = { isLoading: false, error: null, }, - registrationSubjects: { - data: [], - isLoading: false, - error: null, - }, pagesSchema: { data: [], isLoading: false, diff --git a/src/app/features/registries/store/handlers/index.ts b/src/app/features/registries/store/handlers/index.ts index b3c19c705..96f12fb62 100644 --- a/src/app/features/registries/store/handlers/index.ts +++ b/src/app/features/registries/store/handlers/index.ts @@ -1,4 +1,3 @@ export * from './licenses.handlers'; export * from './projects.handlers'; export * from './providers.handlers'; -export * from './subjects.handlers'; diff --git a/src/app/features/registries/store/handlers/subjects.handlers.ts b/src/app/features/registries/store/handlers/subjects.handlers.ts deleted file mode 100644 index 7f06f3d63..000000000 --- a/src/app/features/registries/store/handlers/subjects.handlers.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { StateContext } from '@ngxs/store'; - -import { catchError, tap } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { handleSectionError } from '@osf/core/handlers'; - -import { RegistrationSubjectsService } from '../../services'; -import { FetchRegistrationSubjects, UpdateRegistrationSubjects } from '../registries.actions'; -import { RegistriesStateModel } from '../registries.model'; - -@Injectable() -export class SubjectsHandlers { - subjectsService = inject(RegistrationSubjectsService); - - fetchRegistrationSubjects(ctx: StateContext, { registrationId }: FetchRegistrationSubjects) { - ctx.patchState({ - registrationSubjects: { - ...ctx.getState().registrationSubjects, - isLoading: true, - error: null, - }, - }); - - return this.subjectsService.getRegistrationSubjects(registrationId).pipe( - tap((subjects) => { - ctx.patchState({ - registrationSubjects: { - data: subjects, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'registrationSubjects', error)) - ); - } - - updateRegistrationSubjects( - ctx: StateContext, - { registrationId, subjects }: UpdateRegistrationSubjects - ) { - ctx.patchState({ - registrationSubjects: { - ...ctx.getState().registrationSubjects, - isLoading: true, - error: null, - }, - }); - return this.subjectsService.updateRegistrationSubjects(registrationId, subjects).pipe( - tap(() => { - ctx.patchState({ - registrationSubjects: { - data: subjects, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'registrationSubjects', error)) - ); - } -} diff --git a/src/app/features/registries/store/registries.actions.ts b/src/app/features/registries/store/registries.actions.ts index f17b3f589..b74583278 100644 --- a/src/app/features/registries/store/registries.actions.ts +++ b/src/app/features/registries/store/registries.actions.ts @@ -1,4 +1,4 @@ -import { LicenseOptions, Subject } from '@osf/shared/models'; +import { LicenseOptions } from '@osf/shared/models'; import { RegistrationAttributesJsonApi, RegistrationRelationshipsJsonApi } from '../models'; @@ -56,19 +56,6 @@ export class SaveLicense { ) {} } -export class FetchRegistrationSubjects { - static readonly type = '[Registries] Fetch Registration Subjects'; - constructor(public registrationId: string) {} -} - -export class UpdateRegistrationSubjects { - static readonly type = '[Registries] Update Registration Subject'; - constructor( - public registrationId: string, - public subjects: Subject[] - ) {} -} - export class UpdateStepValidation { static readonly type = '[Registries] Update Step Validation'; constructor( diff --git a/src/app/features/registries/store/registries.model.ts b/src/app/features/registries/store/registries.model.ts index 9494630ca..73ab322d8 100644 --- a/src/app/features/registries/store/registries.model.ts +++ b/src/app/features/registries/store/registries.model.ts @@ -1,4 +1,4 @@ -import { AsyncStateModel, License, Resource, Subject } from '@shared/models'; +import { AsyncStateModel, License, Resource } from '@shared/models'; import { PageSchema, Project, Provider } from '../models'; import { Registration } from '../models/registration.model'; @@ -9,7 +9,6 @@ export interface RegistriesStateModel { draftRegistration: AsyncStateModel; registries: AsyncStateModel; licenses: AsyncStateModel; - registrationSubjects: AsyncStateModel; pagesSchema: AsyncStateModel; stepsValidation: Record; } diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 7988e5879..8f92c6275 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { License, Resource, Subject } from '@shared/models'; +import { License, Resource } from '@shared/models'; import { PageSchema, Project, Provider, Registration } from '../models'; @@ -63,16 +63,6 @@ export class RegistriesSelectors { return state.pagesSchema.data; } - @Selector([RegistriesState]) - static getSelectedSubjects(state: RegistriesStateModel): Subject[] { - return state.registrationSubjects.data; - } - - @Selector([RegistriesState]) - static isSubjectsUpdating(state: RegistriesStateModel): boolean { - return state.registrationSubjects.isLoading; - } - @Selector([RegistriesState]) static getSelectedTags(state: RegistriesStateModel): string[] { return state.draftRegistration.data?.tags || []; diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index 0d11eed79..12968e619 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -14,21 +14,18 @@ import { RegistriesService } from '../services'; import { LicensesHandlers } from './handlers/licenses.handlers'; import { ProjectsHandlers } from './handlers/projects.handlers'; import { ProvidersHandlers } from './handlers/providers.handlers'; -import { SubjectsHandlers } from './handlers/subjects.handlers'; import { DefaultState } from './default.state'; import { CreateDraft, DeleteDraft, FetchDraft, FetchLicenses, - FetchRegistrationSubjects, FetchSchemaBlocks, GetProjects, GetProviders, GetRegistries, SaveLicense, UpdateDraft, - UpdateRegistrationSubjects, UpdateStepValidation, } from './registries.actions'; import { RegistriesStateModel } from './registries.model'; @@ -45,7 +42,6 @@ export class RegistriesState { providersHandler = inject(ProvidersHandlers); projectsHandler = inject(ProjectsHandlers); licensesHandler = inject(LicensesHandlers); - subjectsHandler = inject(SubjectsHandlers); @Action(GetRegistries) getRegistries(ctx: StateContext) { @@ -229,17 +225,4 @@ export class RegistriesState { saveLicense(ctx: StateContext, { registrationId, licenseId, licenseOptions }: SaveLicense) { return this.licensesHandler.saveLicense(ctx, { registrationId, licenseId, licenseOptions }); } - - @Action(FetchRegistrationSubjects) - fetchRegistrationSubjects(ctx: StateContext, { registrationId }: FetchRegistrationSubjects) { - return this.subjectsHandler.fetchRegistrationSubjects(ctx, { registrationId }); - } - - @Action(UpdateRegistrationSubjects) - updateRegistrationSubjects( - ctx: StateContext, - { registrationId, subjects }: UpdateRegistrationSubjects - ) { - return this.subjectsHandler.updateRegistrationSubjects(ctx, { registrationId, subjects }); - } } diff --git a/src/app/features/settings/settings-container.component.spec.ts b/src/app/features/settings/settings-container.component.spec.ts index 5a0b6de71..8b56cb825 100644 --- a/src/app/features/settings/settings-container.component.spec.ts +++ b/src/app/features/settings/settings-container.component.spec.ts @@ -1,5 +1,3 @@ -import { BehaviorSubject } from 'rxjs'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -8,14 +6,10 @@ import { SettingsContainerComponent } from './settings-container.component'; describe('SettingsContainerComponent', () => { let component: SettingsContainerComponent; let fixture: ComponentFixture; - let isWebSubject: BehaviorSubject; beforeEach(async () => { - isWebSubject = new BehaviorSubject(false); - await TestBed.configureTestingModule({ imports: [SettingsContainerComponent], - providers: [], }).compileComponents(); fixture = TestBed.createComponent(SettingsContainerComponent); diff --git a/src/app/shared/components/subjects/subjects.component.ts b/src/app/shared/components/subjects/subjects.component.ts index d3e7ee0c0..6fcb0fadb 100644 --- a/src/app/shared/components/subjects/subjects.component.ts +++ b/src/app/shared/components/subjects/subjects.component.ts @@ -16,7 +16,7 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an import { FormControl } from '@angular/forms'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants'; -import { Subject } from '@osf/shared/models'; +import { SubjectModel } from '@osf/shared/models'; import { SubjectsSelectors } from '@shared/stores'; import { SearchInputComponent } from '../search-input/search-input.component'; @@ -34,15 +34,17 @@ export class SubjectsComponent { searchedSubjects = select(SubjectsSelectors.getSearchedSubjects); areSubjectsUpdating = input(false); isSearching = select(SubjectsSelectors.getSearchedSubjectsLoading); - control = input>(); - selected = input([]); + control = input>(); + selected = input([]); searchChanged = output(); loadChildren = output(); - updateSelection = output(); + updateSelection = output(); - subjectsTree = computed(() => this.subjects().map((subject: Subject) => this.mapSubjectToTreeNode(subject))); - selectedTree = computed(() => this.selected().map((subject: Subject) => this.mapSubjectToTreeNode(subject))); - searchedList = computed(() => this.searchedSubjects().map((subject: Subject) => this.mapParentsSubject(subject))); + subjectsTree = computed(() => this.subjects().map((subject: SubjectModel) => this.mapSubjectToTreeNode(subject))); + selectedTree = computed(() => this.selected().map((subject: SubjectModel) => this.mapSubjectToTreeNode(subject))); + searchedList = computed(() => + this.searchedSubjects().map((subject: SubjectModel) => this.mapParentsSubject(subject)) + ); expanded: Record = {}; protected searchControl = new FormControl(''); @@ -65,7 +67,7 @@ export class SubjectsComponent { this.expanded[event.data.id] = false; } - selectSubject(subject: Subject) { + selectSubject(subject: SubjectModel) { const childrenIds = this.getChildrenIds([subject]); const updatedSelection = [...this.selected().filter((s) => !childrenIds.includes(s.id)), subject]; const parentSubjects = this.mapParentsSubject(subject.parent).filter( @@ -77,14 +79,14 @@ export class SubjectsComponent { this.updateSelection.emit(updatedSelection); } - removeSubject(subject: Subject) { + removeSubject(subject: SubjectModel) { const updatedSelection = this.selected().filter( (s) => s.id !== subject.id && !this.getChildrenIds([subject]).includes(s.id) ); this.updateSelection.emit(updatedSelection); } - selectSearched(event: CheckboxChangeEvent, subjects: Subject[]) { + selectSearched(event: CheckboxChangeEvent, subjects: SubjectModel[]) { if (event.checked) { this.updateSelection.emit([...this.selected(), ...subjects]); } else { @@ -93,8 +95,8 @@ export class SubjectsComponent { } } - private getChildrenIds(subjects: Subject[]): string[] { - return subjects.reduce((acc: string[], subject: Subject) => { + private getChildrenIds(subjects: SubjectModel[]): string[] { + return subjects.reduce((acc: string[], subject: SubjectModel) => { acc.push(subject.id); if (subject.children) { acc.push(...this.getChildrenIds(subject.children)); @@ -103,18 +105,18 @@ export class SubjectsComponent { }, []); } - private mapSubjectToTreeNode(subject: Subject): TreeNode { + private mapSubjectToTreeNode(subject: SubjectModel): TreeNode { return { label: subject.name, data: subject, key: subject.id, - children: subject.children?.map((child: Subject) => this.mapSubjectToTreeNode(child)), + children: subject.children?.map((child: SubjectModel) => this.mapSubjectToTreeNode(child)), leaf: !subject.children, expanded: this.expanded[subject.id] ?? false, }; } - private mapParentsSubject(subject: Subject | null | undefined, acc: Subject[] = []): Subject[] { + private mapParentsSubject(subject: SubjectModel | null | undefined, acc: SubjectModel[] = []): SubjectModel[] { if (!subject) { return acc.reverse(); } diff --git a/src/app/shared/mappers/subjects/subject-mapper.ts b/src/app/shared/mappers/subjects/subject-mapper.ts index 7f1e653a3..92ca1cfed 100644 --- a/src/app/shared/mappers/subjects/subject-mapper.ts +++ b/src/app/shared/mappers/subjects/subject-mapper.ts @@ -1,104 +1,9 @@ -import { NodeSubjectModel, Subject, SubjectData, SubjectDataJsonApi, SubjectsResponseJsonApi } from '@shared/models'; +import { SubjectDataJsonApi, SubjectModel, SubjectsResponseJsonApi } from '@shared/models'; export class SubjectMapper { - static mapSubjectsResponse(subjectData: SubjectData[]): NodeSubjectModel[] { - const subjectMap = new Map(); - const rootSubjects: NodeSubjectModel[] = []; - - const processSubject = (data: SubjectData, level = 0): NodeSubjectModel => { - if (subjectMap.has(data.id)) { - return subjectMap.get(data.id)!; - } - - const subject: NodeSubjectModel = { - id: data.id, - text: data.attributes.text, - taxonomy_name: data.attributes.taxonomy_name, - level: level, - children: [], - }; - - subjectMap.set(data.id, subject); - return subject; - }; - - subjectData.forEach((data) => { - processSubject(data); - - if (data.embeds?.parent?.data) { - const parentData = data.embeds.parent.data; - processSubject(parentData); - - if (parentData.embeds?.parent?.data) { - processSubject(parentData.embeds.parent.data); - } - } - }); - - subjectData.forEach((data) => { - const subject = subjectMap.get(data.id); - if (!subject) return; - - if (data.embeds?.parent?.data) { - const parentData = data.embeds.parent.data; - const parent = subjectMap.get(parentData.id); - - if (parent) { - subject.parent = parent; - - if (!parent.children?.some((child) => child.id === subject.id)) { - if (!parent.children) parent.children = []; - parent.children.push(subject); - } - - if (parentData.embeds?.parent?.data) { - const grandparentData = parentData.embeds.parent.data; - const grandparent = subjectMap.get(grandparentData.id); - - if (grandparent && !parent.parent) { - parent.parent = grandparent; - - if (!grandparent.children?.some((child) => child.id === parent.id)) { - if (!grandparent.children) grandparent.children = []; - grandparent.children.push(parent); - } - } - } - } - } - }); - - const calculateLevels = (subject: NodeSubjectModel): number => { - if (subject.parent) { - subject.level = calculateLevels(subject.parent) + 1; - } else { - subject.level = 0; - if (!rootSubjects.some((root) => root.id === subject.id)) { - rootSubjects.push(subject); - } - } - return subject.level; - }; - - Array.from(subjectMap.values()).forEach((subject) => { - calculateLevels(subject); - }); - - const sortSubjects = (subjects: NodeSubjectModel[]): NodeSubjectModel[] => { - return subjects - .sort((a, b) => a.text.localeCompare(b.text)) - .map((subject) => ({ - ...subject, - children: subject.children ? sortSubjects(subject.children) : [], - })); - }; - - return sortSubjects(rootSubjects); - } - - static fromSubjectsResponseJsonApi(response: SubjectsResponseJsonApi): Subject[] { + static fromSubjectsResponseJsonApi(response: SubjectsResponseJsonApi): SubjectModel[] { return response.data.map((data) => { - const subject: Subject = { + const subject: SubjectModel = { id: data.id, name: data.attributes.text, children: data.relationships.children?.links.related.meta.count > 0 ? [] : undefined, @@ -109,7 +14,7 @@ export class SubjectMapper { }); } - private static setSubjectParent(data: SubjectDataJsonApi): Subject | null { + private static setSubjectParent(data: SubjectDataJsonApi): SubjectModel | null { if (!data) { return null; } diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index 08e4a449a..809165bd6 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -18,7 +18,6 @@ export * from './license.model'; export * from './license.model'; export * from './licenses-json-api.model'; export * from './metadata-field.model'; -export * from './node-subject.model'; export * from './nodes/create-project-form.model'; export * from './nodes/nodes-json-api.model'; export * from './paginated-data.model'; diff --git a/src/app/shared/models/node-subject.model.ts b/src/app/shared/models/node-subject.model.ts deleted file mode 100644 index 62575185b..000000000 --- a/src/app/shared/models/node-subject.model.ts +++ /dev/null @@ -1,68 +0,0 @@ -export interface NodeSubjectModel { - id: string; - text: string; - taxonomy_name: string; - parent?: NodeSubjectModel; - children?: NodeSubjectModel[]; - level: number; -} - -export interface SubjectData extends UpdateSubjectRequestJsonApi { - attributes: { - text: string; - taxonomy_name: string; - }; - relationships: { - parent?: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data?: { - id: string; - type: 'subjects'; - }; - }; - children: { - links: { - related: { - href: string; - meta: Record; - }; - }; - }; - }; - embeds?: { - parent?: { - data: SubjectData; - }; - }; - links: { - self: string; - iri: string; - }; -} - -export interface SubjectJsonApi { - data: SubjectData[]; - links: { - first: string | null; - last: string; - prev: string | null; - next: string | null; - meta: { - total: number; - per_page: number; - }; - }; - meta: { - version: string; - }; -} - -export interface UpdateSubjectRequestJsonApi { - id: string; - type: 'subjects'; -} diff --git a/src/app/shared/models/subject/subject-service.model.ts b/src/app/shared/models/subject/subject-service.model.ts index 56d8abcb8..4c3355ab0 100644 --- a/src/app/shared/models/subject/subject-service.model.ts +++ b/src/app/shared/models/subject/subject-service.model.ts @@ -1,9 +1,9 @@ import { Observable } from 'rxjs'; -import { Subject } from './subject.model'; +import { SubjectModel } from './subject.model'; export interface ISubjectsService { - getSubjects(providerId: string, search?: string): Observable; + getSubjects(providerId: string, search?: string): Observable; - getChildrenSubjects(parentId: string): Observable; + getChildrenSubjects(parentId: string): Observable; } diff --git a/src/app/shared/models/subject/subject.model.ts b/src/app/shared/models/subject/subject.model.ts index 78f50f91a..725bb1f84 100644 --- a/src/app/shared/models/subject/subject.model.ts +++ b/src/app/shared/models/subject/subject.model.ts @@ -1,8 +1,8 @@ -export interface Subject { +export interface SubjectModel { id: string; name: string; - children?: Subject[]; - parent?: Subject | null; + children?: SubjectModel[]; + parent?: SubjectModel | null; expanded?: boolean; iri?: string; } diff --git a/src/app/shared/services/subjects.service.ts b/src/app/shared/services/subjects.service.ts index 359f8cb1e..643493104 100644 --- a/src/app/shared/services/subjects.service.ts +++ b/src/app/shared/services/subjects.service.ts @@ -5,7 +5,9 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services'; import { SubjectMapper } from '@shared/mappers'; -import { NodeSubjectModel, SubjectJsonApi, UpdateSubjectRequestJsonApi } from '@shared/models'; +import { SubjectModel, SubjectsResponseJsonApi } from '@shared/models'; + +import { ResourceType } from '../enums'; import { environment } from 'src/environments/environment'; @@ -16,25 +18,68 @@ export class SubjectsService { private readonly jsonApiService = inject(JsonApiService); private readonly apiUrl = environment.apiUrl; - getSubjects(): Observable { - return this.jsonApiService.get(`${this.apiUrl}/subjects/?page[size]=150&embed=parent`).pipe( - map((response) => { - return SubjectMapper.mapSubjectsResponse(response.data); - }) - ); + private readonly urlMap = new Map([ + [ResourceType.Project, 'nodes'], + [ResourceType.Registration, 'registrations'], + [ResourceType.Preprint, 'preprints'], + [ResourceType.DraftRegistration, 'draft_registrations'], + ]); + + getSubjects(resourceType: ResourceType, resourceId?: string, search?: string): Observable { + const baseUrl = + resourceType === ResourceType.Project + ? `${this.apiUrl}/subjects/` + : `${this.apiUrl}/providers/${this.urlMap.get(resourceType)}/${resourceId}/subjects/`; + + const params: Record = { + 'page[size]': '100', + sort: 'text', + related_counts: 'children', + 'filter[parent]': 'null', + }; + + if (search) { + delete params['filter[parent]']; + params['filter[text]'] = search; + params['embed'] = 'parent'; + } + + return this.jsonApiService + .get(baseUrl, params) + .pipe(map((response) => SubjectMapper.fromSubjectsResponseJsonApi(response))); + } + + getChildrenSubjects(parentId: string): Observable { + const params: Record = { + 'page[size]': '100', + page: '1', + sort: 'text', + related_counts: 'children', + }; + + return this.jsonApiService + .get(`${this.apiUrl}/subjects/${parentId}/children/`, params) + .pipe(map((response) => SubjectMapper.fromSubjectsResponseJsonApi(response))); + } + + getResourceSubjects(resourceId: string, resourceType: ResourceType): Observable { + const baseUrl = `${this.apiUrl}/${this.urlMap.get(resourceType)}/${resourceId}/subjects/`; + const params: Record = { + 'page[size]': '100', + page: '1', + }; + + return this.jsonApiService + .get(baseUrl, params) + .pipe(map((response) => SubjectMapper.fromSubjectsResponseJsonApi(response))); } - updateProjectSubjects(projectId: string, subjectIds: string[]): Observable { + updateResourceSubjects(resourceId: string, resourceType: ResourceType, subjects: SubjectModel[]): Observable { + const baseUrl = `${this.apiUrl}/${this.urlMap.get(resourceType)}/${resourceId}/relationships/subjects/`; const payload = { - data: subjectIds.map((id) => ({ - type: 'subjects', - id, - })), + data: subjects.map((item) => ({ id: item.id, type: 'subjects' })), }; - return this.jsonApiService.put( - `${this.apiUrl}/nodes/${projectId}/relationships/subjects/`, - payload - ); + return this.jsonApiService.put(baseUrl, payload); } } diff --git a/src/app/shared/stores/subjects/subjects.actions.ts b/src/app/shared/stores/subjects/subjects.actions.ts index 6be7d53ca..34891fe20 100644 --- a/src/app/shared/stores/subjects/subjects.actions.ts +++ b/src/app/shared/stores/subjects/subjects.actions.ts @@ -1,22 +1,22 @@ -export class GetSubjects { - static readonly type = '[Subjects] Get Subjects'; -} +import { ResourceType } from '@osf/shared/enums'; +import { SubjectModel } from '@osf/shared/models'; -export class UpdateProjectSubjects { - static readonly type = '[Subjects] Update Project'; +export class FetchSubjects { + static readonly type = '[Subjects] Fetch Subjects'; constructor( - public projectId: string, - public subjectIds: string[] + public resourceType: ResourceType | undefined, + public resourceId?: string, + public search?: string ) {} } -export class FetchSubjects { - static readonly type = '[Subjects] Fetch Subjects'; +export class FetchSelectedSubjects { + static readonly type = '[Subjects] Fetch Selected Subjects'; constructor( - public providerId: string, - public search?: string + public resourceId: string, + public resourceType: ResourceType | undefined ) {} } @@ -25,3 +25,13 @@ export class FetchChildrenSubjects { constructor(public parentId: string) {} } + +export class UpdateResourceSubjects { + static readonly type = '[Subjects] Update Resource Project'; + + constructor( + public resourceId: string, + public resourceType: ResourceType | undefined, + public subjects: SubjectModel[] + ) {} +} diff --git a/src/app/shared/stores/subjects/subjects.model.ts b/src/app/shared/stores/subjects/subjects.model.ts index e24bd610d..13b9f3e88 100644 --- a/src/app/shared/stores/subjects/subjects.model.ts +++ b/src/app/shared/stores/subjects/subjects.model.ts @@ -1,7 +1,7 @@ -import { AsyncStateModel, NodeSubjectModel, Subject } from '@shared/models'; +import { AsyncStateModel, SubjectModel } from '@shared/models'; export interface SubjectsModel { - highlightedSubjects: AsyncStateModel; - subjects: AsyncStateModel; - searchedSubjects: AsyncStateModel; + subjects: AsyncStateModel; + searchedSubjects: AsyncStateModel; + selectedSubjects: AsyncStateModel; } diff --git a/src/app/shared/stores/subjects/subjects.selectors.ts b/src/app/shared/stores/subjects/subjects.selectors.ts index 558097696..f1eea6397 100644 --- a/src/app/shared/stores/subjects/subjects.selectors.ts +++ b/src/app/shared/stores/subjects/subjects.selectors.ts @@ -1,23 +1,13 @@ import { Selector } from '@ngxs/store'; -import { Subject } from '@osf/shared/models'; +import { SubjectModel } from '@osf/shared/models'; import { SubjectsModel } from './subjects.model'; import { SubjectsState } from './subjects.state'; export class SubjectsSelectors { @Selector([SubjectsState]) - static getHighlightedSubjects(state: SubjectsModel) { - return state.highlightedSubjects.data; - } - - @Selector([SubjectsState]) - static getHighlightedSubjectsLoading(state: SubjectsModel): boolean { - return state.highlightedSubjects.isLoading; - } - - @Selector([SubjectsState]) - static getSubjects(state: SubjectsModel): Subject[] { + static getSubjects(state: SubjectsModel): SubjectModel[] { return state.subjects.data; } @@ -27,7 +17,7 @@ export class SubjectsSelectors { } @Selector([SubjectsState]) - static getSearchedSubjects(state: SubjectsModel): Subject[] { + static getSearchedSubjects(state: SubjectsModel): SubjectModel[] { return state.searchedSubjects.data; } @@ -35,4 +25,14 @@ export class SubjectsSelectors { static getSearchedSubjectsLoading(state: SubjectsModel): boolean { return state.searchedSubjects.isLoading; } + + @Selector([SubjectsState]) + static getSelectedSubjects(state: SubjectsModel): SubjectModel[] { + return state.selectedSubjects.data; + } + + @Selector([SubjectsState]) + static areSelectedSubjectsLoading(state: SubjectsModel): boolean { + return state.selectedSubjects.isLoading; + } } diff --git a/src/app/shared/stores/subjects/subjects.state.ts b/src/app/shared/stores/subjects/subjects.state.ts index 7b30563ef..04a6ff62c 100644 --- a/src/app/shared/stores/subjects/subjects.state.ts +++ b/src/app/shared/stores/subjects/subjects.state.ts @@ -4,25 +4,29 @@ import { catchError, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { SUBJECTS_SERVICE } from '@osf/shared/tokens/subjects.token'; -import { ISubjectsService, NodeSubjectModel, Subject } from '@shared/models'; +import { SubjectModel } from '@shared/models'; import { SubjectsService } from '@shared/services'; -import { FetchChildrenSubjects, FetchSubjects, GetSubjects, UpdateProjectSubjects } from './subjects.actions'; +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + UpdateResourceSubjects, +} from './subjects.actions'; import { SubjectsModel } from './subjects.model'; const initialState: SubjectsModel = { - highlightedSubjects: { + subjects: { data: [], isLoading: false, error: null, }, - subjects: { + searchedSubjects: { data: [], isLoading: false, error: null, }, - searchedSubjects: { + selectedSubjects: { data: [], isLoading: false, error: null, @@ -35,11 +39,14 @@ const initialState: SubjectsModel = { }) @Injectable() export class SubjectsState { - private readonly projectSubjectsService = inject(SubjectsService); - private readonly subjectsService = inject(SUBJECTS_SERVICE); + private readonly subjectsService = inject(SubjectsService); @Action(FetchSubjects) - fetchSubjects(ctx: StateContext, { providerId, search }: FetchSubjects) { + fetchSubjects(ctx: StateContext, { resourceId, resourceType, search }: FetchSubjects) { + if (!resourceType) { + return; + } + ctx.patchState({ subjects: { ...ctx.getState().subjects, @@ -53,7 +60,7 @@ export class SubjectsState { }, }); - return this.subjectsService.getSubjects(providerId, search).pipe( + return this.subjectsService.getSubjects(resourceType, resourceId, search).pipe( tap((subjects) => { if (search) { ctx.patchState({ @@ -103,91 +110,70 @@ export class SubjectsState { ); } - @Action(GetSubjects) - getSubjects(ctx: StateContext) { + @Action(FetchSelectedSubjects) + fetchSelectedSubjects(ctx: StateContext, { resourceId, resourceType }: FetchSelectedSubjects) { + if (!resourceType) { + return; + } + ctx.patchState({ - highlightedSubjects: { + selectedSubjects: { data: [], isLoading: true, error: null, }, }); - return this.projectSubjectsService.getSubjects().pipe( - tap({ - next: (subjects) => { - ctx.patchState({ - highlightedSubjects: { - data: subjects, - error: null, - isLoading: false, - }, - }); - }, - }), - catchError((error) => { + + return this.subjectsService.getResourceSubjects(resourceId, resourceType).pipe( + tap((subjects) => { ctx.patchState({ - highlightedSubjects: { - ...ctx.getState().highlightedSubjects, + selectedSubjects: { + data: subjects, isLoading: false, - error, + error: null, }, }); - return throwError(() => error); }), - catchError((error) => { + catchError((error) => this.handleError(ctx, 'selectedSubjects', error)) + ); + } + + @Action(UpdateResourceSubjects) + updateResourceSubjects( + ctx: StateContext, + { resourceId, resourceType, subjects }: UpdateResourceSubjects + ) { + if (!resourceType) { + return; + } + + ctx.patchState({ + selectedSubjects: { + ...ctx.getState().selectedSubjects, + isLoading: true, + error: null, + }, + }); + + return this.subjectsService.updateResourceSubjects(resourceId, resourceType, subjects).pipe( + tap(() => { ctx.patchState({ - highlightedSubjects: { - ...ctx.getState().highlightedSubjects, + selectedSubjects: { + data: subjects, isLoading: false, - error, + error: null, }, }); - return throwError(() => error); - }) - ); - } - - @Action(UpdateProjectSubjects) - updateProjectSubjects(ctx: StateContext, action: UpdateProjectSubjects) { - return this.projectSubjectsService.updateProjectSubjects(action.projectId, action.subjectIds).pipe( - tap({ - next: (result) => { - const state = ctx.getState(); - - const updatedSubjects = result - .map((updatedSubject: { id: string; type: string }) => { - const findSubjectById = (subjects: NodeSubjectModel[]): NodeSubjectModel | undefined => { - for (const subject of subjects) { - if (subject.id === updatedSubject.id) { - return subject; - } - if (subject.children) { - const found = findSubjectById(subject.children); - if (found) { - return found; - } - } - } - return undefined; - }; - - const foundSubject = findSubjectById(state.highlightedSubjects.data); - return foundSubject - ? { - id: foundSubject.id, - text: foundSubject.text, - } - : null; - }) - .filter((subject: { id: string; text: string } | null) => subject !== null); - - return updatedSubjects; - }, - }) + }), + catchError((error) => this.handleError(ctx, 'selectedSubjects', error)) ); } - private updateSubjectChildren(subjects: Subject[], parentId: string, newChildren: Subject[]): Subject[] { + private updateSubjectChildren( + subjects: SubjectModel[], + parentId: string, + newChildren: SubjectModel[] + ): SubjectModel[] { return subjects.map((subject) => { if (subject.id === parentId) { return { ...subject, children: newChildren }; From aeb3041fbe8813f43752d8a9ebbf413b4168e311 Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 9 Jul 2025 16:26:42 +0300 Subject: [PATCH 2/3] fix(create project): fixed bug --- src/app/features/home/home.component.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts index 87080fcd9..dc66eb9b5 100644 --- a/src/app/features/home/home.component.ts +++ b/src/app/features/home/home.component.ts @@ -16,12 +16,13 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MY_PROJECTS_TABLE_PARAMS } from '@osf/core/constants'; import { MyProjectsItem } from '@osf/features/my-projects/models'; -import { AddProjectFormComponent, MyProjectsTableComponent, SubHeaderComponent } from '@osf/shared/components'; +import { MyProjectsTableComponent, SubHeaderComponent } from '@osf/shared/components'; import { SortOrder } from '@osf/shared/enums'; import { TableParameters } from '@osf/shared/models'; import { IS_MEDIUM } from '@osf/shared/utils'; import { FetchUserInstitutions } from '@shared/stores'; +import { CreateProjectDialogComponent } from '../my-projects/components'; import { MyProjectsSearchFilters } from '../my-projects/models'; import { ClearMyProjects, GetMyProjects, MyProjectsSelectors } from '../my-projects/store'; import { AccountSettingsService } from '../settings/account-settings/services'; @@ -233,7 +234,7 @@ export class HomeComponent implements OnInit { this.isSubmitting.set(true); this.dialogService - .open(AddProjectFormComponent, { + .open(CreateProjectDialogComponent, { width: dialogWidth, focusOnShow: false, header: this.translateService.instant('myProjects.header.createProject'), From 1ad2dab65d1bdae86e4c7af4b02a10442fdc6f99 Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 9 Jul 2025 16:46:44 +0300 Subject: [PATCH 3/3] fix(subjects): updated subjects card --- .../preprints-subjects.component.html | 16 ++- .../preprints-subjects.component.ts | 4 +- .../project-metadata-subjects.component.html | 16 ++- .../project-metadata-subjects.component.ts | 4 +- .../registries-subjects.component.html | 23 ++-- .../registries-subjects.component.ts | 10 +- .../subjects/subjects.component.html | 126 +++++++++--------- .../components/subjects/subjects.component.ts | 6 +- 8 files changed, 113 insertions(+), 92 deletions(-) diff --git a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.html b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.html index 4b926a1c0..97bd7afd0 100644 --- a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.html +++ b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.html @@ -1,7 +1,9 @@ - + + + diff --git a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts index 8ca98f103..abbeef477 100644 --- a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts +++ b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts @@ -1,5 +1,7 @@ import { createDispatchMap, select } from '@ngxs/store'; +import { Card } from 'primeng/card'; + import { ChangeDetectionStrategy, Component, input, OnInit } from '@angular/core'; import { SubmitPreprintSelectors } from '@osf/features/preprints/store/submit-preprint'; @@ -16,7 +18,7 @@ import { @Component({ selector: 'osf-preprints-subjects', - imports: [SubjectsComponent], + imports: [SubjectsComponent, Card], templateUrl: './preprints-subjects.component.html', styleUrl: './preprints-subjects.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html index 4b926a1c0..991d700d1 100644 --- a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html +++ b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.html @@ -1,7 +1,9 @@ - + + + diff --git a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts index f1839c943..10a3a81e6 100644 --- a/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts +++ b/src/app/features/project/metadata/components/project-metadata-subjects/project-metadata-subjects.component.ts @@ -1,5 +1,7 @@ import { createDispatchMap, select } from '@ngxs/store'; +import { Card } from 'primeng/card'; + import { ChangeDetectionStrategy, Component, input, OnInit } from '@angular/core'; import { ResourceType } from '@osf/shared/enums'; @@ -15,7 +17,7 @@ import { SubjectsComponent } from '@shared/components'; @Component({ selector: 'osf-project-metadata-subjects', - imports: [SubjectsComponent], + imports: [SubjectsComponent, Card], templateUrl: './project-metadata-subjects.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html index 87384f84b..0136d2f40 100644 --- a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html +++ b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html @@ -1,8 +1,15 @@ - + + + + @if (control().errors?.['required'] && (control().touched || control().dirty)) { + + {{ INPUT_VALIDATION_MESSAGES.required | translate }} + + } + diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts index 1fd51f4bb..a26d06cd3 100644 --- a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts +++ b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts @@ -1,10 +1,16 @@ import { createDispatchMap, select } from '@ngxs/store'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; +import { Message } from 'primeng/message'; + import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { SubjectsComponent } from '@osf/shared/components'; +import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants'; import { ResourceType } from '@osf/shared/enums'; import { SubjectModel } from '@osf/shared/models'; import { @@ -17,7 +23,7 @@ import { @Component({ selector: 'osf-registries-subjects', - imports: [SubjectsComponent], + imports: [SubjectsComponent, Card, Message, TranslatePipe], templateUrl: './registries-subjects.component.html', styleUrl: './registries-subjects.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -38,6 +44,8 @@ export class RegistriesSubjectsComponent { updateResourceSubjects: UpdateResourceSubjects, }); + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + constructor() { this.actions.fetchSubjects(ResourceType.Registration, this.OSF_PROVIDER_ID); this.actions.fetchSelectedSubjects(this.draftId, ResourceType.DraftRegistration); diff --git a/src/app/shared/components/subjects/subjects.component.html b/src/app/shared/components/subjects/subjects.component.html index 8698217b6..7a7996439 100644 --- a/src/app/shared/components/subjects/subjects.component.html +++ b/src/app/shared/components/subjects/subjects.component.html @@ -1,66 +1,68 @@ - -

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

- - @if (!selected().length) { -

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

+

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

+ + + @if (!selected().length) { +

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

+ } @else { + @for (subject of selected(); track subject.id) { + + } + } +
+ + + +@if (searchControl.value) { +
+ @if (isSearching()) { + + + + } @else { - @for (subject of selected(); track subject.id) { - + @if (!searchedList().length) { +

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

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

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

- } - @for (subjects of searchedList(); track $index) { -
- - +
} -
- } @else { - - - } - @if (control()?.errors?.['required'] && (control()?.touched || control()?.dirty)) { - - {{ INPUT_VALIDATION_MESSAGES.required | translate }} - - } - + } +
+} @else { + + +} diff --git a/src/app/shared/components/subjects/subjects.component.ts b/src/app/shared/components/subjects/subjects.component.ts index 6fcb0fadb..e7bac58d6 100644 --- a/src/app/shared/components/subjects/subjects.component.ts +++ b/src/app/shared/components/subjects/subjects.component.ts @@ -6,7 +6,6 @@ import { TreeNode } from 'primeng/api'; import { Card } from 'primeng/card'; import { Checkbox, CheckboxChangeEvent } from 'primeng/checkbox'; import { Chip } from 'primeng/chip'; -import { Message } from 'primeng/message'; import { Skeleton } from 'primeng/skeleton'; import { Tree, TreeModule } from 'primeng/tree'; @@ -15,7 +14,6 @@ import { debounceTime, distinctUntilChanged } from 'rxjs'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants'; import { SubjectModel } from '@osf/shared/models'; import { SubjectsSelectors } from '@shared/stores'; @@ -23,7 +21,7 @@ import { SearchInputComponent } from '../search-input/search-input.component'; @Component({ selector: 'osf-subjects', - imports: [Card, TranslatePipe, Chip, SearchInputComponent, Tree, TreeModule, Checkbox, Skeleton, Message], + imports: [Card, TranslatePipe, Chip, SearchInputComponent, Tree, TreeModule, Checkbox, Skeleton], templateUrl: './subjects.component.html', styleUrl: './subjects.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -34,7 +32,6 @@ export class SubjectsComponent { searchedSubjects = select(SubjectsSelectors.getSearchedSubjects); areSubjectsUpdating = input(false); isSearching = select(SubjectsSelectors.getSearchedSubjectsLoading); - control = input>(); selected = input([]); searchChanged = output(); loadChildren = output(); @@ -48,7 +45,6 @@ export class SubjectsComponent { expanded: Record = {}; protected searchControl = new FormControl(''); - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; constructor() { this.searchControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => {