diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts index c256076a4..0a43ed039 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts @@ -33,14 +33,16 @@ export class AddToCollectionConfirmationDialogComponent { }); protected handleAddToCollectionConfirm(): void { - const project = this.config.data; - if (!project) return; + const payload = this.config.data.payload; + const project = this.config.data.project; + + if (!payload || !project) return; this.isSubmitting.set(true); const updatePublicStatus$ = project.isPublic ? of(null) : this.actions.updateProjectPublicStatus(project.id, true); - const createSubmission$ = this.actions.createCollectionSubmission(project); + const createSubmission$ = this.actions.createCollectionSubmission(payload); forkJoin({ publicStatusUpdate: updatePublicStatus$, diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts index d771da099..b7820770a 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -73,7 +73,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { protected selectedProject = select(ProjectsSelectors.getSelectedProject); protected currentUser = select(UserSelectors.getCurrentUser); protected providerId = signal(''); - protected isSubmitted = signal(false); + protected allowNavigation = signal(false); protected projectMetadataSaved = signal(false); protected projectContributorsSaved = signal(false); protected collectionMetadataSaved = signal(false); @@ -99,6 +99,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { handleProjectSelected(): void { this.projectContributorsSaved.set(false); this.projectMetadataSaved.set(false); + this.allowNavigation.set(false); } handleChangeStep(step: number): void { @@ -127,7 +128,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { collectionMetadata: this.collectionMetadataForm.value || {}, userId: this.currentUser()?.id || '', }; - this.isSubmitted.set(true); const dialogRef = this.dialogService.open(AddToCollectionConfirmationDialogComponent, { width: '500px', @@ -136,12 +136,12 @@ export class AddToCollectionComponent implements CanDeactivateComponent { closeOnEscape: true, modal: true, closable: true, - data: payload, + data: { payload, project: this.selectedProject() }, }); dialogRef.onClose.subscribe((result) => { if (result) { - this.isSubmitted.set(false); + this.allowNavigation.set(true); this.router.navigate(['/my-projects', this.selectedProject()?.id, 'overview']); } }); @@ -162,6 +162,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { effect(() => { this.destroyRef.onDestroy(() => { this.actions.clearAddToCollectionState(); + this.allowNavigation.set(false); }); }); } @@ -173,6 +174,19 @@ export class AddToCollectionComponent implements CanDeactivateComponent { } canDeactivate(): Observable | boolean { - return this.isSubmitted(); + if (this.allowNavigation()) { + return true; + } + + return !this.hasUnsavedChanges(); + } + + private hasUnsavedChanges(): boolean { + return ( + !!this.selectedProject() || + this.projectMetadataSaved() || + this.projectContributorsSaved() || + this.collectionMetadataSaved() + ); } } diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html index 13337d3a1..5e06ee680 100644 --- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html @@ -16,13 +16,13 @@

{{ 'collections.addToCollection.projectMetadata' | translate }}

{{ 'collections.addToCollection.form.description' | translate }}

- {{ selectedProject()?.description ?? 'collections.addToCollection.noDescription' | translate }} + {{ selectedProject()?.description || 'collections.addToCollection.noDescription' | translate }}

{{ 'collections.addToCollection.form.license' | translate }}

- {{ projectLicense()?.name ?? 'collections.addToCollection.noLicense' | translate }} + {{ projectLicense()?.name || 'collections.addToCollection.noLicense' | translate }}

diff --git a/src/app/features/project/overview/mappers/project-overview.mapper.ts b/src/app/features/project/overview/mappers/project-overview.mapper.ts index 79cbe3bcd..0e42347b5 100644 --- a/src/app/features/project/overview/mappers/project-overview.mapper.ts +++ b/src/app/features/project/overview/mappers/project-overview.mapper.ts @@ -34,6 +34,7 @@ export class ProjectOverviewMapper { currentUserIsContributor: response.attributes.current_user_is_contributor, currentUserIsContributorOrGroupMember: response.attributes.current_user_is_contributor_or_group_member, wikiEnabled: response.attributes.wiki_enabled, + customCitation: response.attributes.custom_citation, subjects: response.attributes.subjects.map((subjectArray) => subjectArray[0]), contributors: response.embeds.bibliographic_contributors.data.map((contributor) => ({ id: contributor.embeds.users.data.id, diff --git a/src/app/features/project/overview/models/project-overview.models.ts b/src/app/features/project/overview/models/project-overview.models.ts index 0fd3bb43b..ff776be0f 100644 --- a/src/app/features/project/overview/models/project-overview.models.ts +++ b/src/app/features/project/overview/models/project-overview.models.ts @@ -48,6 +48,7 @@ export interface ProjectOverview { wikiEnabled: boolean; subjects: ProjectOverviewSubject[]; contributors: ProjectOverviewContributor[]; + customCitation: string | null; region?: { id: string; type: string; @@ -95,6 +96,7 @@ export interface ProjectOverviewGetResponseJsoApi { current_user_is_contributor_or_group_member: boolean; wiki_enabled: boolean; subjects: ProjectOverviewSubject[][]; + custom_citation: string | null; }; embeds: { affiliated_institutions: { diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index 88b061b15..2dc1de059 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -18,7 +18,10 @@
- +
} @else { diff --git a/src/app/features/project/overview/project-overview.component.scss b/src/app/features/project/overview/project-overview.component.scss index fd663b1b5..73572f32d 100644 --- a/src/app/features/project/overview/project-overview.component.scss +++ b/src/app/features/project/overview/project-overview.component.scss @@ -5,7 +5,7 @@ } .right-section { - flex: 1; + width: 23rem; border: 1px solid var.$grey-2; border-radius: 12px; } diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index d869bb6c2..b52f9d4aa 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -24,7 +24,13 @@ import { OverviewWikiComponent, RecentActivityComponent, } from './components'; -import { ClearProjectOverview, GetComponents, GetProjectById, ProjectOverviewSelectors } from './store'; +import { + ClearProjectOverview, + GetComponents, + GetProjectById, + ProjectOverviewSelectors, + SetProjectCustomCitation, +} from './store'; @Component({ selector: 'osf-project-overview', @@ -60,6 +66,7 @@ export class ProjectOverviewComponent implements OnInit { getComponents: GetComponents, getLinkedProjects: GetLinkedResources, getNodeLinks: GetAllNodeLinks, + setProjectCustomCitation: SetProjectCustomCitation, clearProjectOverview: ClearProjectOverview, clearWiki: ClearWiki, clearCollections: ClearCollections, @@ -92,6 +99,10 @@ export class ProjectOverviewComponent implements OnInit { this.setupCleanup(); } + onCustomCitationUpdated(citation: string): void { + this.actions.setProjectCustomCitation(citation); + } + ngOnInit(): void { const projectId = this.route.parent?.snapshot.params['id']; if (projectId) { diff --git a/src/app/features/project/overview/store/project-overview.actions.ts b/src/app/features/project/overview/store/project-overview.actions.ts index 0e7b27370..36225ee4b 100644 --- a/src/app/features/project/overview/store/project-overview.actions.ts +++ b/src/app/features/project/overview/store/project-overview.actions.ts @@ -15,6 +15,12 @@ export class UpdateProjectPublicStatus { ) {} } +export class SetProjectCustomCitation { + static readonly type = '[Project Overview] Set Project Custom Citation'; + + constructor(public citation: string) {} +} + export class ForkResource { static readonly type = '[Project Overview] Fork Resource'; diff --git a/src/app/features/project/overview/store/project-overview.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index 796c0c1a8..0c4dd8073 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -16,6 +16,7 @@ import { ForkResource, GetComponents, GetProjectById, + SetProjectCustomCitation, UpdateProjectPublicStatus, } from './project-overview.actions'; import { ProjectOverviewStateModel } from './project-overview.model'; @@ -82,9 +83,10 @@ export class ProjectOverviewState { isSubmitting: true, }, }); - - return this.projectOverviewService.updateProjectPublicStatus(action.projectId, action.isPublic).pipe( - tap(() => { + } + return this.projectOverviewService.updateProjectPublicStatus(action.projectId, action.isPublic).pipe( + tap(() => { + if (state.project.data) { ctx.patchState({ project: { ...state.project, @@ -95,11 +97,24 @@ export class ProjectOverviewState { isSubmitting: false, }, }); - }), - catchError((error) => this.handleError(ctx, 'project', error)) - ); - } - return; + } + }), + catchError((error) => this.handleError(ctx, 'project', error)) + ); + } + + @Action(SetProjectCustomCitation) + setProjectCustomCitation(ctx: StateContext, action: SetProjectCustomCitation) { + const state = ctx.getState(); + ctx.patchState({ + project: { + ...state.project, + data: { + ...state.project.data!, + customCitation: action.citation, + }, + }, + }); } @Action(ForkResource) diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index fa7f1fd30..a8b7e76be 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -3,7 +3,13 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { ResourceType } from '@osf/shared/enums'; -import { ContributorsState, NodeLinksState, SubjectsState, ViewOnlyLinkState } from '@osf/shared/stores'; +import { + CitationsState, + ContributorsState, + NodeLinksState, + SubjectsState, + ViewOnlyLinkState, +} from '@osf/shared/stores'; import { AnalyticsState } from './analytics/store'; import { ProjectFilesState } from './files/store'; @@ -23,7 +29,7 @@ export const projectRoutes: Routes = [ path: 'overview', loadComponent: () => import('../project/overview/project-overview.component').then((mod) => mod.ProjectOverviewComponent), - providers: [provideStates([NodeLinksState])], + providers: [provideStates([CitationsState, NodeLinksState])], }, { path: 'metadata', diff --git a/src/app/features/registry/mappers/registry-metadata.mapper.ts b/src/app/features/registry/mappers/registry-metadata.mapper.ts index d94a07ef1..ca4c1abd9 100644 --- a/src/app/features/registry/mappers/registry-metadata.mapper.ts +++ b/src/app/features/registry/mappers/registry-metadata.mapper.ts @@ -85,6 +85,7 @@ export class RegistryMetadataMapper { doi: (attributes['doi'] as string) || '', isPublic: attributes['public'] as boolean, isFork: attributes['fork'] as boolean, + customCitation: (attributes['custom_citation'] as string) || '', accessRequestsEnabled: attributes['access_requests_enabled'] as boolean, wikiEnabled: attributes['wiki_enabled'] as boolean, currentUserCanComment: attributes['current_user_can_comment'] as boolean, diff --git a/src/app/features/registry/models/registry-overview.models.ts b/src/app/features/registry/models/registry-overview.models.ts index b9c34528a..39c1b9dd0 100644 --- a/src/app/features/registry/models/registry-overview.models.ts +++ b/src/app/features/registry/models/registry-overview.models.ts @@ -44,6 +44,7 @@ export interface RegistryOverview { type: string; }; subjects?: RegistrySubject[]; + customCitation: string; hasData: boolean; hasAnalyticCode: boolean; hasMaterials: boolean; diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.html b/src/app/features/registry/pages/registry-overview/registry-overview.component.html index 2096ba64a..2e614bdba 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.html +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.html @@ -62,7 +62,10 @@

{{ block.value }}

- +
diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index f8b565ed3..ed6f98509 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -25,6 +25,7 @@ import { GetRegistryInstitutions, GetRegistrySubjects, RegistryOverviewSelectors, + SetRegistryCustomCitation, } from '../../store/registry-overview'; @Component({ @@ -104,6 +105,7 @@ export class RegistryOverviewComponent { getBookmarksId: GetBookmarksCollectionId, getSubjects: GetRegistrySubjects, getInstitutions: GetRegistryInstitutions, + setCustomCitation: SetRegistryCustomCitation, }); constructor() { @@ -125,4 +127,8 @@ export class RegistryOverviewComponent { openRevision(revisionIndex: number): void { this.selectedRevisionIndex.set(revisionIndex); } + + onCustomCitationUpdated(citation: string): void { + this.actions.setCustomCitation(citation); + } } diff --git a/src/app/features/registry/store/registry-overview/registry-overview.actions.ts b/src/app/features/registry/store/registry-overview/registry-overview.actions.ts index 1fb066991..448637f09 100644 --- a/src/app/features/registry/store/registry-overview/registry-overview.actions.ts +++ b/src/app/features/registry/store/registry-overview/registry-overview.actions.ts @@ -41,3 +41,9 @@ export class MakePublic { constructor(public registryId: string) {} } + +export class SetRegistryCustomCitation { + static readonly type = '[Registry Overview] Set Registry Custom Citation'; + + constructor(public citation: string) {} +} diff --git a/src/app/features/registry/store/registry-overview/registry-overview.state.ts b/src/app/features/registry/store/registry-overview/registry-overview.state.ts index 9f0c47952..8df7c514c 100644 --- a/src/app/features/registry/store/registry-overview/registry-overview.state.ts +++ b/src/app/features/registry/store/registry-overview/registry-overview.state.ts @@ -13,6 +13,7 @@ import { GetRegistrySubjects, GetSchemaBlocks, MakePublic, + SetRegistryCustomCitation, WithdrawRegistration, } from './registry-overview.actions'; import { RegistryOverviewStateModel } from './registry-overview.model'; @@ -211,6 +212,20 @@ export class RegistryOverviewState { ); } + @Action(SetRegistryCustomCitation) + setRegistryCustomCitation(ctx: StateContext, action: SetRegistryCustomCitation) { + const state = ctx.getState(); + ctx.patchState({ + registry: { + ...state.registry, + data: { + ...state.registry.data!, + customCitation: action.citation, + }, + }, + }); + } + private handleError( ctx: StateContext, section: 'registry' | 'subjects' | 'institutions' | 'schemaBlocks', diff --git a/src/app/shared/components/resource-citations/resource-citations.component.html b/src/app/shared/components/resource-citations/resource-citations.component.html new file mode 100644 index 000000000..aeacd5222 --- /dev/null +++ b/src/app/shared/components/resource-citations/resource-citations.component.html @@ -0,0 +1,118 @@ +@let resource = currentResource(); + +@if (resource) { + +} diff --git a/src/app/shared/components/resource-citations/resource-citations.component.scss b/src/app/shared/components/resource-citations/resource-citations.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resource-citations/resource-citations.component.spec.ts b/src/app/shared/components/resource-citations/resource-citations.component.spec.ts new file mode 100644 index 000000000..cd842b506 --- /dev/null +++ b/src/app/shared/components/resource-citations/resource-citations.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResourceCitationsComponent } from './resource-citations.component'; + +describe('ResourceCitationsComponent', () => { + let component: ResourceCitationsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourceCitationsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ResourceCitationsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resource-citations/resource-citations.component.ts b/src/app/shared/components/resource-citations/resource-citations.component.ts new file mode 100644 index 000000000..dc7cabd9b --- /dev/null +++ b/src/app/shared/components/resource-citations/resource-citations.component.ts @@ -0,0 +1,202 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; +import { Button } from 'primeng/button'; +import { Divider } from 'primeng/divider'; +import { Select, SelectChangeEvent, SelectFilterEvent } from 'primeng/select'; +import { Skeleton } from 'primeng/skeleton'; +import { Textarea } from 'primeng/textarea'; + +import { debounceTime, distinctUntilChanged, Subject, takeUntil } from 'rxjs'; + +import { Clipboard } from '@angular/cdk/clipboard'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; + +import { CitationStyle, CustomOption, ResourceOverview } from '@shared/models'; +import { ToastService } from '@shared/services'; +import { + CitationsSelectors, + GetCitationStyles, + GetDefaultCitations, + GetStyledCitation, + UpdateCustomCitation, +} from '@shared/stores'; + +@Component({ + selector: 'osf-resource-citations', + imports: [ + Accordion, + AccordionPanel, + AccordionHeader, + TranslatePipe, + AccordionContent, + Divider, + Select, + Button, + Skeleton, + Textarea, + ReactiveFormsModule, + ], + templateUrl: './resource-citations.component.html', + styleUrl: './resource-citations.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResourceCitationsComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly translateService = inject(TranslateService); + currentResource = input.required(); + private readonly clipboard = inject(Clipboard); + private readonly toastService = inject(ToastService); + private readonly destroy$ = new Subject(); + private readonly filterSubject = new Subject(); + protected customCitation = output(); + protected defaultCitations = select(CitationsSelectors.getDefaultCitations); + protected isCitationsLoading = select(CitationsSelectors.getDefaultCitationsLoading); + protected citationStyles = select(CitationsSelectors.getCitationStyles); + protected isCitationStylesLoading = select(CitationsSelectors.getCitationStylesLoading); + protected isCustomCitationSubmitting = select(CitationsSelectors.getCustomCitationSubmitting); + protected styledCitation = select(CitationsSelectors.getStyledCitation); + protected citationStylesOptions = signal[]>([]); + protected isEditMode = signal(false); + protected filterMessage = computed(() => { + const isLoading = this.isCitationStylesLoading(); + return isLoading + ? this.translateService.instant('project.overview.metadata.citationLoadingPlaceholder') + : this.translateService.instant('project.overview.metadata.noCitationStylesFound'); + }); + protected customCitationInput = new FormControl(''); + + protected actions = createDispatchMap({ + getDefaultCitations: GetDefaultCitations, + getCitationStyles: GetCitationStyles, + getStyledCitation: GetStyledCitation, + updateCustomCitation: UpdateCustomCitation, + }); + + constructor() { + this.setupFilterDebounce(); + this.setupDefaultCitationsEffect(); + this.setupCitationStylesEffect(); + this.setupDestroyEffect(); + } + + protected setupDefaultCitationsEffect(): void { + effect(() => { + const resource = this.currentResource(); + + if (resource) { + this.actions.getDefaultCitations(resource.type, resource.id); + this.customCitationInput.setValue(resource.customCitation); + } + }); + } + + protected handleCitationStyleFilterSearch(event: SelectFilterEvent) { + event.originalEvent.preventDefault(); + this.filterSubject.next(event.filter); + } + + protected handleGetStyledCitation(event: SelectChangeEvent) { + const resource = this.currentResource(); + + if (resource) { + this.actions.getStyledCitation(resource.type, resource.id, event.value.id); + } + } + + protected handleUpdateCustomCitation(): void { + const resource = this.currentResource(); + const customCitationText = this.customCitationInput.value?.trim(); + + if (resource && customCitationText) { + const payload = { + id: resource.id, + type: resource.type, + citationText: customCitationText, + }; + + this.actions.updateCustomCitation(payload).subscribe({ + next: () => { + this.customCitation.emit(customCitationText); + }, + complete: () => { + this.toggleEditMode(); + }, + }); + } + } + + protected handleDeleteCustomCitation(): void { + const resource = this.currentResource(); + + if (resource) { + const payload = { + id: resource.id, + type: resource.type, + citationText: '', + }; + + this.actions.updateCustomCitation(payload).subscribe({ + next: () => { + this.customCitation.emit(''); + }, + complete: () => { + this.toggleEditMode(); + }, + }); + } + } + + protected toggleEditMode(): void { + this.isEditMode.set(!this.isEditMode()); + } + + protected copyCitation(): void { + const resource = this.currentResource(); + + if (resource?.customCitation) { + this.clipboard.copy(resource.customCitation); + this.toastService.showSuccess('settings.developerApps.messages.copied'); + } + } + + private setupFilterDebounce(): void { + this.filterSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe((filterValue) => { + this.actions.getCitationStyles(filterValue); + }); + } + + private setupCitationStylesEffect(): void { + effect(() => { + const styles = this.citationStyles(); + + const options = styles.map((style: CitationStyle) => ({ + label: style.title, + value: style, + })); + this.citationStylesOptions.set(options); + }); + } + + private setupDestroyEffect(): void { + this.destroyRef.onDestroy(() => { + this.destroy$.next(); + this.destroy$.complete(); + }); + } +} diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.html b/src/app/shared/components/resource-metadata/resource-metadata.component.html index 9a6a93e23..1252df352 100644 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.html +++ b/src/app/shared/components/resource-metadata/resource-metadata.component.html @@ -163,5 +163,10 @@

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

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

} + + } diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.ts b/src/app/shared/components/resource-metadata/resource-metadata.component.ts index 6b02fa95f..533b7af33 100644 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.ts +++ b/src/app/shared/components/resource-metadata/resource-metadata.component.ts @@ -4,22 +4,28 @@ import { Button } from 'primeng/button'; import { Tag } from 'primeng/tag'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { ResourceCitationsComponent } from '@shared/components/resource-citations/resource-citations.component'; import { TruncatedTextComponent } from '@shared/components/truncated-text/truncated-text.component'; import { OsfResourceTypes } from '@shared/constants'; import { ResourceOverview } from '@shared/models'; @Component({ selector: 'osf-resource-metadata', - imports: [Button, TranslatePipe, TruncatedTextComponent, RouterLink, Tag, DatePipe], + imports: [Button, TranslatePipe, TruncatedTextComponent, RouterLink, Tag, DatePipe, ResourceCitationsComponent], templateUrl: './resource-metadata.component.html', styleUrl: './resource-metadata.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ResourceMetadataComponent { currentResource = input.required(); + customCitationUpdated = output(); protected readonly resourceTypes = OsfResourceTypes; + + onCustomCitationUpdated(citation: string): void { + this.customCitationUpdated.emit(citation); + } } diff --git a/src/app/shared/components/truncated-text/truncated-text.component.scss b/src/app/shared/components/truncated-text/truncated-text.component.scss index 5fcf0c1d0..4b46cf06c 100644 --- a/src/app/shared/components/truncated-text/truncated-text.component.scss +++ b/src/app/shared/components/truncated-text/truncated-text.component.scss @@ -7,6 +7,7 @@ line-height: 1.7; -webkit-line-clamp: var(--line-clamp); -webkit-box-orient: vertical; + white-space: pre-line; &.expanded { display: block; diff --git a/src/app/shared/constants/default-citation-titles.const.ts b/src/app/shared/constants/default-citation-titles.const.ts new file mode 100644 index 000000000..0f543dcc7 --- /dev/null +++ b/src/app/shared/constants/default-citation-titles.const.ts @@ -0,0 +1,7 @@ +import { CitationTypes } from '@shared/enums'; + +export const CITATION_TITLES: Record = { + [CitationTypes.APA]: 'APA', + [CitationTypes.MLA]: 'MLA', + [CitationTypes.CHICAGO]: 'Chicago', +}; diff --git a/src/app/shared/constants/index.ts b/src/app/shared/constants/index.ts index c8fbd40d4..178bd2fba 100644 --- a/src/app/shared/constants/index.ts +++ b/src/app/shared/constants/index.ts @@ -3,6 +3,7 @@ export * from './addons-category-options.const'; export * from './addons-tab-options.const'; export * from './constants'; export * from './contributors'; +export * from './default-citation-titles.const'; export * from './filter-placeholders'; export * from './input-limits.const'; export * from './input-validation-messages.const'; diff --git a/src/app/shared/enums/citation-types.enum.ts b/src/app/shared/enums/citation-types.enum.ts new file mode 100644 index 000000000..f7e32943d --- /dev/null +++ b/src/app/shared/enums/citation-types.enum.ts @@ -0,0 +1,5 @@ +export enum CitationTypes { + APA = 'apa', + MLA = 'modern-language-association', + CHICAGO = 'chicago-author-date', +} diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index bbb8ad422..8f8ece679 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -3,6 +3,7 @@ export * from './addon-tab.enum'; export * from './addons-category.enum'; export * from './addons-credentials-format.enum'; export * from './breakpoint-queries.enum'; +export * from './citation-types.enum'; export * from './contributors'; export * from './create-component-form-controls.enum'; export * from './create-project-form-controls.enum'; diff --git a/src/app/shared/mappers/citations.mapper.ts b/src/app/shared/mappers/citations.mapper.ts new file mode 100644 index 000000000..0a45e6d8a --- /dev/null +++ b/src/app/shared/mappers/citations.mapper.ts @@ -0,0 +1,56 @@ +import { CITATION_TITLES } from '@shared/constants'; +import { CitationTypes } from '@shared/enums'; +import { + CitationStyle, + CitationStyleJsonApi, + DefaultCitation, + DefaultCitationJsonApi, + StyledCitation, + StyledCitationJsonApi, +} from '@shared/models'; +import { CustomCitationPayload } from '@shared/models/citations/custom-citation-payload.model'; +import { CustomCitationPayloadJsonApi } from '@shared/models/citations/custom-citation-payload-json-api.model'; + +export class CitationsMapper { + static fromGetDefaultResponse(response: DefaultCitationJsonApi): DefaultCitation { + const citationId = response.id; + + return { + id: citationId, + type: response.type, + citation: response.attributes.citation, + title: CITATION_TITLES[citationId as CitationTypes], + }; + } + + static fromGetCitationStylesResponse(response: CitationStyleJsonApi[]): CitationStyle[] { + return response.map((style) => ({ + id: style.id, + type: style.type, + title: style.attributes.title, + shortTitle: style.attributes.short_title, + summary: style.attributes.summary, + dateParsed: style.attributes.date_parsed, + })); + } + + static fromGetStyledCitationResponse(response: StyledCitationJsonApi): StyledCitation { + return { + id: response.id, + type: response.type, + citation: response.attributes.citation, + }; + } + + static toUpdateCustomCitationRequest(payload: CustomCitationPayload): CustomCitationPayloadJsonApi { + return { + data: { + id: payload.id, + type: payload.type, + attributes: { + custom_citation: payload.citationText, + }, + }, + }; + } +} diff --git a/src/app/shared/mappers/index.ts b/src/app/shared/mappers/index.ts index c1470f13e..2d546d8fd 100644 --- a/src/app/shared/mappers/index.ts +++ b/src/app/shared/mappers/index.ts @@ -1,4 +1,5 @@ export * from './addon.mapper'; +export * from './citations.mapper'; export * from './components'; export * from './contributors'; export * from './filters'; diff --git a/src/app/shared/mappers/resource-overview.mappers.ts b/src/app/shared/mappers/resource-overview.mappers.ts index 24dc74f26..4fa3244a8 100644 --- a/src/app/shared/mappers/resource-overview.mappers.ts +++ b/src/app/shared/mappers/resource-overview.mappers.ts @@ -31,6 +31,7 @@ export function MapProjectOverview(project: ProjectOverview): ResourceOverview { wikiEnabled: project.wikiEnabled, subjects: project.subjects?.filter(Boolean) || [], contributors: project.contributors?.filter(Boolean) || [], + customCitation: project.customCitation || null, region: project.region || undefined, affiliatedInstitutions: project.affiliatedInstitutions?.filter(Boolean) || undefined, forksCount: project.forksCount || 0, @@ -73,6 +74,7 @@ export function MapRegistryOverview( region: registry.region || undefined, forksCount: registry.forksCount, subjects: subjects, + customCitation: registry.customCitation, affiliatedInstitutions: institutions, associatedProjectId: registry.associatedProjectId, }; diff --git a/src/app/shared/models/citations/citation-style-json-api.model.ts b/src/app/shared/models/citations/citation-style-json-api.model.ts new file mode 100644 index 000000000..4f31268aa --- /dev/null +++ b/src/app/shared/models/citations/citation-style-json-api.model.ts @@ -0,0 +1,11 @@ +export interface CitationStyleJsonApi { + id: string; + type: string; + attributes: { + title: string; + short_title: string | null; + summary: string | null; + date_parsed: string; + }; + links: Record; +} diff --git a/src/app/shared/models/citations/citation-style.model.ts b/src/app/shared/models/citations/citation-style.model.ts new file mode 100644 index 000000000..19879adfa --- /dev/null +++ b/src/app/shared/models/citations/citation-style.model.ts @@ -0,0 +1,7 @@ +export interface CitationStyle { + id: string; + title: string; + shortTitle: string | null; + summary: string | null; + dateParsed: string; +} diff --git a/src/app/shared/models/citations/custom-citation-payload-json-api.model.ts b/src/app/shared/models/citations/custom-citation-payload-json-api.model.ts new file mode 100644 index 000000000..34e043fbc --- /dev/null +++ b/src/app/shared/models/citations/custom-citation-payload-json-api.model.ts @@ -0,0 +1,7 @@ +export interface CustomCitationPayloadJsonApi { + data: { + id: string; + type: string; + attributes: { custom_citation: string }; + }; +} diff --git a/src/app/shared/models/citations/custom-citation-payload.model.ts b/src/app/shared/models/citations/custom-citation-payload.model.ts new file mode 100644 index 000000000..862bb2eed --- /dev/null +++ b/src/app/shared/models/citations/custom-citation-payload.model.ts @@ -0,0 +1,5 @@ +export interface CustomCitationPayload { + id: string; + type: string; + citationText: string; +} diff --git a/src/app/shared/models/citations/default-citation-json-api.model.ts b/src/app/shared/models/citations/default-citation-json-api.model.ts new file mode 100644 index 000000000..104ae90e9 --- /dev/null +++ b/src/app/shared/models/citations/default-citation-json-api.model.ts @@ -0,0 +1,7 @@ +export interface DefaultCitationJsonApi { + id: string; + type: string; + attributes: { + citation: string; + }; +} diff --git a/src/app/shared/models/citations/default-citation.model.ts b/src/app/shared/models/citations/default-citation.model.ts new file mode 100644 index 000000000..55725a0f2 --- /dev/null +++ b/src/app/shared/models/citations/default-citation.model.ts @@ -0,0 +1,6 @@ +export interface DefaultCitation { + id: string; + type: string; + title: string; + citation?: string; +} diff --git a/src/app/shared/models/citations/index.ts b/src/app/shared/models/citations/index.ts new file mode 100644 index 000000000..49b54e04d --- /dev/null +++ b/src/app/shared/models/citations/index.ts @@ -0,0 +1,8 @@ +export * from './citation-style.model'; +export * from './citation-style-json-api.model'; +export * from './custom-citation-payload.model'; +export * from './custom-citation-payload-json-api.model'; +export * from './default-citation.model'; +export * from './default-citation-json-api.model'; +export * from './styled-citation.model'; +export * from './styled-citation-json-api.model'; diff --git a/src/app/shared/models/citations/styled-citation-json-api.model.ts b/src/app/shared/models/citations/styled-citation-json-api.model.ts new file mode 100644 index 000000000..6b6113311 --- /dev/null +++ b/src/app/shared/models/citations/styled-citation-json-api.model.ts @@ -0,0 +1,7 @@ +export interface StyledCitationJsonApi { + id: string; + type: string; + attributes: { + citation: string; + }; +} diff --git a/src/app/shared/models/citations/styled-citation.model.ts b/src/app/shared/models/citations/styled-citation.model.ts new file mode 100644 index 000000000..9ce85f20a --- /dev/null +++ b/src/app/shared/models/citations/styled-citation.model.ts @@ -0,0 +1,5 @@ +export interface StyledCitation { + id: string; + type: string; + citation: string; +} diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index 33f9c295b..c78ff0c9a 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -3,6 +3,7 @@ export * from './brand.json-api.model'; export * from './brand.model'; export * from './can-deactivate.interface'; export * from './charts'; +export * from './citations'; export * from './components'; export * from './confirmation-options.model'; export * from './contributors'; diff --git a/src/app/shared/models/resource-overview.model.ts b/src/app/shared/models/resource-overview.model.ts index 77caa9968..ad81f1de9 100644 --- a/src/app/shared/models/resource-overview.model.ts +++ b/src/app/shared/models/resource-overview.model.ts @@ -54,6 +54,7 @@ export interface ResourceOverview { wikiEnabled: boolean; subjects: RegistrySubject[]; contributors: ProjectOverviewContributor[]; + customCitation: string | null; region?: { id: string; type: string; diff --git a/src/app/shared/services/citations.service.ts b/src/app/shared/services/citations.service.ts new file mode 100644 index 000000000..74f16cb39 --- /dev/null +++ b/src/app/shared/services/citations.service.ts @@ -0,0 +1,64 @@ +import { map, Observable } from 'rxjs'; + +import { HttpParams } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { JsonApiResponse } from '@core/models'; +import { JsonApiService } from '@core/services'; +import { CitationsMapper } from '@shared/mappers'; +import { + CitationStyle, + CitationStyleJsonApi, + CustomCitationPayload, + DefaultCitation, + DefaultCitationJsonApi, + StyledCitation, + StyledCitationJsonApi, +} from '@shared/models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class CitationsService { + private readonly jsonApiService = inject(JsonApiService); + + fetchDefaultCitation(resourceType: string, resourceId: string, citationId: string): Observable { + const baseUrl = this.getBaseCitationUrl(resourceType, resourceId); + return this.jsonApiService + .get>(`${baseUrl}/${citationId}/`) + .pipe(map((response) => CitationsMapper.fromGetDefaultResponse(response.data))); + } + + fetchCitationStyles(searchQuery?: string): Observable { + const baseUrl = environment.apiUrl; + + const params = new HttpParams().set('filter[title,short_title]', searchQuery || '').set('page[size]', '100'); + + return this.jsonApiService + .get>(`${baseUrl}/citations/styles`, { params }) + .pipe(map((response) => CitationsMapper.fromGetCitationStylesResponse(response.data))); + } + + updateCustomCitation(payload: CustomCitationPayload): Observable { + const baseUrl = environment.apiUrl; + const citationData = CitationsMapper.toUpdateCustomCitationRequest(payload); + + return this.jsonApiService.patch(`${baseUrl}/${payload.type}/${payload.id}/`, citationData); + } + + fetchStyledCitation(resourceType: string, resourceId: string, citationStyle: string): Observable { + const baseUrl = this.getBaseCitationUrl(resourceType, resourceId); + + return this.jsonApiService + .get>(`${baseUrl}/${citationStyle}/`) + .pipe(map((response) => CitationsMapper.fromGetStyledCitationResponse(response.data))); + } + + private getBaseCitationUrl(resourceType: string, resourceId: string): string { + const baseUrl = `${environment.apiUrl}`; + + return `${baseUrl}/${resourceType}/${resourceId}/citation`; + } +} diff --git a/src/app/shared/stores/citations/citations.actions.ts b/src/app/shared/stores/citations/citations.actions.ts new file mode 100644 index 000000000..bd92a13e3 --- /dev/null +++ b/src/app/shared/stores/citations/citations.actions.ts @@ -0,0 +1,32 @@ +import { CustomCitationPayload } from '@shared/models'; + +export class GetDefaultCitations { + static readonly type = '[Citations] Get Default Citations'; + + constructor( + public resourceType: string, + public resourceId: string + ) {} +} + +export class GetCitationStyles { + static readonly type = '[Citations] Get Citation Styles'; + + constructor(public searchQuery?: string) {} +} + +export class UpdateCustomCitation { + static readonly type = '[Citations] Update Custom Citation'; + + constructor(public payload: CustomCitationPayload) {} +} + +export class GetStyledCitation { + static readonly type = '[Citations] Get Styled Citation'; + + constructor( + public resourceType: string, + public resourceId: string, + public citationStyle: string + ) {} +} diff --git a/src/app/shared/stores/citations/citations.model.ts b/src/app/shared/stores/citations/citations.model.ts new file mode 100644 index 000000000..0f5a91097 --- /dev/null +++ b/src/app/shared/stores/citations/citations.model.ts @@ -0,0 +1,9 @@ +import { CitationStyle, DefaultCitation, StyledCitation } from '@shared/models'; +import { AsyncStateModel } from '@shared/models/store'; + +export interface CitationsStateModel { + defaultCitations: AsyncStateModel; + citationStyles: AsyncStateModel; + styledCitation: AsyncStateModel; + customCitation: AsyncStateModel; +} diff --git a/src/app/shared/stores/citations/citations.selectors.ts b/src/app/shared/stores/citations/citations.selectors.ts new file mode 100644 index 000000000..dda0f1f53 --- /dev/null +++ b/src/app/shared/stores/citations/citations.selectors.ts @@ -0,0 +1,46 @@ +import { Selector } from '@ngxs/store'; + +import { CitationsStateModel } from './citations.model'; +import { CitationsState } from './citations.state'; + +export class CitationsSelectors { + @Selector([CitationsState]) + static getDefaultCitations(state: CitationsStateModel) { + return state.defaultCitations.data; + } + + @Selector([CitationsState]) + static getDefaultCitationsLoading(state: CitationsStateModel) { + return state.defaultCitations.isLoading; + } + + @Selector([CitationsState]) + static getDefaultCitationsSubmitting(state: CitationsStateModel) { + return state.defaultCitations.isSubmitting; + } + + @Selector([CitationsState]) + static getCustomCitationSubmitting(state: CitationsStateModel) { + return state.customCitation.isSubmitting; + } + + @Selector([CitationsState]) + static getCitationStyles(state: CitationsStateModel) { + return state.citationStyles.data; + } + + @Selector([CitationsState]) + static getCitationStylesLoading(state: CitationsStateModel) { + return state.citationStyles.isLoading; + } + + @Selector([CitationsState]) + static getStyledCitation(state: CitationsStateModel) { + return state.styledCitation.data; + } + + @Selector([CitationsState]) + static getStyledCitationLoading(state: CitationsStateModel) { + return state.styledCitation.isLoading; + } +} diff --git a/src/app/shared/stores/citations/citations.state.ts b/src/app/shared/stores/citations/citations.state.ts new file mode 100644 index 000000000..a32b4e005 --- /dev/null +++ b/src/app/shared/stores/citations/citations.state.ts @@ -0,0 +1,155 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { catchError, forkJoin, Observable, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@core/handlers'; +import { CitationTypes } from '@shared/enums'; +import { CitationsService } from '@shared/services/citations.service'; + +import { GetCitationStyles, GetDefaultCitations, GetStyledCitation, UpdateCustomCitation } from './citations.actions'; +import { CitationsStateModel } from './citations.model'; + +const CITATIONS_DEFAULTS: CitationsStateModel = { + defaultCitations: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, + citationStyles: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, + styledCitation: { + data: null, + isLoading: false, + isSubmitting: false, + error: null, + }, + customCitation: { + data: '', + isLoading: false, + isSubmitting: false, + error: null, + }, +}; + +@State({ + name: 'citations', + defaults: CITATIONS_DEFAULTS, +}) +@Injectable() +export class CitationsState { + citationsService = inject(CitationsService); + + @Action(GetDefaultCitations) + getDefaultCitation(ctx: StateContext, action: GetDefaultCitations) { + const state = ctx.getState(); + ctx.patchState({ + defaultCitations: { + ...state.defaultCitations, + isLoading: true, + error: null, + }, + }); + + const citationRequests = Object.values(CitationTypes).map((citationType) => + this.citationsService.fetchDefaultCitation(action.resourceType, action.resourceId, citationType) + ); + + return forkJoin(citationRequests).pipe( + tap((citations) => { + ctx.patchState({ + defaultCitations: { + data: citations, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'defaultCitations', error)) + ); + } + + @Action(GetCitationStyles) + getCitationStyles(ctx: StateContext, action: GetCitationStyles) { + const state = ctx.getState(); + ctx.patchState({ + citationStyles: { + ...state.citationStyles, + isLoading: true, + error: null, + }, + }); + + return this.citationsService.fetchCitationStyles(action.searchQuery).pipe( + tap((citationStyles) => { + ctx.patchState({ + citationStyles: { + data: citationStyles, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'citationStyles', error)) + ); + } + + @Action(UpdateCustomCitation) + updateCustomCitation(ctx: StateContext, action: UpdateCustomCitation): Observable { + const state = ctx.getState(); + ctx.patchState({ + customCitation: { + ...state.customCitation, + isSubmitting: true, + error: null, + }, + }); + + return this.citationsService.updateCustomCitation(action.payload).pipe( + tap(() => { + ctx.patchState({ + customCitation: { + ...state.customCitation, + data: action.payload.citationText, + isSubmitting: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'customCitation', error)) + ); + } + + @Action(GetStyledCitation) + getStyledCitation(ctx: StateContext, action: GetStyledCitation) { + const state = ctx.getState(); + ctx.patchState({ + styledCitation: { + ...state.styledCitation, + isLoading: true, + error: null, + }, + }); + + return this.citationsService.fetchStyledCitation(action.resourceType, action.resourceId, action.citationStyle).pipe( + tap((styledCitation) => { + ctx.patchState({ + styledCitation: { + data: styledCitation, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'styledCitation', error)) + ); + } +} diff --git a/src/app/shared/stores/citations/index.ts b/src/app/shared/stores/citations/index.ts new file mode 100644 index 000000000..59e249712 --- /dev/null +++ b/src/app/shared/stores/citations/index.ts @@ -0,0 +1,4 @@ +export * from './citations.actions'; +export * from './citations.model'; +export * from './citations.selectors'; +export * from './citations.state'; diff --git a/src/app/shared/stores/index.ts b/src/app/shared/stores/index.ts index 4e519e0a1..bcda2d20a 100644 --- a/src/app/shared/stores/index.ts +++ b/src/app/shared/stores/index.ts @@ -1,5 +1,6 @@ export * from './addons'; export * from './bookmarks'; +export * from './citations'; export * from './contributors'; export * from './institutions'; export * from './institutions-search'; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index e14d85615..d1bab0ca4 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -40,6 +40,8 @@ "view": "View", "review": "Review", "upload": "Upload", + "customize": "Customize", + "createCustomCitation": "Create Custom Citation", "preview": "Preview", "continueUpdate": "Continue Update" }, @@ -101,7 +103,7 @@ "message": "Are you sure you want to proceed?" }, "discardChanges": { - "header": "You Might Loose Unsaved Changes", + "header": "You Might Lose Unsaved Changes", "message": "Are you sure you want to proceed?" }, "discardChangesDialog": { @@ -577,6 +579,13 @@ "publication": "Publication DOI", "subjects": "Subjects", "tags": "Tags", + "citation": "Citation", + "getMoreCitations": "Get More Citations", + "citationInputPlaceholder": "Select citation style or start typing", + "citationLoadingPlaceholder": "Loading options...", + "customCitationPlaceholder": "Enter custom citation", + "citeAs": "Cite as:", + "noCitationStylesFound": "No results found", "affiliatedInstitutions": "Affiliated Institutions", "noDescription": "No description", "noLicense": "No License", diff --git a/src/assets/styles/overrides/accordion.scss b/src/assets/styles/overrides/accordion.scss index da0ce6333..221e87564 100644 --- a/src/assets/styles/overrides/accordion.scss +++ b/src/assets/styles/overrides/accordion.scss @@ -15,7 +15,8 @@ } .resource, -.license { +.license, +.metadata-accordion { .p-accordion { --p-accordion-panel-border-width: 0;