diff --git a/.husky/pre-push b/.husky/pre-push index 85c4fcf43..06ca98983 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -16,4 +16,4 @@ npm run test:check-coverage-thresholds || { printf "\n\nYou are seeing this error because test coverage increased without updating the jest.config.js thresholds." #printf "\n\nPlease address them before proceeding.\n\n\n\n" # exit 1 -} \ No newline at end of file +} diff --git a/src/app/features/moderation/enums/submission-review-status.enum.ts b/src/app/features/moderation/enums/submission-review-status.enum.ts index e2dc41b46..140b59d94 100644 --- a/src/app/features/moderation/enums/submission-review-status.enum.ts +++ b/src/app/features/moderation/enums/submission-review-status.enum.ts @@ -1,5 +1,6 @@ export enum SubmissionReviewStatus { Pending = 'pending', + InProgress = 'in_progress', Accepted = 'accepted', Rejected = 'rejected', Withdrawn = 'withdrawn', diff --git a/src/app/features/project/analytics/analytics.component.html b/src/app/features/project/analytics/analytics.component.html index 0a2379e28..33a9a5bcd 100644 --- a/src/app/features/project/analytics/analytics.component.html +++ b/src/app/features/project/analytics/analytics.component.html @@ -70,6 +70,7 @@ [value]="relatedCounts()?.forksCount" [showButton]="true" [buttonLabel]="'project.analytics.kpi.viewForks'" + (buttonClick)="navigateToDuplicates()" > params['id'])) ?? of(undefined)); readonly resourceType: Signal = toSignal( @@ -63,7 +65,11 @@ export class AnalyticsComponent implements OnInit { protected isMetricsError = select(AnalyticsSelectors.isMetricsError); - protected actions = createDispatchMap({ getMetrics: GetMetrics, getRelatedCounts: GetRelatedCounts }); + protected actions = createDispatchMap({ + getMetrics: GetMetrics, + getRelatedCounts: GetRelatedCounts, + clearAnalytics: ClearAnalytics, + }); protected visitsLabels: string[] = []; protected visitsDataset: DatasetInput[] = []; @@ -83,6 +89,16 @@ export class AnalyticsComponent implements OnInit { this.setData(); } + constructor() { + this.setupCleanup(); + } + + setupCleanup(): void { + this.destroyRef.onDestroy(() => { + this.actions.clearAnalytics(); + }); + } + onRangeChange(range: DateRangeOption) { this.selectedRange.set(range); this.actions.getMetrics(this.resourceId(), range.value); @@ -107,4 +123,8 @@ export class AnalyticsComponent implements OnInit { this.popularPagesLabels = analytics.popularPages.map((item) => item.title); this.popularPagesDataset = [{ label: 'Popular pages', data: analytics.popularPages.map((item) => item.count) }]; } + + protected navigateToDuplicates() { + this.router.navigate(['duplicates'], { relativeTo: this.route }); + } } diff --git a/src/app/features/project/analytics/components/analytics-kpi/analytics-kpi.component.html b/src/app/features/project/analytics/components/analytics-kpi/analytics-kpi.component.html index c56d71575..d4116560b 100644 --- a/src/app/features/project/analytics/components/analytics-kpi/analytics-kpi.component.html +++ b/src/app/features/project/analytics/components/analytics-kpi/analytics-kpi.component.html @@ -9,7 +9,7 @@ @if (showButton()) {
- +
} diff --git a/src/app/features/project/analytics/components/analytics-kpi/analytics-kpi.component.ts b/src/app/features/project/analytics/components/analytics-kpi/analytics-kpi.component.ts index 65911e37a..90d7b7a5f 100644 --- a/src/app/features/project/analytics/components/analytics-kpi/analytics-kpi.component.ts +++ b/src/app/features/project/analytics/components/analytics-kpi/analytics-kpi.component.ts @@ -3,7 +3,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; @Component({ selector: 'osf-analytics-kpi', @@ -18,4 +18,5 @@ export class AnalyticsKpiComponent { buttonLabel = input(''); title = input(''); value = input(0); + buttonClick = output(); } diff --git a/src/app/features/project/analytics/components/index.ts b/src/app/features/project/analytics/components/index.ts index ed50233b4..8f6e62400 100644 --- a/src/app/features/project/analytics/components/index.ts +++ b/src/app/features/project/analytics/components/index.ts @@ -1 +1,2 @@ export { AnalyticsKpiComponent } from './analytics-kpi/analytics-kpi.component'; +export { ViewDuplicatesComponent } from './view-duplicates/view-duplicates.component'; diff --git a/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.html b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.html new file mode 100644 index 000000000..b3ca268c8 --- /dev/null +++ b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.html @@ -0,0 +1,92 @@ + + +
+ @if (!isDuplicatesLoading() && currentResource()) { + @if (!duplicates().length) { +

{{ 'project.overview.dialog.fork.noForksMessage' | translate }}

+ } @else { +

{{ 'project.overview.dialog.fork.forksMessage' | translate }}

+ + @for (duplicate of duplicates(); track duplicate.id) { + @if (duplicate.currentUserPermissions.includes(UserPermissions.Read)) { +
+
+

+ + {{ duplicate.title }} +

+
+ @if (duplicate.currentUserPermissions.includes(UserPermissions.Write)) { + + + } + + + + {{ item.label | translate }} + + +
+
+ +
+
+ {{ 'common.labels.forked' | translate }}: +

{{ duplicate.dateCreated | date: 'MMM d, y, h:mm a' }}

+
+ +
+ {{ 'common.labels.lastUpdated' | translate }}: +

{{ duplicate.dateModified | date: 'MMM d, y, h:mm a' }}

+
+ +
+ {{ 'common.labels.contributors' | translate }}: + @for (contributor of duplicate.contributors; track contributor.id) { +
+ {{ contributor.fullName }} + {{ $last ? '' : ',' }} +
+ } +
+ +
+
+ {{ 'common.labels.description' | translate }}: + +
+
+
+ +
+ } + } + + @if (totalDuplicates() > pageSize) { + + } + } + } @else { + + } +
diff --git a/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.scss b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.scss new file mode 100644 index 000000000..79f7bf627 --- /dev/null +++ b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.scss @@ -0,0 +1,14 @@ +@use "assets/styles/variables" as var; +@use "assets/styles/mixins" as mix; + +:host { + display: flex; + flex-direction: column; + flex: 1; +} + +.duplicate-wrapper { + border: 1px solid var.$grey-2; + border-radius: mix.rem(12px); + color: var.$dark-blue-1; +} diff --git a/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.spec.ts b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.spec.ts new file mode 100644 index 000000000..1cde4d324 --- /dev/null +++ b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ViewDuplicatesComponent } from './view-duplicates.component'; + +describe.skip('ViewForksComponent', () => { + let component: ViewDuplicatesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ViewDuplicatesComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ViewDuplicatesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.ts new file mode 100644 index 000000000..d58e3aa7c --- /dev/null +++ b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.ts @@ -0,0 +1,230 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DialogService } from 'primeng/dynamicdialog'; +import { Menu } from 'primeng/menu'; +import { PaginatorState } from 'primeng/paginator'; + +import { map, of } from 'rxjs'; + +import { DatePipe, NgClass } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + Signal, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; + +import { DeleteComponentDialogComponent, ForkDialogComponent } from '@osf/features/project/overview/components'; +import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { + ClearRegistryOverview, + GetRegistryById, + RegistryOverviewSelectors, +} from '@osf/features/registry/store/registry-overview'; +import { + CustomPaginatorComponent, + LoadingSpinnerComponent, + SubHeaderComponent, + TruncatedTextComponent, +} from '@shared/components'; +import { ResourceType, UserPermissions } from '@shared/enums'; +import { IS_SMALL } from '@shared/helpers'; +import { ToolbarResource } from '@shared/models'; +import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@shared/stores'; + +@Component({ + selector: 'osf-view-duplicates', + imports: [ + SubHeaderComponent, + TranslatePipe, + Button, + Menu, + TruncatedTextComponent, + NgClass, + DatePipe, + LoadingSpinnerComponent, + RouterLink, + CustomPaginatorComponent, + ], + templateUrl: './view-duplicates.component.html', + styleUrl: './view-duplicates.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService], +}) +export class ViewDuplicatesComponent { + private dialogService = inject(DialogService); + private translateService = inject(TranslateService); + private route = inject(ActivatedRoute); + private router = inject(Router); + private destroyRef = inject(DestroyRef); + protected currentPage = signal('1'); + protected isSmall = toSignal(inject(IS_SMALL)); + protected readonly pageSize = 10; + protected readonly UserPermissions = UserPermissions; + protected firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * this.pageSize); + private project = select(ProjectOverviewSelectors.getProject); + private registration = select(RegistryOverviewSelectors.getRegistry); + protected duplicates = select(DuplicatesSelectors.getDuplicates); + protected isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); + protected totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); + + protected readonly forkActionItems = (resourceId: string) => [ + { + label: 'project.overview.actions.manageContributors', + command: () => this.router.navigate(['/project', resourceId, 'contributors']), + }, + { + label: 'project.overview.actions.settings', + command: () => this.router.navigate(['/project', resourceId, 'settings']), + }, + { + label: 'project.overview.actions.delete', + command: () => this.handleDeleteFork(resourceId), + }, + ]; + + 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 currentResource = computed(() => { + const resourceType = this.resourceType(); + + if (resourceType) { + if (resourceType === ResourceType.Project) return this.project(); + + if (resourceType === ResourceType.Registration) return this.registration(); + } + + return null; + }); + + protected actions = createDispatchMap({ + getProject: GetProjectById, + getRegistration: GetRegistryById, + getDuplicates: GetAllDuplicates, + clearDuplicates: ClearDuplicates, + clearProject: ClearProjectOverview, + clearRegistration: ClearRegistryOverview, + }); + + constructor() { + effect(() => { + const resourceId = this.resourceId(); + const resourceType = this.resourceType(); + + if (resourceId) { + if (resourceType === ResourceType.Project) this.actions.getProject(resourceId); + if (resourceType === ResourceType.Registration) this.actions.getRegistration(resourceId); + } + }); + + effect(() => { + const resource = this.currentResource(); + + if (resource) { + this.actions.getDuplicates(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); + } + }); + + this.setupCleanup(); + } + + protected toolbarResource = computed(() => { + const resource = this.currentResource(); + const resourceType = this.resourceType(); + if (resource && resourceType) { + return { + id: resource.id, + isPublic: resource.isPublic, + storage: undefined, + viewOnlyLinksCount: 0, + forksCount: resource.forksCount, + resourceType: resourceType, + } as ToolbarResource; + } + return null; + }); + + protected handleForkResource(): void { + const toolbarResource = this.toolbarResource(); + const dialogWidth = !this.isSmall() ? '95vw' : '450px'; + + if (toolbarResource) { + const dialogRef = this.dialogService.open(ForkDialogComponent, { + width: dialogWidth, + focusOnShow: false, + header: this.translateService.instant('project.overview.dialog.fork.headerProject'), + closeOnEscape: true, + modal: true, + closable: true, + data: { + resource: toolbarResource, + resourceType: this.resourceType(), + }, + }); + + dialogRef.onClose.subscribe((result) => { + if (result.success) { + const resource = this.currentResource(); + if (resource) { + this.actions.getDuplicates(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); + } + } + }); + } + } + + onPageChange(event: PaginatorState): void { + if (event.page !== undefined) { + const pageNumber = (event.page + 1).toString(); + this.currentPage.set(pageNumber); + } + } + + setupCleanup(): void { + this.destroyRef.onDestroy(() => { + this.actions.clearDuplicates(); + this.actions.clearProject(); + this.actions.clearRegistration(); + }); + } + + private handleDeleteFork(id: string): void { + const dialogWidth = !this.isSmall() ? '95vw' : '650px'; + + const dialogRef = this.dialogService.open(DeleteComponentDialogComponent, { + width: dialogWidth, + focusOnShow: false, + header: this.translateService.instant('project.overview.dialog.deleteComponent.header'), + closeOnEscape: true, + modal: true, + closable: true, + data: { + componentId: id, + resourceType: this.resourceType(), + isForksContext: true, + currentPage: parseInt(this.currentPage()), + pageSize: this.pageSize, + }, + }); + + dialogRef.onClose.subscribe((result) => { + if (result && result.success) { + const resource = this.currentResource(); + if (resource) { + this.actions.getDuplicates(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); + } + } + }); + } +} diff --git a/src/app/features/project/analytics/store/analytics.actions.ts b/src/app/features/project/analytics/store/analytics.actions.ts index e61e07d96..a8e450bec 100644 --- a/src/app/features/project/analytics/store/analytics.actions.ts +++ b/src/app/features/project/analytics/store/analytics.actions.ts @@ -19,3 +19,7 @@ export class GetRelatedCounts { public resourceType: ResourceType | undefined ) {} } + +export class ClearAnalytics { + static readonly type = '[Analytics] Clear Analytics'; +} diff --git a/src/app/features/project/analytics/store/analytics.model.ts b/src/app/features/project/analytics/store/analytics.model.ts index 34794c27d..8f40c81ba 100644 --- a/src/app/features/project/analytics/store/analytics.model.ts +++ b/src/app/features/project/analytics/store/analytics.model.ts @@ -6,3 +6,16 @@ export interface AnalyticsStateModel { metrics: AsyncStateModel; relatedCounts: AsyncStateModel; } + +export const ANALYTICS_DEFAULT_STATE: AnalyticsStateModel = { + metrics: { + data: [], + isLoading: false, + error: '', + }, + relatedCounts: { + data: [], + isLoading: false, + error: '', + }, +}; diff --git a/src/app/features/project/analytics/store/analytics.state.ts b/src/app/features/project/analytics/store/analytics.state.ts index 7e61181ad..8c7e9c1e0 100644 --- a/src/app/features/project/analytics/store/analytics.state.ts +++ b/src/app/features/project/analytics/store/analytics.state.ts @@ -8,23 +8,12 @@ import { inject, Injectable } from '@angular/core'; import { AnalyticsMetricsModel, RelatedCountsModel } from '../models'; import { AnalyticsService } from '../services'; -import { GetMetrics, GetRelatedCounts } from './analytics.actions'; -import { AnalyticsStateModel } from './analytics.model'; +import { ClearAnalytics, GetMetrics, GetRelatedCounts } from './analytics.actions'; +import { ANALYTICS_DEFAULT_STATE, AnalyticsStateModel } from './analytics.model'; @State({ name: 'analytics', - defaults: { - metrics: { - data: [], - isLoading: false, - error: '', - }, - relatedCounts: { - data: [], - isLoading: false, - error: '', - }, - }, + defaults: ANALYTICS_DEFAULT_STATE, }) @Injectable() export class AnalyticsState { @@ -120,6 +109,11 @@ export class AnalyticsState { ); } + @Action(ClearAnalytics) + clearAnalytics(ctx: StateContext) { + ctx.patchState(ANALYTICS_DEFAULT_STATE); + } + private shouldRefresh(lastFetched: number | undefined): boolean { if (!lastFetched) { return true; diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts index 7cbfec6a6..5d1983256 100644 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts +++ b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts @@ -1,4 +1,4 @@ -import { select, Store } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -11,7 +11,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { DeleteComponent, GetComponents, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { RegistryOverviewSelectors } from '@osf/features/registry/store/registry-overview'; import { ScientistsNames } from '@osf/shared/constants'; +import { ResourceType } from '@shared/enums'; import { ToastService } from '@shared/services'; @Component({ @@ -22,14 +24,14 @@ import { ToastService } from '@shared/services'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeleteComponentDialogComponent { - private store = inject(Store); private dialogConfig = inject(DynamicDialogConfig); private toastService = inject(ToastService); protected dialogRef = inject(DynamicDialogRef); protected destroyRef = inject(DestroyRef); private componentId = signal(this.dialogConfig.data.componentId); protected scientistNames = ScientistsNames; - protected currentProject = select(ProjectOverviewSelectors.getProject); + protected project = select(ProjectOverviewSelectors.getProject); + protected registration = select(RegistryOverviewSelectors.getRegistry); protected isSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting); protected userInput = signal(''); protected selectedScientist = computed(() => { @@ -37,6 +39,23 @@ export class DeleteComponentDialogComponent { return names[Math.floor(Math.random() * names.length)]; }); + readonly currentResource = computed(() => { + const resourceType = this.dialogConfig.data.resourceType; + + if (resourceType) { + if (resourceType === ResourceType.Project) return this.project(); + + if (resourceType === ResourceType.Registration) return this.registration(); + } + + return null; + }); + + protected actions = createDispatchMap({ + getComponents: GetComponents, + deleteComponent: DeleteComponent, + }); + protected isInputValid(): boolean { return this.userInput() === this.selectedScientist(); } @@ -46,18 +65,25 @@ export class DeleteComponentDialogComponent { } protected handleDeleteComponent(): void { - const project = this.currentProject(); + const resource = this.currentResource(); const componentId = this.componentId(); - if (!componentId || !project) return; + if (!componentId || !resource) return; - this.store - .dispatch(new DeleteComponent(componentId)) + this.actions + .deleteComponent(componentId) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { - this.dialogRef.close(); - this.store.dispatch(new GetComponents(project.id)); + this.dialogRef.close({ success: true }); + + const isForksContext = this.dialogConfig.data.isForksContext; + + if (!isForksContext) { + this.actions.getComponents(resource.id); + } + }, + complete: () => { this.toastService.showSuccess('project.overview.dialog.toast.deleteComponent.success'); }, }); diff --git a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts index 3fb34d7be..39b72d2d3 100644 --- a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts +++ b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts @@ -30,11 +30,11 @@ export class DeleteNodeLinkDialogComponent { handleDeleteNodeLink(): void { const project = this.currentProject(); - const nodeLinkId = this.dialogConfig.data.nodeLinkId; + const currentLink = this.dialogConfig.data.currentLink; - if (!nodeLinkId || !project) return; + if (!currentLink || !project) return; - this.actions.deleteNodeLink(project.id, nodeLinkId).subscribe({ + this.actions.deleteNodeLink(project.id, currentLink).subscribe({ next: () => { this.dialogRef.close(); this.actions.getLinkedResources(project.id); diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html index 03cd469a4..04e346c56 100644 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html +++ b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html @@ -1,9 +1,5 @@

- @if (config.data.resource.resourceType === ResourceType.Project) { - {{ 'project.overview.dialog.fork.messageProject' | translate }} - } @else if (config.data.resource.resourceType === ResourceType.Registration) { - {{ 'project.overview.dialog.fork.messageRegistry' | translate }} - } + {{ 'project.overview.dialog.fork.messageProject' | translate }}

@@ -12,12 +8,12 @@ severity="info" (click)="dialogRef.close(false)" [disabled]="isSubmitting()" - class="btn-half-width" + class="btn-full-width" />
diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts index 2d4a1c2dc..1b17c315a 100644 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts +++ b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts @@ -39,7 +39,7 @@ export class ForkDialogComponent { .pipe( takeUntilDestroyed(this.destroyRef), finalize(() => { - this.dialogRef.close(); + this.dialogRef.close({ success: true }); this.toastService.showSuccess('project.overview.dialog.toast.fork.success'); }) ) diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html index d12a1701e..5fb99a496 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html @@ -32,7 +32,7 @@ > - @if (isCurrentTableLoading() || isNodeLinksLoading()) { + @if (isCurrentTableLoading()) { @@ -96,7 +96,7 @@ [inputId]="item.id" [ngModel]="isItemLinked()(item.id)" [binary]="true" - (onChange)="handleToggleNodeLink($event, item.id)" + (onChange)="handleToggleNodeLink($event, item)" /> {{ item.title }}

diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts index 618270b12..9b8cb87d0 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts @@ -28,11 +28,10 @@ import { FormControl, FormsModule } from '@angular/forms'; import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { SearchInputComponent } from '@shared/components'; import { ResourceSearchMode, ResourceType } from '@shared/enums'; -import { MyResourcesSearchFilters } from '@shared/models'; +import { MyResourcesItem, MyResourcesSearchFilters } from '@shared/models'; import { CreateNodeLink, DeleteNodeLink, - GetAllNodeLinks, GetLinkedResources, GetMyProjects, GetMyRegistrations, @@ -77,8 +76,7 @@ export class LinkResourceDialogComponent { protected totalProjectsCount = select(MyResourcesSelectors.getTotalProjects); protected totalRegistrationsCount = select(MyResourcesSelectors.getTotalRegistrations); protected isNodeLinksSubmitting = select(NodeLinksSelectors.getNodeLinksSubmitting); - protected isNodeLinksLoading = select(NodeLinksSelectors.getNodeLinksLoading); - protected nodeLinks = select(NodeLinksSelectors.getNodeLinks); + protected linkedResources = select(NodeLinksSelectors.getLinkedResources); protected currentTableItems = computed(() => { return this.resourceType() === ResourceType.Project ? this.myProjects() : this.myRegistrations(); @@ -93,8 +91,8 @@ export class LinkResourceDialogComponent { }); protected isItemLinked = computed(() => { - const nodeLinks = this.nodeLinks(); - const linkedTargetIds = new Set(nodeLinks.map((link) => link.targetNode.id)); + const linkedResources = this.linkedResources(); + const linkedTargetIds = new Set(linkedResources.map((resource) => resource.id)); return (itemId: string) => linkedTargetIds.has(itemId); }); @@ -103,7 +101,6 @@ export class LinkResourceDialogComponent { getProjects: GetMyProjects, getRegistrations: GetMyRegistrations, createNodeLink: CreateNodeLink, - getAllNodeLinks: GetAllNodeLinks, deleteNodeLink: DeleteNodeLink, getLinkedProjects: GetLinkedResources, }); @@ -141,7 +138,7 @@ export class LinkResourceDialogComponent { effect(() => { const currentProject = this.currentProject(); if (currentProject) { - this.actions.getAllNodeLinks(currentProject.id); + this.actions.getLinkedProjects(currentProject.id); } }); } @@ -167,24 +164,24 @@ export class LinkResourceDialogComponent { this.dialogRef.close(); } - handleToggleNodeLink($event: CheckboxChangeEvent, linkProjectId: string) { + handleToggleNodeLink($event: CheckboxChangeEvent, resource: MyResourcesItem) { const currentProjectId = this.currentProject()?.id; if (!currentProjectId) { return; } - const isCurrentlyLinked = this.isItemLinked()(linkProjectId); + const isCurrentlyLinked = this.isItemLinked()(resource.id); if (isCurrentlyLinked) { - const nodeLinks = this.nodeLinks(); - const linkToDelete = nodeLinks.find((link) => link.targetNode.id === linkProjectId); + const resources = this.linkedResources(); + const linkToDelete = resources.find((component) => component.id === resource.id); if (!linkToDelete) return; - this.actions.deleteNodeLink(currentProjectId, linkToDelete.id); + this.actions.deleteNodeLink(currentProjectId, linkToDelete); } else { - this.actions.createNodeLink(currentProjectId, linkProjectId); + this.actions.createNodeLink(currentProjectId, resource); } } diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts index 1484409cb..18ec6782c 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts @@ -31,7 +31,6 @@ export class LinkedResourcesComponent { protected linkedResources = select(NodeLinksSelectors.getLinkedResources); protected isLinkedResourcesLoading = select(NodeLinksSelectors.getLinkedResourcesLoading); protected isMobile = toSignal(inject(IS_XSMALL)); - protected nodeLinks = select(NodeLinksSelectors.getNodeLinks); openLinkProjectModal() { const dialogWidth = this.isMobile() ? '95vw' : '850px'; @@ -61,12 +60,12 @@ export class LinkedResourcesComponent { modal: true, closable: true, data: { - nodeLinkId: currentLink.id, + currentLink, }, }); } private getCurrentResourceNodeLink(resourceId: string) { - return this.nodeLinks().find((link) => link.targetNode.id === resourceId); + return this.linkedResources().find((resource) => resource.id === resourceId); } } diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html index f71a27543..18120dbfc 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html @@ -33,6 +33,9 @@

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

@case (SubmissionReviewStatus.Pending) { } + @case (SubmissionReviewStatus.InProgress) { + + } @case (SubmissionReviewStatus.Removed) { } diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.ts index 5c0850ea6..b023deaeb 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.ts +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.ts @@ -13,7 +13,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; import { TruncatedTextComponent } from '@osf/shared/components'; -import { UserPermissions } from '@osf/shared/enums'; +import { ResourceType, UserPermissions } from '@osf/shared/enums'; import { IS_XSMALL } from '@osf/shared/helpers'; import { ProjectOverviewSelectors } from '../../store'; @@ -77,6 +77,7 @@ export class OverviewComponentsComponent { closable: true, data: { componentId, + resourceType: ResourceType.Project, }, }); } diff --git a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html index c8e360b26..5668ddd7f 100644 --- a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html +++ b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html @@ -53,27 +53,24 @@ } - - {{ resource.forksCount }} - - - - @if ( - item.label !== 'project.overview.actions.duplicateProject' || - currentResource()?.resourceType !== ResourceType.Registration - ) { + @if (resource.resourceType === ResourceType.Project) { + + {{ resource.forksCount }} + + + {{ item.label | translate }} - } - - - + + + + } (false); protected isPublic = signal(false); protected isBookmarked = signal(false); @@ -70,7 +72,7 @@ export class OverviewToolbarComponent { protected readonly socialsActionItems = SOCIAL_ACTION_ITEMS; protected readonly forkActionItems = [ { - label: 'project.overview.actions.forkResource', + label: 'project.overview.actions.forkProject', command: () => this.handleForkResource(), }, { @@ -80,7 +82,7 @@ export class OverviewToolbarComponent { { label: 'project.overview.actions.viewDuplication', command: () => { - //TODO: RNa redirect to duplication page + this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); }, }, ]; @@ -116,19 +118,6 @@ export class OverviewToolbarComponent { this.isBookmarked.set(bookmarks.some((bookmark) => bookmark.id === resource.id)); }); - - effect(() => { - const resource = this.currentResource(); - - if (resource) { - this.forkActionItems[0].label = - this.currentResource()?.resourceType === ResourceType.Project - ? 'project.overview.actions.forkProject' - : this.currentResource()?.resourceType === ResourceType.Registration - ? 'project.overview.actions.forkRegistry' - : 'project.overview.actions.forkResource'; - } - }); } private handleForkResource(): void { diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 29407add9..8d9961a60 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -8,6 +8,7 @@ import { CitationsState, CollectionsState, ContributorsState, + DuplicatesState, NodeLinksState, SubjectsState, ViewOnlyLinkState, @@ -70,6 +71,15 @@ export const projectRoutes: Routes = [ data: { resourceType: ResourceType.Project }, providers: [provideStates([AnalyticsState])], }, + { + path: 'analytics/duplicates', + data: { resourceType: ResourceType.Project }, + loadComponent: () => + import('../project/analytics/components/view-duplicates/view-duplicates.component').then( + (mod) => mod.ViewDuplicatesComponent + ), + providers: [provideStates([DuplicatesState])], + }, { path: 'wiki', loadComponent: () => import('../project/wiki/wiki.component').then((mod) => mod.WikiComponent), diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index 8ea5de485..0a0fd0d58 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -8,7 +8,7 @@ import { RegistryLinksState } from '@osf/features/registry/store/registry-links' import { RegistryMetadataState } from '@osf/features/registry/store/registry-metadata'; import { RegistryOverviewState } from '@osf/features/registry/store/registry-overview'; import { ResourceType } from '@osf/shared/enums'; -import { ContributorsState, ViewOnlyLinkState } from '@osf/shared/stores'; +import { ContributorsState, DuplicatesState, ViewOnlyLinkState } from '@osf/shared/stores'; import { AnalyticsState } from '../project/analytics/store'; @@ -70,6 +70,15 @@ export const registryRoutes: Routes = [ data: { resourceType: ResourceType.Registration }, providers: [provideStates([AnalyticsState])], }, + { + path: 'analytics/duplicates', + data: { resourceType: ResourceType.Registration }, + loadComponent: () => + import('../project/analytics/components/view-duplicates/view-duplicates.component').then( + (mod) => mod.ViewDuplicatesComponent + ), + providers: [provideStates([DuplicatesState])], + }, { path: 'files', loadComponent: () => diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index 8c102d8a9..900abd0e7 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -44,7 +44,7 @@ export class RegistryOverviewService { }; return this.jsonApiService - .get(`${environment.apiUrl}/registrations/${id}`, params) + .get(`${environment.apiUrl}/registrations/${id}/`, params) .pipe(map((response) => MapRegistryOverview(response.data))); } 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 ba711ace7..99d42c047 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 @@ -66,3 +66,7 @@ export class SubmitDecision { public isRevision: boolean ) {} } + +export class ClearRegistryOverview { + static readonly type = '[Registry Overview] Clear Registry Overview'; +} 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 66dae4056..8fc118972 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 @@ -12,6 +12,7 @@ import { handleSectionError } from '@osf/shared/helpers'; import { RegistryOverviewService } from '../../services'; import { + ClearRegistryOverview, GetRegistryById, GetRegistryInstitutions, GetRegistryReviewActions, @@ -267,4 +268,9 @@ export class RegistryOverviewState { catchError((error) => handleSectionError(ctx, 'moderationActions', error)) ); } + + @Action(ClearRegistryOverview) + clearRegistryOverview(ctx: StateContext) { + ctx.patchState(REGISTRY_OVERVIEW_DEFAULTS); + } } diff --git a/src/app/shared/components/resource-citations/resource-citations.component.ts b/src/app/shared/components/resource-citations/resource-citations.component.ts index 0a140f8a7..718f3c5ea 100644 --- a/src/app/shared/components/resource-citations/resource-citations.component.ts +++ b/src/app/shared/components/resource-citations/resource-citations.component.ts @@ -29,6 +29,7 @@ import { CitationStyle, CustomOption, ResourceOverview } from '@shared/models'; import { ToastService } from '@shared/services'; import { CitationsSelectors, + ClearStyledCitation, GetCitationStyles, GetDefaultCitations, GetStyledCitation, @@ -85,6 +86,7 @@ export class ResourceCitationsComponent { getCitationStyles: GetCitationStyles, getStyledCitation: GetStyledCitation, updateCustomCitation: UpdateCustomCitation, + clearStyledCitation: ClearStyledCitation, }); constructor() { @@ -162,6 +164,9 @@ export class ResourceCitationsComponent { } protected toggleEditMode(): void { + if (this.styledCitation()) { + this.actions.clearStyledCitation(); + } this.isEditMode.set(!this.isEditMode()); } diff --git a/src/app/shared/constants/fork-action-items.const.ts b/src/app/shared/constants/fork-action-items.const.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/mappers/duplicates.mapper.ts b/src/app/shared/mappers/duplicates.mapper.ts new file mode 100644 index 000000000..a082823a9 --- /dev/null +++ b/src/app/shared/mappers/duplicates.mapper.ts @@ -0,0 +1,29 @@ +import { ResponseJsonApi } from '@shared/models'; + +import { DuplicateJsonApi, DuplicatesWithTotal } from 'src/app/shared/models/duplicates'; + +export class DuplicatesMapper { + static fromDuplicatesJsonApiResponse(response: ResponseJsonApi): DuplicatesWithTotal { + return { + data: response.data.map((duplicate) => ({ + id: duplicate.id, + type: duplicate.type, + title: duplicate.attributes.title, + description: duplicate.attributes.description, + dateCreated: duplicate.attributes.forked_date, + dateModified: duplicate.attributes.date_modified, + public: duplicate.attributes.public, + currentUserPermissions: duplicate.attributes.current_user_permissions, + contributors: duplicate.embeds.bibliographic_contributors.data.map((contributor) => ({ + familyName: contributor.embeds.users.data.attributes.family_name, + fullName: contributor.embeds.users.data.attributes.full_name, + givenName: contributor.embeds.users.data.attributes.given_name, + middleName: contributor.embeds.users.data.attributes.middle_name, + id: contributor.embeds.users.data.id, + type: contributor.embeds.users.data.type, + })), + })), + totalCount: response.meta.total, + }; + } +} diff --git a/src/app/shared/mappers/index.ts b/src/app/shared/mappers/index.ts index 78e6ff767..d80702797 100644 --- a/src/app/shared/mappers/index.ts +++ b/src/app/shared/mappers/index.ts @@ -3,10 +3,10 @@ export * from './citations.mapper'; export * from './collections'; export * from './components'; export * from './contributors'; +export * from './duplicates.mapper'; export * from './filters'; export * from './institutions'; export * from './licenses.mapper'; -export * from './node-links'; export * from './registry'; export * from './resource-card'; export * from './resource-overview.mappers'; diff --git a/src/app/shared/mappers/node-links/index.ts b/src/app/shared/mappers/node-links/index.ts deleted file mode 100644 index 7feb2c976..000000000 --- a/src/app/shared/mappers/node-links/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './node-links.mapper'; diff --git a/src/app/shared/mappers/node-links/node-links.mapper.ts b/src/app/shared/mappers/node-links/node-links.mapper.ts deleted file mode 100644 index ede7b6176..000000000 --- a/src/app/shared/mappers/node-links/node-links.mapper.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NodeLink, NodeLinkJsonApi } from '@shared/models/node-links'; - -export class NodeLinksMapper { - static fromNodeLinkResponse(response: NodeLinkJsonApi): NodeLink { - return { - type: response.type, - id: response.id, - targetNode: { - type: response.relationships.target_node.data.type, - id: response.relationships.target_node.data.id, - }, - }; - } -} diff --git a/src/app/shared/models/duplicates/duplicate-json-api.model.ts b/src/app/shared/models/duplicates/duplicate-json-api.model.ts new file mode 100644 index 000000000..80a92af54 --- /dev/null +++ b/src/app/shared/models/duplicates/duplicate-json-api.model.ts @@ -0,0 +1,32 @@ +export interface DuplicateJsonApi { + id: string; + type: string; + attributes: { + title: string; + forked_date: string; + date_modified: string; + description: string; + public: boolean; + current_user_permissions: string[]; + }; + embeds: { + bibliographic_contributors: { + data: { + embeds: { + users: { + data: { + id: string; + type: string; + attributes: { + family_name: string; + full_name: string; + given_name: string; + middle_name: string; + }; + }; + }; + }; + }[]; + }; + }; +} diff --git a/src/app/shared/models/duplicates/duplicate.model.ts b/src/app/shared/models/duplicates/duplicate.model.ts new file mode 100644 index 000000000..ebf28e551 --- /dev/null +++ b/src/app/shared/models/duplicates/duplicate.model.ts @@ -0,0 +1,23 @@ +export interface Duplicate { + id: string; + type: string; + title: string; + description: string; + dateCreated: string; + dateModified: string; + public: boolean; + currentUserPermissions: string[]; + contributors: { + familyName: string; + fullName: string; + givenName: string; + middleName: string; + id: string; + type: string; + }[]; +} + +export interface DuplicatesWithTotal { + data: Duplicate[]; + totalCount: number; +} diff --git a/src/app/shared/models/duplicates/index.ts b/src/app/shared/models/duplicates/index.ts new file mode 100644 index 000000000..7702deeb4 --- /dev/null +++ b/src/app/shared/models/duplicates/index.ts @@ -0,0 +1,2 @@ +export * from './duplicate.model'; +export * from './duplicate-json-api.model'; diff --git a/src/app/shared/services/duplicates.service.ts b/src/app/shared/services/duplicates.service.ts new file mode 100644 index 000000000..2e74271e4 --- /dev/null +++ b/src/app/shared/services/duplicates.service.ts @@ -0,0 +1,42 @@ +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { DuplicatesMapper } from '@shared/mappers'; +import { ResponseJsonApi } from '@shared/models'; +import { JsonApiService } from '@shared/services/json-api.service'; + +import { DuplicateJsonApi, DuplicatesWithTotal } from 'src/app/shared/models/duplicates'; +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class DuplicatesService { + private jsonApiService = inject(JsonApiService); + + fetchAllDuplicates( + resourceId: string, + resourceType: string, + pageNumber?: number, + pageSize?: number + ): Observable { + const params: Record = { + embed: 'bibliographic_contributors', + 'fields[users]': 'family_name,full_name,given_name,middle_name', + }; + + if (pageNumber) { + params['page'] = pageNumber; + } + + if (pageSize) { + params['page[size]'] = pageSize; + } + + return this.jsonApiService + .get>(`${environment.apiUrl}/${resourceType}/${resourceId}/forks/`, params) + .pipe(map((res) => DuplicatesMapper.fromDuplicatesJsonApiResponse(res))); + } +} diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index 459c3b14f..41ca37cad 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -4,6 +4,7 @@ export { BrandService } from './brand.service'; export { CollectionsService } from './collections.service'; export { ContributorsService } from './contributors.service'; export { CustomConfirmationService } from './custom-confirmation.service'; +export { DuplicatesService } from './duplicates.service'; export { FilesService } from './files.service'; export { FiltersOptionsService } from './filters-options.service'; export { InstitutionsService } from './institutions.service'; diff --git a/src/app/shared/services/node-links.service.ts b/src/app/shared/services/node-links.service.ts index 89ade54e2..b4e895474 100644 --- a/src/app/shared/services/node-links.service.ts +++ b/src/app/shared/services/node-links.service.ts @@ -4,10 +4,9 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@osf/shared/services'; -import { NodeLinksMapper } from '@shared/mappers'; import { ComponentsMapper } from '@shared/mappers/components'; -import { ComponentGetResponseJsonApi, ComponentOverview, JsonApiResponse } from '@shared/models'; -import { NodeLink, NodeLinkJsonApi } from '@shared/models/node-links'; +import { ComponentGetResponseJsonApi, ComponentOverview, JsonApiResponse, MyResourcesItem } from '@shared/models'; +import { NodeLinkJsonApi } from '@shared/models/node-links'; import { environment } from 'src/environments/environment'; @@ -17,44 +16,39 @@ import { environment } from 'src/environments/environment'; export class NodeLinksService { jsonApiService = inject(JsonApiService); - createNodeLink(currentProjectId: string, linkProjectId: string): Observable { + createNodeLink( + currentProjectId: string, + resource: MyResourcesItem + ): Observable> { const payload = { - data: { - type: 'node_links', - relationships: { - nodes: { - data: { - type: 'nodes', - id: linkProjectId, - }, - }, + data: [ + { + type: resource.type, + id: resource.id, }, - }, + ], }; - return this.jsonApiService - .post< - JsonApiResponse - >(`${environment.apiUrl}/nodes/${currentProjectId}/node_links/`, payload) - .pipe( - map((response) => { - return NodeLinksMapper.fromNodeLinkResponse(response.data); - }) - ); + return this.jsonApiService.post>( + `${environment.apiUrl}/nodes/${currentProjectId}/relationships/linked_${resource.type}/`, + payload + ); } - fetchAllNodeLinks(projectId: string): Observable { - const params: Record = { - 'fields[nodes]': 'relationships', + deleteNodeLink(projectId: string, resource: ComponentOverview): Observable { + const payload = { + data: [ + { + type: resource.type, + id: resource.id, + }, + ], }; - return this.jsonApiService - .get>(`${environment.apiUrl}/nodes/${projectId}/linked_nodes/`, params) - .pipe(map((response) => response.data.map((item) => NodeLinksMapper.fromNodeLinkResponse(item)))); - } - - deleteNodeLink(projectId: string, nodeLinkId: string): Observable { - return this.jsonApiService.delete(`${environment.apiUrl}/nodes/${projectId}/node_links/${nodeLinkId}/`); + return this.jsonApiService.delete( + `${environment.apiUrl}/nodes/${projectId}/relationships/linked_${resource.type}/`, + payload + ); } fetchLinkedProjects(projectId: string): Observable { @@ -83,7 +77,7 @@ export class NodeLinksService { return this.jsonApiService .get< JsonApiResponse - >(`${environment.apiUrl}/nodes/${projectId}/linked_registrations`, params) + >(`${environment.apiUrl}/nodes/${projectId}/linked_registrations/`, params) .pipe( map((response) => { return response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)); diff --git a/src/app/shared/stores/citations/citations.actions.ts b/src/app/shared/stores/citations/citations.actions.ts index 2cc1a6001..170fca665 100644 --- a/src/app/shared/stores/citations/citations.actions.ts +++ b/src/app/shared/stores/citations/citations.actions.ts @@ -31,3 +31,7 @@ export class GetStyledCitation { public citationStyle: string ) {} } + +export class ClearStyledCitation { + static readonly type = '[Citations] Clear Styled Citation'; +} diff --git a/src/app/shared/stores/citations/citations.state.ts b/src/app/shared/stores/citations/citations.state.ts index 830072645..54a67a9d9 100644 --- a/src/app/shared/stores/citations/citations.state.ts +++ b/src/app/shared/stores/citations/citations.state.ts @@ -8,7 +8,13 @@ import { CitationTypes } from '@shared/enums'; import { handleSectionError } from '@shared/helpers'; import { CitationsService } from '@shared/services/citations.service'; -import { GetCitationStyles, GetDefaultCitations, GetStyledCitation, UpdateCustomCitation } from './citations.actions'; +import { + ClearStyledCitation, + GetCitationStyles, + GetDefaultCitations, + GetStyledCitation, + UpdateCustomCitation, +} from './citations.actions'; import { CitationsStateModel } from './citations.model'; const CITATIONS_DEFAULTS: CitationsStateModel = { @@ -152,4 +158,15 @@ export class CitationsState { catchError((error) => handleSectionError(ctx, 'styledCitation', error)) ); } + + @Action(ClearStyledCitation) + clearStyledCitation(ctx: StateContext) { + const state = ctx.getState(); + ctx.patchState({ + styledCitation: { + ...state.styledCitation, + data: null, + }, + }); + } } diff --git a/src/app/shared/stores/duplicates/duplicates.actions.ts b/src/app/shared/stores/duplicates/duplicates.actions.ts new file mode 100644 index 000000000..4bcf560da --- /dev/null +++ b/src/app/shared/stores/duplicates/duplicates.actions.ts @@ -0,0 +1,14 @@ +export class GetAllDuplicates { + static readonly type = '[Forks] Get All Duplicates'; + + constructor( + public resourceId: string, + public resourceType: string, + public page: number, + public pageSize: number + ) {} +} + +export class ClearDuplicates { + static readonly type = '[Forks] Clear Duplicates'; +} diff --git a/src/app/shared/stores/duplicates/duplicates.model.ts b/src/app/shared/stores/duplicates/duplicates.model.ts new file mode 100644 index 000000000..ae0951a5b --- /dev/null +++ b/src/app/shared/stores/duplicates/duplicates.model.ts @@ -0,0 +1,17 @@ +import { AsyncStateWithTotalCount } from '@osf/shared/models'; + +import { Duplicate } from 'src/app/shared/models/duplicates'; + +export interface DuplicatesStateModel { + duplicates: AsyncStateWithTotalCount; +} + +export const DUPLICATES_DEFAULTS: DuplicatesStateModel = { + duplicates: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + totalCount: 0, + }, +}; diff --git a/src/app/shared/stores/duplicates/duplicates.selectors.ts b/src/app/shared/stores/duplicates/duplicates.selectors.ts new file mode 100644 index 000000000..67446a676 --- /dev/null +++ b/src/app/shared/stores/duplicates/duplicates.selectors.ts @@ -0,0 +1,21 @@ +import { Selector } from '@ngxs/store'; + +import { DuplicatesStateModel } from './duplicates.model'; +import { DuplicatesState } from './duplicates.state'; + +export class DuplicatesSelectors { + @Selector([DuplicatesState]) + static getDuplicates(state: DuplicatesStateModel) { + return state.duplicates.data; + } + + @Selector([DuplicatesState]) + static getDuplicatesLoading(state: DuplicatesStateModel) { + return state.duplicates.isLoading; + } + + @Selector([DuplicatesState]) + static getDuplicatesTotalCount(state: DuplicatesStateModel) { + return state.duplicates.totalCount; + } +} diff --git a/src/app/shared/stores/duplicates/duplicates.state.ts b/src/app/shared/stores/duplicates/duplicates.state.ts new file mode 100644 index 000000000..e34f64f09 --- /dev/null +++ b/src/app/shared/stores/duplicates/duplicates.state.ts @@ -0,0 +1,52 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { catchError, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@shared/helpers'; +import { DuplicatesService } from '@shared/services/duplicates.service'; + +import { ClearDuplicates, GetAllDuplicates } from './duplicates.actions'; +import { DUPLICATES_DEFAULTS, DuplicatesStateModel } from './duplicates.model'; + +@State({ + name: 'duplicates', + defaults: DUPLICATES_DEFAULTS, +}) +@Injectable() +export class DuplicatesState { + duplicatesService = inject(DuplicatesService); + + @Action(GetAllDuplicates) + getDuplicates(ctx: StateContext, action: GetAllDuplicates) { + const state = ctx.getState(); + ctx.patchState({ + duplicates: { + ...state.duplicates, + isLoading: true, + }, + }); + + return this.duplicatesService + .fetchAllDuplicates(action.resourceId, action.resourceType, action.page, action.pageSize) + .pipe( + tap((response) => { + ctx.patchState({ + duplicates: { + data: response.data, + isLoading: false, + error: null, + totalCount: response.totalCount, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'duplicates', error)) + ); + } + + @Action(ClearDuplicates) + clearDuplicates(ctx: StateContext) { + ctx.patchState(DUPLICATES_DEFAULTS); + } +} diff --git a/src/app/shared/stores/duplicates/index.ts b/src/app/shared/stores/duplicates/index.ts new file mode 100644 index 000000000..7d2822569 --- /dev/null +++ b/src/app/shared/stores/duplicates/index.ts @@ -0,0 +1,4 @@ +export * from './duplicates.actions'; +export * from './duplicates.model'; +export * from './duplicates.selectors'; +export * from './duplicates.state'; diff --git a/src/app/shared/stores/index.ts b/src/app/shared/stores/index.ts index 2350f9cb5..66f706c5b 100644 --- a/src/app/shared/stores/index.ts +++ b/src/app/shared/stores/index.ts @@ -3,12 +3,14 @@ export * from './bookmarks'; export * from './citations'; export * from './collections'; export * from './contributors'; +export * from './duplicates'; export * from './institutions'; export * from './institutions-search'; export * from './licenses'; export * from './my-resources'; export * from './node-links'; export * from './projects'; +export * from './regions'; export * from './subjects'; export * from './view-only-links'; export * from './wiki'; diff --git a/src/app/shared/stores/node-links/node-links.actions.ts b/src/app/shared/stores/node-links/node-links.actions.ts index 52aa0e62f..7430983d6 100644 --- a/src/app/shared/stores/node-links/node-links.actions.ts +++ b/src/app/shared/stores/node-links/node-links.actions.ts @@ -1,18 +1,14 @@ +import { ComponentOverview, MyResourcesItem } from '@shared/models'; + export class CreateNodeLink { static readonly type = '[Node Links] Create Node Link'; constructor( public currentProjectId: string, - public linkProjectId: string + public resource: MyResourcesItem ) {} } -export class GetAllNodeLinks { - static readonly type = '[Node Links] Get All Node Links'; - - constructor(public projectId: string) {} -} - export class GetLinkedResources { static readonly type = '[Node Links] Get Linked Resources'; @@ -24,7 +20,7 @@ export class DeleteNodeLink { constructor( public projectId: string, - public nodeLinkId: string + public linkedResource: ComponentOverview ) {} } diff --git a/src/app/shared/stores/node-links/node-links.model.ts b/src/app/shared/stores/node-links/node-links.model.ts index f6f6eaf6a..91208eb02 100644 --- a/src/app/shared/stores/node-links/node-links.model.ts +++ b/src/app/shared/stores/node-links/node-links.model.ts @@ -1,18 +1,10 @@ import { AsyncStateModel, ComponentOverview } from '@osf/shared/models'; -import { NodeLink } from '@osf/shared/models/node-links'; export interface NodeLinksStateModel { - nodeLinks: AsyncStateModel; linkedResources: AsyncStateModel; } export const NODE_LINKS_DEFAULTS: NodeLinksStateModel = { - nodeLinks: { - data: [], - isLoading: false, - isSubmitting: false, - error: null, - }, linkedResources: { data: [], isLoading: false, diff --git a/src/app/shared/stores/node-links/node-links.selectors.ts b/src/app/shared/stores/node-links/node-links.selectors.ts index 731bc5155..1a6bdfd3d 100644 --- a/src/app/shared/stores/node-links/node-links.selectors.ts +++ b/src/app/shared/stores/node-links/node-links.selectors.ts @@ -4,19 +4,9 @@ import { NodeLinksStateModel } from './node-links.model'; import { NodeLinksState } from './node-links.state'; export class NodeLinksSelectors { - @Selector([NodeLinksState]) - static getNodeLinks(state: NodeLinksStateModel) { - return state.nodeLinks.data; - } - - @Selector([NodeLinksState]) - static getNodeLinksLoading(state: NodeLinksStateModel) { - return state.nodeLinks.isLoading; - } - @Selector([NodeLinksState]) static getNodeLinksSubmitting(state: NodeLinksStateModel) { - return state.nodeLinks.isSubmitting || false; + return state.linkedResources.isSubmitting || false; } @Selector([NodeLinksState]) diff --git a/src/app/shared/stores/node-links/node-links.state.ts b/src/app/shared/stores/node-links/node-links.state.ts index aba25b716..4dc0f5383 100644 --- a/src/app/shared/stores/node-links/node-links.state.ts +++ b/src/app/shared/stores/node-links/node-links.state.ts @@ -6,13 +6,7 @@ import { inject, Injectable } from '@angular/core'; import { NodeLinksService } from '@osf/shared/services'; -import { - ClearNodeLinks, - CreateNodeLink, - DeleteNodeLink, - GetAllNodeLinks, - GetLinkedResources, -} from './node-links.actions'; +import { ClearNodeLinks, CreateNodeLink, DeleteNodeLink, GetLinkedResources } from './node-links.actions'; import { NODE_LINKS_DEFAULTS, NodeLinksStateModel } from './node-links.model'; @State({ @@ -27,49 +21,22 @@ export class NodeLinksState { createNodeLink(ctx: StateContext, action: CreateNodeLink) { const state = ctx.getState(); ctx.patchState({ - nodeLinks: { - ...state.nodeLinks, + linkedResources: { + ...state.linkedResources, isSubmitting: true, }, }); - return this.nodeLinksService.createNodeLink(action.currentProjectId, action.linkProjectId).pipe( - tap((nodeLink) => { - ctx.patchState({ - nodeLinks: { - data: [...state.nodeLinks.data, nodeLink], - isSubmitting: false, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => this.handleError(ctx, 'nodeLinks', error)) - ); - } - - @Action(GetAllNodeLinks) - getAllNodeLinks(ctx: StateContext, action: GetAllNodeLinks) { - const state = ctx.getState(); - ctx.patchState({ - nodeLinks: { - ...state.nodeLinks, - isLoading: true, - }, - }); - - return this.nodeLinksService.fetchAllNodeLinks(action.projectId).pipe( - tap((nodeLinks) => { + return this.nodeLinksService.createNodeLink(action.currentProjectId, action.resource).pipe( + tap(() => { ctx.patchState({ - nodeLinks: { - data: nodeLinks, - isLoading: false, + linkedResources: { + ...state.linkedResources, isSubmitting: false, - error: null, }, }); }), - catchError((error) => this.handleError(ctx, 'nodeLinks', error)) + catchError((error) => this.handleError(ctx, 'linkedResources', error)) ); } @@ -106,18 +73,20 @@ export class NodeLinksState { const state = ctx.getState(); ctx.patchState({ - nodeLinks: { - ...state.nodeLinks, + linkedResources: { + ...state.linkedResources, isSubmitting: true, }, }); - return this.nodeLinksService.deleteNodeLink(action.projectId, action.nodeLinkId).pipe( + return this.nodeLinksService.deleteNodeLink(action.projectId, action.linkedResource).pipe( tap(() => { - const updatedNodeLinks = state.nodeLinks.data.filter((link) => link.id !== action.nodeLinkId); + const updatedResources = state.linkedResources.data.filter( + (resource) => resource.id !== action.linkedResource.id + ); ctx.patchState({ - nodeLinks: { - data: updatedNodeLinks, + linkedResources: { + data: updatedResources, isLoading: false, isSubmitting: false, error: null, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 9ce8b64e5..ed151ff90 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -100,6 +100,9 @@ "and": "and", "more": "more", "data": "Data", + "forked": "Forked", + "lastUpdated": "Last Updated", + "contributors": "Contributors", "updated": "Updated", "dateUpdated": "Date Updated", "dateCreated": "Date Created" @@ -445,10 +448,10 @@ "project": { "analytics": { "kpi": { - "forks": "Forks", + "forks": "Duplicates", "linksToThisProject": "Links to this project", "templateCopies": "Template copies", - "viewForks": "View forks", + "viewForks": "View duplicates", "viewLinks": "View links" }, "charts": { @@ -622,7 +625,7 @@ "header": "Make Project Private", "confirmButton": "Make Private", "cancelButton": "Cancel", - "message": "
  • • Public forks and registrations of this project will remain public.
  • • Search engines (including Google's cache) or others may have accessed files, wiki pages, or analytics while this project was public.
  • • The project will automatically be removed from any collections. Any pending requests will be cancelled.

" + "message": "
  • • Public duplicates and registrations of this project will remain public.
  • • Search engines (including Google's cache) or others may have accessed files, wiki pages, or analytics while this project was public.
  • • The project will automatically be removed from any collections. Any pending requests will be cancelled.

" }, "toast": { "makePublic": { @@ -641,7 +644,7 @@ "success": "Node link has been deleted successfully" }, "fork": { - "success": "Project has been forked successfully" + "success": "Project has been duplicated successfully" }, "duplicate": { "success": "Project has been duplicated successfully" @@ -698,12 +701,14 @@ "message": "Are you sure you want to delete this link? This will not remove the project or registration this link refers to." }, "fork": { - "headerProject": "Fork This Project", - "headerRegistry": "Fork This Registry", - "confirmButton": "Fork", + "headerProject": "Duplicate This Project", + "headerRegistry": "Duplicate This Registry", + "forksMessage": "Duplicates you have permission to view are shown here.", + "noForksMessage": "No duplicates found", + "confirmButton": "Duplicate", "cancelButton": "Cancel", - "messageProject": "Are you sure you want to fork this project?", - "messageRegistry": "Are you sure you want to fork this registry?" + "messageProject": "Are you sure you want to duplicate this project (with files)?", + "messageRegistry": "Are you sure you want to duplicate this registry (with files)?" }, "duplicate": { "header": "Duplicate Template", @@ -717,11 +722,12 @@ "manageContributors": "Manage Contributors", "settings": "Settings", "delete": "Delete", - "forkProject": "Fork this project", - "forkRegistry": "Fork this registry", + "forkProject": "Duplicate project (with files)", + "forkProjectLabel": "Duplicate project", + "forkRegistry": "Duplicate this registration", "forkResource": "Fork this resource", - "duplicateProject": "Duplicate this project", - "viewDuplication": "View forks", + "duplicateProject": "Duplicate project (structure only)", + "viewDuplication": "View duplicates", "socials": { "email": "Email", "x": "X",