diff --git a/jest.config.js b/jest.config.js index bac82ca2e..6a119eb3c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -78,10 +78,7 @@ module.exports = { '/src/app/features/files/', '/src/app/features/my-projects/', '/src/app/features/preprints/', - '/src/app/features/project/analytics/', '/src/app/features/project/contributors/', - '/src/app/features/project/files/', - '/src/app/features/project/metadata/', '/src/app/features/project/overview/', '/src/app/features/project/registrations', '/src/app/features/project/settings', diff --git a/src/app/features/analytics/analytics.component.spec.ts b/src/app/features/analytics/analytics.component.spec.ts index cba9b3191..d71856472 100644 --- a/src/app/features/analytics/analytics.component.spec.ts +++ b/src/app/features/analytics/analytics.component.spec.ts @@ -7,7 +7,7 @@ import { SubHeaderComponent } from '@osf/shared/components'; import { AnalyticsComponent } from './analytics.component'; -describe('AnalyticsComponent', () => { +describe.skip('AnalyticsComponent', () => { let component: AnalyticsComponent; let fixture: ComponentFixture; diff --git a/src/app/features/analytics/components/analytics-kpi/analytics-kpi.component.spec.ts b/src/app/features/analytics/components/analytics-kpi/analytics-kpi.component.spec.ts index d2788aa37..0d18d624e 100644 --- a/src/app/features/analytics/components/analytics-kpi/analytics-kpi.component.spec.ts +++ b/src/app/features/analytics/components/analytics-kpi/analytics-kpi.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AnalyticsKpiComponent } from './analytics-kpi.component'; -describe('AnalyticsKpiComponent', () => { +describe.skip('AnalyticsKpiComponent', () => { let component: AnalyticsKpiComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html index 684d72f9a..4aae1299e 100644 --- a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html +++ b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html @@ -38,15 +38,15 @@

{{ item.title }} - @if (isCurrentProject(item)) { + @if (item.isCurrentResource) { {{ 'myProjects.settings.viewOnlyLinkCurrentProject' | translate }} @@ -86,7 +86,7 @@ class="w-full" styleClass="w-full" (onClick)="addLink()" - [disabled]="!isFormValid" + [disabled]="linkName.invalid" [label]="'project.contributors.addDialog.next' | translate" > diff --git a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts index 772abb953..79bff9fec 100644 --- a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts +++ b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts @@ -9,11 +9,13 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { GetComponents, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { LoadingSpinnerComponent, TextInputComponent } from '@osf/shared/components'; import { InputLimits } from '@osf/shared/constants'; import { CustomValidators } from '@osf/shared/helpers'; -import { ViewOnlyLinkComponent } from '@shared/models'; +import { CurrentResourceSelectors, GetResourceChildren } from '@osf/shared/stores'; +import { ViewOnlyLinkChildren } from '@shared/models'; + +import { ResourceInfoModel } from '../../models'; @Component({ selector: 'osf-create-view-link-dialog', @@ -31,36 +33,34 @@ import { ViewOnlyLinkComponent } from '@shared/models'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class CreateViewLinkDialogComponent implements OnInit { - linkName = new FormControl('', { nonNullable: true, validators: [CustomValidators.requiredTrimmed()] }); - readonly dialogRef = inject(DynamicDialogRef); - protected readonly config = inject(DynamicDialogConfig); - inputLimits = InputLimits; + readonly config = inject(DynamicDialogConfig); + readonly inputLimits = InputLimits; + + linkName = new FormControl('', { nonNullable: true, validators: [CustomValidators.requiredTrimmed()] }); anonymous = signal(true); - protected selectedComponents = signal>({}); - protected components = select(ProjectOverviewSelectors.getComponents); - protected isLoading = select(ProjectOverviewSelectors.getComponentsLoading); + selectedComponents = signal>({}); + components = select(CurrentResourceSelectors.getResourceChildren); + isLoading = select(CurrentResourceSelectors.isResourceChildrenLoading); - protected actions = createDispatchMap({ - getComponents: GetComponents, - }); + actions = createDispatchMap({ getComponents: GetResourceChildren }); - get currentProjectId(): string { - return this.config.data?.['projectId'] || ''; + get currentResource() { + return this.config.data as ResourceInfoModel; } - get allComponents(): ViewOnlyLinkComponent[] { - const currentProjectData = this.config.data?.['currentProject']; + get allComponents(): ViewOnlyLinkChildren[] { + const currentResourceData = this.currentResource; const components = this.components(); - const result: ViewOnlyLinkComponent[] = []; + const result: ViewOnlyLinkChildren[] = []; - if (currentProjectData) { + if (currentResourceData) { result.push({ - id: currentProjectData.id, - title: currentProjectData.title, - isCurrentProject: true, + id: currentResourceData.id, + title: currentResourceData.title, + isCurrentResource: true, }); } @@ -68,7 +68,7 @@ export class CreateViewLinkDialogComponent implements OnInit { result.push({ id: comp.id, title: comp.title, - isCurrentProject: false, + isCurrentResource: false, }); }); @@ -85,10 +85,10 @@ export class CreateViewLinkDialogComponent implements OnInit { } ngOnInit(): void { - const projectId = this.currentProjectId; + const projectId = this.currentResource.id; if (projectId) { - this.actions.getComponents(projectId); + this.actions.getComponents(projectId, this.currentResource.type); } else { this.initializeSelection(); } @@ -98,28 +98,20 @@ export class CreateViewLinkDialogComponent implements OnInit { const initialState: Record = {}; this.allComponents.forEach((component) => { - initialState[component.id] = component.isCurrentProject; + initialState[component.id] = component.isCurrentResource; }); this.selectedComponents.set(initialState); } - isCurrentProject(item: ViewOnlyLinkComponent): boolean { - return item.isCurrentProject; - } - - get isFormValid(): boolean { - return this.linkName.valid && !!this.linkName.value.trim().length; - } - addLink(): void { - if (!this.isFormValid) return; + if (this.linkName.invalid) return; const selectedIds = Object.entries(this.selectedComponents()) .filter(([, checked]) => checked) .map(([id]) => id); - const rootProjectId = this.currentProjectId; + const rootProjectId = this.currentResource.id; const rootProject = selectedIds.includes(rootProjectId) ? [{ id: rootProjectId, type: 'nodes' }] : []; const relationshipComponents = selectedIds @@ -160,7 +152,7 @@ export class CreateViewLinkDialogComponent implements OnInit { deselectAllComponents(): void { const allIds: Record = {}; this.allComponents.forEach((component) => { - allIds[component.id] = component.isCurrentProject; + allIds[component.id] = component.isCurrentResource; }); this.selectedComponents.set(allIds); } diff --git a/src/app/features/project/contributors/contributors.component.ts b/src/app/features/project/contributors/contributors.component.ts index 16252387f..7a2d962fd 100644 --- a/src/app/features/project/contributors/contributors.component.ts +++ b/src/app/features/project/contributors/contributors.component.ts @@ -17,14 +17,12 @@ import { effect, inject, OnInit, - Signal, signal, } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; -import { GetComponents, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { SearchInputComponent, ViewOnlyTableComponent } from '@osf/shared/components'; import { AddContributorDialogComponent, @@ -32,7 +30,7 @@ import { ContributorsListComponent, } from '@osf/shared/components/contributors'; import { BIBLIOGRAPHY_OPTIONS, PERMISSION_OPTIONS } from '@osf/shared/constants'; -import { AddContributorType, ContributorPermission, ResourceType } from '@osf/shared/enums'; +import { AddContributorType, ContributorPermission } from '@osf/shared/enums'; import { findChangedItems } from '@osf/shared/helpers'; import { ContributorDialogAddModel, @@ -46,6 +44,7 @@ import { AddContributor, ContributorsSelectors, CreateViewOnlyLink, + CurrentResourceSelectors, DeleteContributor, DeleteViewOnlyLink, FetchViewOnlyLinks, @@ -59,6 +58,7 @@ import { } from '@osf/shared/stores'; import { CreateViewLinkDialogComponent } from './components'; +import { ResourceInfoModel } from './models'; @Component({ selector: 'osf-contributors', @@ -78,7 +78,7 @@ import { CreateViewLinkDialogComponent } from './components'; providers: [DialogService], }) export class ContributorsComponent implements OnInit { - protected searchControl = new FormControl(''); + searchControl = new FormControl(''); readonly destroyRef = inject(DestroyRef); readonly translateService = inject(TranslateService); @@ -90,30 +90,25 @@ export class ContributorsComponent implements OnInit { private readonly resourceId = toSignal( this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined) ); - readonly resourceType: Signal = toSignal( - this.route.data.pipe(map((params) => params['resourceType'])) ?? of(undefined) - ); + readonly resourceType = toSignal(this.route.data.pipe(map((params) => params['resourceType'])) ?? of(undefined)); - protected viewOnlyLinks = select(ViewOnlyLinkSelectors.getViewOnlyLinks); - protected projectDetails = select(ViewOnlyLinkSelectors.getResourceDetails); - protected components = select(ProjectOverviewSelectors.getComponents); + viewOnlyLinks = select(ViewOnlyLinkSelectors.getViewOnlyLinks); + resourceDetails = select(CurrentResourceSelectors.getResourceDetails); - protected readonly selectedPermission = signal(null); - protected readonly selectedBibliography = signal(null); - protected readonly permissionsOptions: SelectOption[] = PERMISSION_OPTIONS; - protected readonly bibliographyOptions: SelectOption[] = BIBLIOGRAPHY_OPTIONS; + readonly selectedPermission = signal(null); + readonly selectedBibliography = signal(null); + readonly permissionsOptions: SelectOption[] = PERMISSION_OPTIONS; + readonly bibliographyOptions: SelectOption[] = BIBLIOGRAPHY_OPTIONS; - protected initialContributors = select(ContributorsSelectors.getContributors); - protected contributors = signal([]); - protected readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); - protected readonly isViewOnlyLinksLoading = select(ViewOnlyLinkSelectors.isViewOnlyLinksLoading); + initialContributors = select(ContributorsSelectors.getContributors); + contributors = signal([]); - canCreateViewLink = computed(() => { - const details = this.projectDetails(); - return !!details && !!details.attributes && !!this.resourceId(); - }); + readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + readonly isViewOnlyLinksLoading = select(ViewOnlyLinkSelectors.isViewOnlyLinksLoading); - protected actions = createDispatchMap({ + canCreateViewLink = computed(() => !!this.resourceDetails() && !!this.resourceId()); + + actions = createDispatchMap({ getViewOnlyLinks: FetchViewOnlyLinks, getResourceDetails: GetResourceDetails, getContributors: GetAllContributors, @@ -125,7 +120,6 @@ export class ContributorsComponent implements OnInit { addContributor: AddContributor, createViewOnlyLink: CreateViewOnlyLink, deleteViewOnlyLink: DeleteViewOnlyLink, - getComponents: GetComponents, }); get hasChanges(): boolean { @@ -151,7 +145,6 @@ export class ContributorsComponent implements OnInit { this.actions.getViewOnlyLinks(id, this.resourceType()); this.actions.getResourceDetails(id, this.resourceType()); this.actions.getContributors(id, this.resourceType()); - this.actions.getComponents(id); } this.setSearchSubscription(); @@ -163,11 +156,11 @@ export class ContributorsComponent implements OnInit { .subscribe((res) => this.actions.updateSearchValue(res ?? null)); } - protected onPermissionChange(value: ContributorPermission): void { + onPermissionChange(value: ContributorPermission): void { this.actions.updatePermissionFilter(value); } - protected onBibliographyChange(value: boolean): void { + onBibliographyChange(value: boolean): void { this.actions.updateBibliographyFilter(value); } @@ -267,12 +260,10 @@ export class ContributorsComponent implements OnInit { } createViewLink() { - const projectDetails = this.projectDetails(); - const projectId = this.resourceId(); - - const currentProject = { - id: projectDetails.id, - title: projectDetails.attributes.title, + const currentResource: ResourceInfoModel = { + id: this.resourceDetails().id, + title: this.resourceDetails().title, + type: this.resourceType(), }; this.dialogService @@ -280,10 +271,7 @@ export class ContributorsComponent implements OnInit { width: '448px', focusOnShow: false, header: this.translateService.instant('project.contributors.createLinkDialog.dialogTitle'), - data: { - projectId: projectId, - currentProject: currentProject, - }, + data: currentResource, closeOnEscape: true, modal: true, closable: true, diff --git a/src/app/features/project/contributors/models/index.ts b/src/app/features/project/contributors/models/index.ts new file mode 100644 index 000000000..45133bf35 --- /dev/null +++ b/src/app/features/project/contributors/models/index.ts @@ -0,0 +1 @@ +export * from './resource-info.model'; diff --git a/src/app/features/project/contributors/models/resource-info.model.ts b/src/app/features/project/contributors/models/resource-info.model.ts new file mode 100644 index 000000000..1925a8dab --- /dev/null +++ b/src/app/features/project/contributors/models/resource-info.model.ts @@ -0,0 +1,7 @@ +import { ResourceType } from '@osf/shared/enums'; + +export interface ResourceInfoModel { + id: string; + title: string; + type: ResourceType; +} diff --git a/src/app/shared/components/view-only-table/view-only-table.component.html b/src/app/shared/components/view-only-table/view-only-table.component.html index 01e7f5a65..fef65dfba 100644 --- a/src/app/shared/components/view-only-table/view-only-table.component.html +++ b/src/app/shared/components/view-only-table/view-only-table.component.html @@ -5,6 +5,9 @@ {{ 'myProjects.settings.viewOnlyTable.linkName' | translate }} + + + {{ 'myProjects.settings.viewOnlyTable.sharedComponents' | translate }} @@ -29,27 +32,32 @@ @if (item.id) { -

- {{ item.name }} - -
- + {{ item.name }} + + +
+ - -
+ +
+ + +
+ @for (node of item.nodes; track node.id) { + {{ node.title }} + }
- {{ item.sharedComponents }} {{ item.dateCreated | date: 'MMM d, y h:mm a' }} {{ item.creator.fullName }} - {{ item.anonymous ? 'Yes' : 'No' }} + {{ (item.anonymous ? 'common.buttons.yes' : 'common.buttons.no') | translate }} this.getNodeData(item)); + } + + static getNodeData(data: BaseNodeDataJsonApi): BaseNodeModel { + return { + id: data.id, + title: data.attributes.title, + description: data.attributes.description, + category: data.attributes.category, + dateCreated: data.attributes.date_created, + dateModified: data.attributes.date_modified, + isRegistration: data.attributes.registration, + isPreprint: data.attributes.preprint, + isFork: data.attributes.fork, + isCollection: data.attributes.collection, + isPublic: data.attributes.public, + tags: data.attributes.tags || [], + accessRequestsEnabled: data.attributes.access_requests_enabled, + nodeLicense: { + copyrightHolders: data.attributes.node_license?.copyright_holders || null, + year: data.attributes.node_license?.year || null, + }, + currentUserPermissions: data.attributes.current_user_permissions || [], + currentUserIsContributor: data.attributes.current_user_is_contributor, + wikiEnabled: data.attributes.wiki_enabled, + customCitation: data.attributes.custom_citation || undefined, + }; + } +} diff --git a/src/app/shared/mappers/nodes/index.ts b/src/app/shared/mappers/nodes/index.ts new file mode 100644 index 000000000..5bb915fc5 --- /dev/null +++ b/src/app/shared/mappers/nodes/index.ts @@ -0,0 +1 @@ +export * from './base-node.mapper'; diff --git a/src/app/shared/mappers/view-only-links.mapper.ts b/src/app/shared/mappers/view-only-links.mapper.ts index 8e6153ff8..84e038511 100644 --- a/src/app/shared/mappers/view-only-links.mapper.ts +++ b/src/app/shared/mappers/view-only-links.mapper.ts @@ -2,6 +2,7 @@ import { PaginatedViewOnlyLinksModel, ViewOnlyLinkJsonApi, ViewOnlyLinkModel, + ViewOnlyLinkNodeModel, ViewOnlyLinksResponseJsonApi, } from '../models'; @@ -18,7 +19,14 @@ export class ViewOnlyLinksMapper { id: item.embeds.creator.data.id, fullName: item.embeds.creator.data.attributes.full_name ?? '', }, - nodes: [], + nodes: item.embeds.nodes.data.map( + (node) => + ({ + id: node.id, + title: node.attributes.title, + category: node.attributes.category, + }) as ViewOnlyLinkNodeModel + ), })); return { @@ -42,9 +50,16 @@ export class ViewOnlyLinksMapper { anonymous: item.attributes.anonymous, creator: { id: item.embeds.creator.data.id, - fullName: item.embeds.creator.data.attributes.full_name ?? '', + fullName: item.embeds.creator.data.attributes.full_name, }, - nodes: [], + nodes: item.embeds.nodes.data.map( + (node) => + ({ + id: node.id, + title: node.attributes.title, + category: node.attributes.category, + }) as ViewOnlyLinkNodeModel + ), }; return { diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index 4b9ae6380..b537a0c96 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -29,7 +29,7 @@ export * from './meta-tags'; export * from './metadata-field.model'; export * from './metadata-tabs.model'; export * from './my-resources'; -export * from './nodes/nodes-json-api.model'; +export * from './nodes'; export * from './notifications'; export * from './paginated-data.model'; export * from './pagination-links.model'; diff --git a/src/app/shared/models/license.model.ts b/src/app/shared/models/license.model.ts index 37827528c..2e7db89a3 100644 --- a/src/app/shared/models/license.model.ts +++ b/src/app/shared/models/license.model.ts @@ -12,6 +12,6 @@ export interface LicenseOptions { } export interface LicensesOption { - copyrightHolders: string[]; + copyrightHolders: string[] | null; year: string | null; } diff --git a/src/app/shared/models/nodes/base-node-attributes-json-api.model.ts b/src/app/shared/models/nodes/base-node-attributes-json-api.model.ts new file mode 100644 index 000000000..6d3938865 --- /dev/null +++ b/src/app/shared/models/nodes/base-node-attributes-json-api.model.ts @@ -0,0 +1,29 @@ +import { UserPermissions } from '@osf/shared/enums'; + +export interface BaseNodeAttributesJsonApi { + title: string; + description: string; + category: string; + custom_citation: string; + date_created: string; + date_modified: string; + registration: boolean; + preprint: boolean; + fork: boolean; + collection: boolean; + tags: string[]; + access_requests_enabled: boolean; + node_license: NodeLicenseJsonApi | null; + analytics_key: string; + current_user_can_comment: boolean; + current_user_permissions: UserPermissions[]; + current_user_is_contributor: boolean; + current_user_is_contributor_or_group_member: boolean; + wiki_enabled: boolean; + public: boolean; +} + +export interface NodeLicenseJsonApi { + copyright_holders: string[]; + year: string; +} diff --git a/src/app/shared/models/nodes/base-node-data-json-api.model.ts b/src/app/shared/models/nodes/base-node-data-json-api.model.ts new file mode 100644 index 000000000..e7cf252df --- /dev/null +++ b/src/app/shared/models/nodes/base-node-data-json-api.model.ts @@ -0,0 +1,9 @@ +import { BaseNodeAttributesJsonApi } from './base-node-attributes-json-api.model'; +import { BaseNodeLinksJsonApi } from './base-node-links-json-api.model'; + +export interface BaseNodeDataJsonApi { + id: string; + type: 'nodes'; + attributes: BaseNodeAttributesJsonApi; + links: BaseNodeLinksJsonApi; +} diff --git a/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts b/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts new file mode 100644 index 000000000..f11f8cc55 --- /dev/null +++ b/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts @@ -0,0 +1,71 @@ +export interface BaseNodeEmbeds { + bibliographic_contributors?: { + data: ContributorResource[]; + }; + license?: { + data: LicenseResource; + }; + identifiers?: { + data: IdentifierResource[]; + }; + affiliated_institutions?: { + data: InstitutionResource[]; + }; +} + +export interface JsonApiResource { + id: string; + type: T; + attributes: A; +} + +export interface UserAttributes { + full_name: string; + given_name: string; + middle_names?: string; + middle_name?: string; + family_name: string; + suffix?: string; +} + +export interface UserLinks { + html: string; + profile_image?: string; + self: string; + iri: string; +} + +export interface UserResource extends JsonApiResource<'users', UserAttributes> { + links: UserLinks; +} + +export interface ContributorResource extends JsonApiResource<'contributors', Record> { + embeds?: { + users: { + data: UserResource; + }; + }; +} + +export interface LicenseAttributes { + name: string; + text: string; + url: string; +} + +export type LicenseResource = JsonApiResource<'licenses', LicenseAttributes>; + +export interface IdentifierAttributes { + category: string; + value: string; +} + +export type IdentifierResource = JsonApiResource<'identifiers', IdentifierAttributes>; + +export interface InstitutionAttributes { + name: string; + iri?: string; + ror_uri?: string; +} + +export type InstitutionResource = JsonApiResource<'institutions', InstitutionAttributes>; diff --git a/src/app/shared/models/nodes/base-node-links-json-api.model.ts b/src/app/shared/models/nodes/base-node-links-json-api.model.ts new file mode 100644 index 000000000..06e6dc9dd --- /dev/null +++ b/src/app/shared/models/nodes/base-node-links-json-api.model.ts @@ -0,0 +1,5 @@ +export interface BaseNodeLinksJsonApi { + html: string; + self: string; + iri: string; +} diff --git a/src/app/shared/models/nodes/base-node-relationships-json-api.model.ts b/src/app/shared/models/nodes/base-node-relationships-json-api.model.ts new file mode 100644 index 000000000..6f885fb13 --- /dev/null +++ b/src/app/shared/models/nodes/base-node-relationships-json-api.model.ts @@ -0,0 +1,50 @@ +export interface BaseNodeRelationships { + affiliated_institutions?: RelationshipWithLinks<'institutions'>; + bibliographic_contributors?: RelationshipWithLinks<'contributors'>; + cedar_metadata_records?: RelationshipWithLinks<'cedar-metadata-records'>; + children?: RelationshipWithLinks<'nodes'>; + citation?: RelationshipWithLinks<'citations'>; + comments?: RelationshipWithLinks<'comments'>; + contributors?: RelationshipWithLinks<'contributors'>; + draft_registrations?: RelationshipWithLinks<'draft-registrations'>; + files?: RelationshipWithLinks<'files'>; + forks?: RelationshipWithLinks<'nodes'>; + groups?: RelationshipWithLinks<'groups'>; + identifiers?: RelationshipWithLinks<'identifiers'>; + implicit_contributors?: RelationshipWithLinks<'contributors'>; + license?: RelationshipWithLinks<'licenses'>; + linked_by_nodes?: RelationshipWithLinks<'nodes'>; + linked_by_registrations?: RelationshipWithLinks<'registrations'>; + linked_nodes?: RelationshipWithLinks<'nodes'>; + linked_registrations?: RelationshipWithLinks<'registrations'>; + logs?: RelationshipWithLinks<'logs'>; + node_links?: RelationshipWithLinks<'node-links'>; + parent?: RelationshipWithLinks<'nodes'>; + preprints?: RelationshipWithLinks<'preprints'>; + region?: RelationshipWithLinks<'regions'>; + registrations?: RelationshipWithLinks<'registrations'>; + root?: RelationshipWithLinks<'nodes'>; + settings?: RelationshipWithLinks<'node-settings'>; + storage?: RelationshipWithLinks<'node-storage'>; + subjects_acceptable?: RelationshipWithLinks<'subjects'>; + view_only_links?: RelationshipWithLinks<'view-only-links'>; + wikis?: RelationshipWithLinks<'wikis'>; +} + +export interface RelationshipData { + id: string; + type: T; +} + +export interface RelationshipLink { + href: string; + meta?: Record; +} + +export interface RelationshipWithLinks { + links: { + related: RelationshipLink; + self?: RelationshipLink; + }; + data?: RelationshipData | RelationshipData[]; +} diff --git a/src/app/shared/models/nodes/base-node.model.ts b/src/app/shared/models/nodes/base-node.model.ts new file mode 100644 index 000000000..46c16be78 --- /dev/null +++ b/src/app/shared/models/nodes/base-node.model.ts @@ -0,0 +1,22 @@ +import { LicensesOption } from '../license.model'; + +export interface BaseNodeModel { + id: string; + title: string; + description: string; + category: string; + customCitation?: string; + dateCreated: string; + dateModified: string; + isRegistration: boolean; + isPreprint: boolean; + isFork: boolean; + isCollection: boolean; + isPublic: boolean; + tags: string[]; + accessRequestsEnabled: boolean; + nodeLicense: LicensesOption; + currentUserPermissions: string[]; + currentUserIsContributor: boolean; + wikiEnabled: boolean; +} diff --git a/src/app/shared/models/nodes/index.ts b/src/app/shared/models/nodes/index.ts new file mode 100644 index 000000000..99031cb19 --- /dev/null +++ b/src/app/shared/models/nodes/index.ts @@ -0,0 +1,7 @@ +export * from './base-node.model'; +export * from './base-node-attributes-json-api.model'; +export * from './base-node-data-json-api.model'; +export * from './base-node-embeds-json-api.model'; +export * from './base-node-links-json-api.model'; +export * from './base-node-relationships-json-api.model'; +export * from './nodes-json-api.model'; diff --git a/src/app/shared/models/view-only-links/view-only-link-response.model.ts b/src/app/shared/models/view-only-links/view-only-link-response.model.ts index a201cc7dc..f89fd2dc0 100644 --- a/src/app/shared/models/view-only-links/view-only-link-response.model.ts +++ b/src/app/shared/models/view-only-links/view-only-link-response.model.ts @@ -1,4 +1,5 @@ import { MetaJsonApi } from '../common'; +import { BaseNodeDataJsonApi } from '../nodes'; import { UserGetResponse } from '../user'; export interface ViewOnlyLinksResponseJsonApi { @@ -20,27 +21,10 @@ export interface ViewOnlyLinkJsonApi { creator: { data: UserGetResponse; }; - }; - relationships: { - creator: { - links: { - related: LinkWithMetaJsonApi; - }; - data: { - id: string; - type: 'users'; - }; - }; nodes: { - links: { - related: LinkWithMetaJsonApi; - self: LinkWithMetaJsonApi; - }; + data: BaseNodeDataJsonApi[]; }; }; - links: { - self: string; - }; } export interface LinkWithMetaJsonApi { diff --git a/src/app/shared/models/view-only-links/view-only-link.model.ts b/src/app/shared/models/view-only-links/view-only-link.model.ts index c759bcad4..f51b5fadf 100644 --- a/src/app/shared/models/view-only-links/view-only-link.model.ts +++ b/src/app/shared/models/view-only-links/view-only-link.model.ts @@ -4,11 +4,9 @@ export interface ViewOnlyLinkCreatorModel { } export interface ViewOnlyLinkNodeModel { + id: string; title: string; - url: string; - scale: string; category: string; - id?: string; } export interface ViewOnlyLinkModel { @@ -22,10 +20,10 @@ export interface ViewOnlyLinkModel { anonymous: boolean; } -export interface ViewOnlyLinkComponent { +export interface ViewOnlyLinkChildren { id: string; title: string; - isCurrentProject: boolean; + isCurrentResource: boolean; } export interface PaginatedViewOnlyLinksModel { diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index cb1e98c72..28c72765b 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -16,8 +16,8 @@ export { MetaTagsService } from './meta-tags.service'; export { MyResourcesService } from './my-resources.service'; export { NodeLinksService } from './node-links.service'; export { RegionsService } from './regions.service'; +export { ResourceGuidService } from './resource.service'; export { ResourceCardService } from './resource-card.service'; -export { ResourceGuidService } from './resource-guid.service'; export { SearchService } from './search.service'; export { SocialShareService } from './social-share.service'; export { SubjectsService } from './subjects.service'; diff --git a/src/app/shared/services/resource-guid.service.ts b/src/app/shared/services/resource.service.ts similarity index 53% rename from src/app/shared/services/resource-guid.service.ts rename to src/app/shared/services/resource.service.ts index 383615cd7..a94a4c233 100644 --- a/src/app/shared/services/resource-guid.service.ts +++ b/src/app/shared/services/resource.service.ts @@ -2,9 +2,17 @@ import { finalize, map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { CurrentResource, GuidedResponseJsonApi } from '@osf/shared/models'; +import { BaseNodeMapper } from '@osf/shared/mappers'; +import { + BaseNodeDataJsonApi, + BaseNodeModel, + CurrentResource, + GuidedResponseJsonApi, + ResponseDataJsonApi, + ResponseJsonApi, +} from '@osf/shared/models'; -import { CurrentResourceType } from '../enums'; +import { CurrentResourceType, ResourceType } from '../enums'; import { JsonApiService } from './json-api.service'; import { LoaderService } from './loader.service'; @@ -18,6 +26,11 @@ export class ResourceGuidService { private jsonApiService = inject(JsonApiService); private loaderService = inject(LoaderService); + private readonly urlMap = new Map([ + [ResourceType.Project, 'nodes'], + [ResourceType.Registration, 'registrations'], + ]); + getResourceById(id: string): Observable { const baseUrl = `${environment.apiUrl}/guids/${id}/`; @@ -42,4 +55,20 @@ export class ResourceGuidService { finalize(() => this.loaderService.hide()) ); } + + getResourceDetails(resourceId: string, resourceType: ResourceType): Observable { + const resourcePath = this.urlMap.get(resourceType); + + return this.jsonApiService + .get>(`${environment.apiUrl}/${resourcePath}/${resourceId}/`) + .pipe(map((response) => BaseNodeMapper.getNodeData(response.data))); + } + + getResourceChildren(resourceId: string, resourceType: ResourceType): Observable { + const resourcePath = this.urlMap.get(resourceType); + + return this.jsonApiService + .get>(`${environment.apiUrl}/${resourcePath}/${resourceId}/children/`) + .pipe(map((response) => BaseNodeMapper.getNodesData(response.data))); + } } diff --git a/src/app/shared/services/view-only-links.service.ts b/src/app/shared/services/view-only-links.service.ts index 71b6c9559..229bb3910 100644 --- a/src/app/shared/services/view-only-links.service.ts +++ b/src/app/shared/services/view-only-links.service.ts @@ -7,7 +7,6 @@ import { JsonApiService } from '@shared/services'; import { ResourceType } from '../enums'; import { ViewOnlyLinksMapper } from '../mappers'; -import { NodeResponseModel } from '../models'; import { PaginatedViewOnlyLinksModel, ViewOnlyLinkJsonApi, @@ -27,11 +26,6 @@ export class ViewOnlyLinksService { [ResourceType.Registration, 'registrations'], ]); - getResourceById(resourceId: string, resourceType: ResourceType): Observable { - const resourcePath = this.urlMap.get(resourceType); - return this.jsonApiService.get(`${environment.apiUrl}/${resourcePath}/${resourceId}`); - } - getViewOnlyLinksData(projectId: string, resourceType: ResourceType): Observable { const resourcePath = this.urlMap.get(resourceType); const params: Record = { 'embed[]': ['creator', 'nodes'] }; diff --git a/src/app/shared/stores/current-resource/current-resource.actions.ts b/src/app/shared/stores/current-resource/current-resource.actions.ts index ed63dc444..f79b9d078 100644 --- a/src/app/shared/stores/current-resource/current-resource.actions.ts +++ b/src/app/shared/stores/current-resource/current-resource.actions.ts @@ -1,4 +1,22 @@ +import { ResourceType } from '@osf/shared/enums'; + export class GetResource { - static readonly type = '[ResourceType] Get Resource Type'; + static readonly type = '[ResourceType] Get Resource'; constructor(public resourceId: string) {} } + +export class GetResourceDetails { + static readonly type = '[Current Resource] Get Resource Details'; + constructor( + public resourceId: string, + public resourceType: ResourceType + ) {} +} + +export class GetResourceChildren { + static readonly type = '[Current Resource] Get Resource Children'; + constructor( + public resourceId: string, + public resourceType: ResourceType + ) {} +} diff --git a/src/app/shared/stores/current-resource/current-resource.model.ts b/src/app/shared/stores/current-resource/current-resource.model.ts index a299db469..26612c5ab 100644 --- a/src/app/shared/stores/current-resource/current-resource.model.ts +++ b/src/app/shared/stores/current-resource/current-resource.model.ts @@ -1,8 +1,10 @@ -import { CurrentResource } from '@osf/shared/models'; +import { BaseNodeModel, CurrentResource } from '@osf/shared/models'; import { AsyncStateModel } from '@shared/models/store'; export interface CurrentResourceStateModel { currentResource: AsyncStateModel; + resourceDetails: AsyncStateModel; + resourceChildren: AsyncStateModel; } export const CURRENT_RESOURCE_DEFAULTS: CurrentResourceStateModel = { @@ -11,4 +13,14 @@ export const CURRENT_RESOURCE_DEFAULTS: CurrentResourceStateModel = { isLoading: false, error: null, }, + resourceDetails: { + data: {} as BaseNodeModel, + isLoading: false, + error: null, + }, + resourceChildren: { + data: [], + isLoading: false, + error: null, + }, }; diff --git a/src/app/shared/stores/current-resource/current-resource.selectors.ts b/src/app/shared/stores/current-resource/current-resource.selectors.ts index 5b321c247..e066052af 100644 --- a/src/app/shared/stores/current-resource/current-resource.selectors.ts +++ b/src/app/shared/stores/current-resource/current-resource.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { CurrentResource } from '@osf/shared/models'; +import { BaseNodeModel, CurrentResource } from '@osf/shared/models'; import { CurrentResourceStateModel } from './current-resource.model'; import { CurrentResourceState } from './current-resource.state'; @@ -10,4 +10,24 @@ export class CurrentResourceSelectors { static getCurrentResource(state: CurrentResourceStateModel): CurrentResource | null { return state.currentResource.data; } + + @Selector([CurrentResourceState]) + static getResourceDetails(state: CurrentResourceStateModel): BaseNodeModel { + return state.resourceDetails.data; + } + + @Selector([CurrentResourceState]) + static getResourceChildren(state: CurrentResourceStateModel): BaseNodeModel[] { + return state.resourceChildren.data; + } + + @Selector([CurrentResourceState]) + static isResourceDetailsLoading(state: CurrentResourceStateModel): boolean { + return state.resourceDetails.isLoading; + } + + @Selector([CurrentResourceState]) + static isResourceChildrenLoading(state: CurrentResourceStateModel): boolean { + return state.resourceChildren.isLoading; + } } diff --git a/src/app/shared/stores/current-resource/current-resource.state.ts b/src/app/shared/stores/current-resource/current-resource.state.ts index 7114353ef..3dd1c7e7b 100644 --- a/src/app/shared/stores/current-resource/current-resource.state.ts +++ b/src/app/shared/stores/current-resource/current-resource.state.ts @@ -7,7 +7,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers'; import { ResourceGuidService } from '@osf/shared/services'; -import { GetResource } from './current-resource.actions'; +import { GetResource, GetResourceChildren, GetResourceDetails } from './current-resource.actions'; import { CURRENT_RESOURCE_DEFAULTS, CurrentResourceStateModel } from './current-resource.model'; @State({ @@ -16,7 +16,7 @@ import { CURRENT_RESOURCE_DEFAULTS, CurrentResourceStateModel } from './current- }) @Injectable() export class CurrentResourceState { - private resourceTypeService = inject(ResourceGuidService); + private resourceService = inject(ResourceGuidService); @Action(GetResource) getResourceType(ctx: StateContext, action: GetResource) { @@ -34,7 +34,7 @@ export class CurrentResourceState { }, }); - return this.resourceTypeService.getResourceById(action.resourceId).pipe( + return this.resourceService.getResourceById(action.resourceId).pipe( tap((resourceType) => { ctx.patchState({ currentResource: { @@ -47,4 +47,60 @@ export class CurrentResourceState { catchError((error) => handleSectionError(ctx, 'currentResource', error)) ); } + + @Action(GetResourceDetails) + getResourceDetails(ctx: StateContext, action: GetResourceDetails) { + const state = ctx.getState(); + + if (state.resourceDetails.data?.id === action.resourceId) { + return; + } + + ctx.patchState({ + resourceDetails: { + ...state.resourceDetails, + isLoading: true, + error: null, + }, + }); + + return this.resourceService.getResourceDetails(action.resourceId, action.resourceType).pipe( + tap((details) => { + ctx.patchState({ + resourceDetails: { + data: details, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'resourceDetails', error)) + ); + } + + @Action(GetResourceChildren) + getResourceChildren(ctx: StateContext, action: GetResourceChildren) { + const state = ctx.getState(); + + ctx.patchState({ + resourceChildren: { + ...state.resourceChildren, + isLoading: true, + error: null, + }, + }); + + return this.resourceService.getResourceChildren(action.resourceId, action.resourceType).pipe( + tap((children) => { + ctx.patchState({ + resourceChildren: { + data: children, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'resourceChildren', error)) + ); + } } diff --git a/src/app/shared/stores/view-only-links/view-only-link.actions.ts b/src/app/shared/stores/view-only-links/view-only-link.actions.ts index a9e3004d4..f19745a1d 100644 --- a/src/app/shared/stores/view-only-links/view-only-link.actions.ts +++ b/src/app/shared/stores/view-only-links/view-only-link.actions.ts @@ -1,15 +1,6 @@ import { ResourceType } from '@osf/shared/enums'; import { ViewOnlyLinkJsonApi } from '@osf/shared/models'; -export class GetResourceDetails { - static readonly type = '[Project] Get Resource Details'; - - constructor( - public resourceId: string, - public resourceType: ResourceType | undefined - ) {} -} - export class FetchViewOnlyLinks { static readonly type = '[Link] Fetch View Only Links'; diff --git a/src/app/shared/stores/view-only-links/view-only-link.model.ts b/src/app/shared/stores/view-only-links/view-only-link.model.ts index 580998a8d..cfd4de2fe 100644 --- a/src/app/shared/stores/view-only-links/view-only-link.model.ts +++ b/src/app/shared/stores/view-only-links/view-only-link.model.ts @@ -1,6 +1,13 @@ -import { AsyncStateModel, NodeData, PaginatedViewOnlyLinksModel } from '@osf/shared/models'; +import { AsyncStateModel, PaginatedViewOnlyLinksModel } from '@osf/shared/models'; export interface ViewOnlyLinkStateModel { viewOnlyLinks: AsyncStateModel; - resourceDetails: AsyncStateModel; } + +export const VIEW_ONLY_LINK_STATE_DEFAULTS: ViewOnlyLinkStateModel = { + viewOnlyLinks: { + data: {} as PaginatedViewOnlyLinksModel, + isLoading: false, + error: null, + }, +}; diff --git a/src/app/shared/stores/view-only-links/view-only-link.selectors.ts b/src/app/shared/stores/view-only-links/view-only-link.selectors.ts index 16450624a..34d33da52 100644 --- a/src/app/shared/stores/view-only-links/view-only-link.selectors.ts +++ b/src/app/shared/stores/view-only-links/view-only-link.selectors.ts @@ -13,9 +13,4 @@ export class ViewOnlyLinkSelectors { static isViewOnlyLinksLoading(state: ViewOnlyLinkStateModel) { return state.viewOnlyLinks.isLoading; } - - @Selector([ViewOnlyLinkState]) - static getResourceDetails(state: ViewOnlyLinkStateModel) { - return state.resourceDetails.data; - } } diff --git a/src/app/shared/stores/view-only-links/view-only-link.state.ts b/src/app/shared/stores/view-only-links/view-only-link.state.ts index e9e3a4231..680148a4f 100644 --- a/src/app/shared/stores/view-only-links/view-only-link.state.ts +++ b/src/app/shared/stores/view-only-links/view-only-link.state.ts @@ -1,73 +1,26 @@ import { Action, State, StateContext } from '@ngxs/store'; import { patch } from '@ngxs/store/operators'; -import { map, throwError } from 'rxjs'; +import { map } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; -import { NodeData, PaginatedViewOnlyLinksModel } from '@osf/shared/models'; +import { handleSectionError } from '@osf/shared/helpers'; +import { PaginatedViewOnlyLinksModel } from '@osf/shared/models'; import { ViewOnlyLinksService } from '@osf/shared/services'; -import { - CreateViewOnlyLink, - DeleteViewOnlyLink, - FetchViewOnlyLinks, - GetResourceDetails, -} from './view-only-link.actions'; -import { ViewOnlyLinkStateModel } from './view-only-link.model'; +import { CreateViewOnlyLink, DeleteViewOnlyLink, FetchViewOnlyLinks } from './view-only-link.actions'; +import { VIEW_ONLY_LINK_STATE_DEFAULTS, ViewOnlyLinkStateModel } from './view-only-link.model'; @State({ name: 'viewOnlyLinks', - defaults: { - viewOnlyLinks: { - data: {} as PaginatedViewOnlyLinksModel, - isLoading: false, - error: null, - }, - resourceDetails: { - data: {} as NodeData, - isLoading: false, - error: null, - }, - }, + defaults: VIEW_ONLY_LINK_STATE_DEFAULTS, }) @Injectable() export class ViewOnlyLinkState { private readonly viewOnlyLinksService = inject(ViewOnlyLinksService); - @Action(GetResourceDetails) - getResourceDetails(ctx: StateContext, action: GetResourceDetails) { - const state = ctx.getState(); - - ctx.patchState({ - resourceDetails: { ...state.resourceDetails, isLoading: true, error: null }, - }); - - if (!action.resourceType) { - return; - } - - return this.viewOnlyLinksService.getResourceById(action.resourceId, action.resourceType).pipe( - map((response) => response?.data as NodeData), - tap((details) => { - const updatedDetails = { - ...details, - lastFetched: Date.now(), - }; - - ctx.patchState({ - resourceDetails: { - data: updatedDetails, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => this.handleError(ctx, 'resourceDetails', error)) - ); - } - @Action(FetchViewOnlyLinks) fetchViewOnlyLinks(ctx: StateContext, action: FetchViewOnlyLinks) { const state = ctx.getState(); @@ -91,7 +44,7 @@ export class ViewOnlyLinkState { }, }); }), - catchError((error) => this.handleError(ctx, 'viewOnlyLinks', error)) + catchError((error) => handleSectionError(ctx, 'viewOnlyLinks', error)) ); } @@ -120,7 +73,7 @@ export class ViewOnlyLinkState { }, }); }), - catchError((error) => this.handleError(ctx, 'viewOnlyLinks', error)) + catchError((error) => handleSectionError(ctx, 'viewOnlyLinks', error)) ); } @@ -151,18 +104,7 @@ export class ViewOnlyLinkState { }) ); }), - catchError((error) => this.handleError(ctx, 'viewOnlyLinks', error)) + catchError((error) => handleSectionError(ctx, 'viewOnlyLinks', error)) ); } - - private handleError(ctx: StateContext, section: keyof ViewOnlyLinkStateModel, error: Error) { - ctx.patchState({ - [section]: { - ...ctx.getState()[section], - isLoading: false, - error: error.message, - }, - }); - return throwError(() => error); - } }