From e4e471c2da7ca13f5c9b8b4f52d0e68298af9e4d Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Wed, 10 Sep 2025 15:42:33 +0300 Subject: [PATCH 01/21] fix(add-project-redirect-modal): added project redirect modal --- .../pages/dashboard/dashboard.component.ts | 24 ++++++++++++------- .../create-project-dialog.component.ts | 7 ++++-- .../my-projects/my-projects.component.ts | 24 ++++++++++++------- .../models/confirmation-options.model.ts | 1 + .../services/custom-confirmation.service.ts | 2 +- src/app/shared/services/index.ts | 1 + .../project-redirect-dialog.service.ts | 23 ++++++++++++++++++ src/assets/i18n/en.json | 6 +++++ 8 files changed, 69 insertions(+), 19 deletions(-) create mode 100644 src/app/shared/services/project-redirect-dialog.service.ts diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index 989fc28a9..d8f29e5ab 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -20,6 +20,7 @@ import { MY_PROJECTS_TABLE_PARAMS } from '@osf/shared/constants'; import { SortOrder } from '@osf/shared/enums'; import { IS_MEDIUM } from '@osf/shared/helpers'; import { MyResourcesItem, MyResourcesSearchFilters, TableParameters } from '@osf/shared/models'; +import { ProjectRedirectDialogService } from '@osf/shared/services'; import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores'; @Component({ @@ -35,6 +36,7 @@ export class DashboardComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly translateService = inject(TranslateService); private readonly dialogService = inject(DialogService); + private readonly projectRedirectDialogService = inject(ProjectRedirectDialogService); readonly isMedium = toSignal(inject(IS_MEDIUM)); @@ -175,13 +177,19 @@ export class DashboardComponent implements OnInit { createProject(): void { const dialogWidth = this.isMedium() ? '850px' : '95vw'; - this.dialogService.open(CreateProjectDialogComponent, { - width: dialogWidth, - focusOnShow: false, - header: this.translateService.instant('myProjects.header.createProject'), - closeOnEscape: true, - modal: true, - closable: true, - }); + this.dialogService + .open(CreateProjectDialogComponent, { + width: dialogWidth, + focusOnShow: false, + header: this.translateService.instant('myProjects.header.createProject'), + closeOnEscape: true, + modal: true, + closable: true, + }) + .onClose.subscribe((result) => { + if (result.project.id) { + this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id); + } + }); } } diff --git a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts index 0e967d8f4..d9a1d4fa3 100644 --- a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts +++ b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts @@ -1,4 +1,4 @@ -import { createDispatchMap, select } from '@ngxs/store'; +import { createDispatchMap, select, Store } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -24,6 +24,7 @@ import { CreateProject, GetMyProjects, MyResourcesSelectors } from '@osf/shared/ }) export class CreateProjectDialogComponent { readonly dialogRef = inject(DynamicDialogRef); + private readonly store = inject(Store); private actions = createDispatchMap({ getMyProjects: GetMyProjects, @@ -70,8 +71,10 @@ export class CreateProjectDialogComponent { ) .subscribe({ next: () => { + const projects = this.store.selectSnapshot(MyResourcesSelectors.getProjects); + const newProject = projects[0]; this.actions.getMyProjects(1, MY_PROJECTS_TABLE_PARAMS.rows, {}); - this.dialogRef.close(); + this.dialogRef.close({ project: newProject }); }, }); } diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 8c09683b5..7542155bf 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -38,6 +38,7 @@ import { GetMyRegistrations, MyResourcesSelectors, } from '@osf/shared/stores'; +import { ProjectRedirectDialogService } from '@shared/services'; import { CreateProjectDialogComponent } from './components'; import { MY_PROJECTS_TABS } from './constants'; @@ -68,6 +69,7 @@ export class MyProjectsComponent implements OnInit { readonly router = inject(Router); readonly route = inject(ActivatedRoute); readonly translateService = inject(TranslateService); + readonly projectRedirectDialogService = inject(ProjectRedirectDialogService); readonly isLoading = signal(false); readonly isTablet = toSignal(inject(IS_MEDIUM)); @@ -326,14 +328,20 @@ export class MyProjectsComponent implements OnInit { createProject(): void { const dialogWidth = this.isTablet() ? '850px' : '95vw'; - this.dialogService.open(CreateProjectDialogComponent, { - width: dialogWidth, - focusOnShow: false, - header: this.translateService.instant('myProjects.header.createProject'), - closeOnEscape: true, - modal: true, - closable: true, - }); + this.dialogService + .open(CreateProjectDialogComponent, { + width: dialogWidth, + focusOnShow: false, + header: this.translateService.instant('myProjects.header.createProject'), + closeOnEscape: true, + modal: true, + closable: true, + }) + .onClose.subscribe((result) => { + if (result.project && result.project.id) { + this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id); + } + }); } navigateToProject(project: MyResourcesItem): void { diff --git a/src/app/shared/models/confirmation-options.model.ts b/src/app/shared/models/confirmation-options.model.ts index 8afbb57e5..03cb05c8f 100644 --- a/src/app/shared/models/confirmation-options.model.ts +++ b/src/app/shared/models/confirmation-options.model.ts @@ -18,6 +18,7 @@ export interface AcceptConfirmationOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any messageParams?: any; acceptLabelKey?: string; + rejectLabelKey?: string; onConfirm: () => void; onReject?: () => void; } diff --git a/src/app/shared/services/custom-confirmation.service.ts b/src/app/shared/services/custom-confirmation.service.ts index 51451e050..cd92fe5e3 100644 --- a/src/app/shared/services/custom-confirmation.service.ts +++ b/src/app/shared/services/custom-confirmation.service.ts @@ -43,7 +43,7 @@ export class CustomConfirmationService { label: this.translateService.instant(options.acceptLabelKey || 'common.buttons.move'), }, rejectButtonProps: { - label: this.translateService.instant('common.buttons.cancel'), + label: this.translateService.instant(options.rejectLabelKey || 'common.buttons.cancel'), severity: 'info', }, accept: () => { diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index 29694a143..66362a18d 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -15,6 +15,7 @@ export { LoaderService } from './loader.service'; export { MetaTagsService } from './meta-tags.service'; export { MyResourcesService } from './my-resources.service'; export { NodeLinksService } from './node-links.service'; +export { ProjectRedirectDialogService } from './project-redirect-dialog.service'; export { RegionsService } from './regions.service'; export { ResourceGuidService } from './resource.service'; export { ResourceCardService } from './resource-card.service'; diff --git a/src/app/shared/services/project-redirect-dialog.service.ts b/src/app/shared/services/project-redirect-dialog.service.ts new file mode 100644 index 000000000..f5d230b2d --- /dev/null +++ b/src/app/shared/services/project-redirect-dialog.service.ts @@ -0,0 +1,23 @@ +import { inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +import { CustomConfirmationService } from './custom-confirmation.service'; + +@Injectable({ + providedIn: 'root', +}) +export class ProjectRedirectDialogService { + private readonly confirmationService = inject(CustomConfirmationService); + private readonly router = inject(Router); + + showProjectRedirectDialog(projectId: string): void { + this.confirmationService.confirmAccept({ + headerKey: 'myProjects.redirectDialog.header', + messageKey: 'myProjects.redirectDialog.message', + acceptLabelKey: 'myProjects.redirectDialog.confirmButton', + rejectLabelKey: 'myProjects.redirectDialog.rejectButton', + onConfirm: () => this.router.navigate(['/', projectId]), + onReject: () => null, + }); + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index f7d818057..fba1bd785 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -437,6 +437,12 @@ }, "updateProjectDetailsMessage": "Successfully updated project details.", "updateProjectSettingsMessage": "Successfully updated project settings." + }, + "redirectDialog": { + "header": "Success", + "message": "New project created successfully!", + "confirmButton": "Go to project", + "rejectButton": "Keep working here" } }, "myProfile": { From 28530411128577da9f65d199013b8941ce61c6f2 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Wed, 10 Sep 2025 16:33:57 +0300 Subject: [PATCH 02/21] refactor(add-project-redirect-modal): added pipe to the modal subscription --- .../home/pages/dashboard/dashboard.component.ts | 13 +++++++------ .../features/my-projects/my-projects.component.ts | 13 +++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index d8f29e5ab..ab1603af8 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -7,7 +7,7 @@ import { Button } from 'primeng/button'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { TablePageEvent } from 'primeng/table'; -import { debounceTime, distinctUntilChanged } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs'; import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; @@ -186,10 +186,11 @@ export class DashboardComponent implements OnInit { modal: true, closable: true, }) - .onClose.subscribe((result) => { - if (result.project.id) { - this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id); - } - }); + .onClose.pipe( + filter((result) => result.project.id), + tap((result) => this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } } diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 7542155bf..df2eef8fe 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -7,7 +7,7 @@ import { DialogService } from 'primeng/dynamicdialog'; import { TablePageEvent } from 'primeng/table'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; -import { debounceTime, distinctUntilChanged } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs'; import { ChangeDetectionStrategy, @@ -337,11 +337,12 @@ export class MyProjectsComponent implements OnInit { modal: true, closable: true, }) - .onClose.subscribe((result) => { - if (result.project && result.project.id) { - this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id); - } - }); + .onClose.pipe( + filter((result) => result.project.id), + tap((result) => this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } navigateToProject(project: MyResourcesItem): void { From 1aa52dc98965ce94a5d005d92a1428038f6fd24f Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Fri, 12 Sep 2025 12:12:28 +0300 Subject: [PATCH 03/21] fix(addons): added logic for handling redirection after the oauth flow has finished --- .../pages/dashboard/dashboard.component.ts | 2 +- .../my-projects/my-projects.component.ts | 2 +- .../connect-configured-addon.component.ts | 61 +++++++++-- .../connect-addon/connect-addon.component.ts | 66 ++++++----- .../addon-setup-account-form.component.html | 21 ++-- src/app/shared/models/addons/index.ts | 1 + .../models/addons/oauth-callbacks.model.ts | 7 ++ .../services/addons/addon-form.service.ts | 2 +- .../services/addons/addon-oauth.service.ts | 103 ++++++++++++++++++ .../shared/services/addons/addons.service.ts | 2 +- src/app/shared/services/addons/index.ts | 1 + 11 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 src/app/shared/models/addons/oauth-callbacks.model.ts create mode 100644 src/app/shared/services/addons/addon-oauth.service.ts diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index ab1603af8..b7bbf9ce7 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -187,7 +187,7 @@ export class DashboardComponent implements OnInit { closable: true, }) .onClose.pipe( - filter((result) => result.project.id), + filter((result) => result && result.project.id), tap((result) => this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id)), takeUntilDestroyed(this.destroyRef) ) diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index df2eef8fe..69681affe 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -338,7 +338,7 @@ export class MyProjectsComponent implements OnInit { closable: true, }) .onClose.pipe( - filter((result) => result.project.id), + filter((result) => result && result.project.id), tap((result) => this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id)), takeUntilDestroyed(this.destroyRef) ) diff --git a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts index ad3b7ecfe..9de274a63 100644 --- a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts +++ b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts @@ -9,7 +9,7 @@ import { RadioButtonModule } from 'primeng/radiobutton'; import { StepPanel, StepPanels, Stepper } from 'primeng/stepper'; import { TableModule } from 'primeng/table'; -import { Component, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, computed, DestroyRef, inject, signal, viewChild } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -26,7 +26,13 @@ import { } from '@shared/components/addons'; import { AddonServiceNames } from '@shared/enums'; import { AddonModel, AddonTerm, AuthorizedAddonRequestJsonApi } from '@shared/models'; -import { AddonDialogService, AddonFormService, AddonOperationInvocationService, ToastService } from '@shared/services'; +import { + AddonDialogService, + AddonFormService, + AddonOAuthService, + AddonOperationInvocationService, + ToastService, +} from '@shared/services'; import { AddonsSelectors, CreateAddonOperationInvocation, @@ -71,6 +77,8 @@ export class ConnectConfiguredAddonComponent { private addonDialogService = inject(AddonDialogService); private addonFormService = inject(AddonFormService); private operationInvocationService = inject(AddonOperationInvocationService); + private oauthService = inject(AddonOAuthService); + private destroyRef = inject(DestroyRef); private router = inject(Router); private route = inject(ActivatedRoute); private selectedAccount = signal({} as AuthorizedAccountModel); @@ -157,6 +165,10 @@ export class ConnectConfiguredAddonComponent { this.router.navigate([`${this.baseUrl()}/addons`]); } this.addon.set(addon); + + this.destroyRef.onDestroy(() => { + this.oauthService.stopOAuthTracking(); + }); } handleCreateConfiguredAddon() { @@ -197,16 +209,11 @@ export class ConnectConfiguredAddonComponent { this.actions.createAuthorizedAddon(payload, this.addonTypeString()).subscribe({ complete: () => { - const addon = this.createdAuthorizedAddon(); - if (addon?.authUrl) { - this.addonAuthUrl.set(addon.authUrl); - window.open(addon.authUrl, '_blank'); - this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); + const createdAddon = this.createdAuthorizedAddon(); + if (createdAddon?.authUrl) { + this.startOauthFlow(createdAddon); } else { - this.router.navigate([`${this.baseUrl()}/addons`]); - this.toastService.showSuccess('settings.addons.toast.createSuccess', { - addonName: AddonServiceNames[addon?.externalServiceName as keyof typeof AddonServiceNames], - }); + this.refreshAccountsForOAuth(); } }, }); @@ -331,4 +338,36 @@ export class ConnectConfiguredAddonComponent { this.selectedStorageItemUrl.set(''); this.selectedResourceType.set(''); } + + private startOauthFlow(createdAddon: AuthorizedAccountModel): void { + this.addonAuthUrl.set(createdAddon.authUrl!); + window.open(createdAddon.authUrl!, '_blank'); + this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); + + this.oauthService.startOAuthTracking(createdAddon, this.addonTypeString(), { + onSuccess: () => { + this.refreshAccountsForOAuth(); + }, + }); + } + + private refreshAccountsForOAuth(): void { + const requiredData = this.getDataForAccountCheck(); + if (!requiredData) return; + + const { addonType, referenceId, currentAddon } = requiredData; + const addonConfig = this.getAddonConfig(addonType, referenceId); + + if (!addonConfig) return; + + addonConfig.getAddons().subscribe({ + complete: () => { + const authorizedAddons = addonConfig.getAuthorizedAddons(); + const matchingAddons = this.findMatchingAddons(authorizedAddons, currentAddon); + this.currentAuthorizedAddonAccounts.set(matchingAddons); + + this.stepper()?.value.set(ProjectAddonsStepperValue.CHOOSE_ACCOUNT); + }, + }); + } } diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts index 21ecad24b..d30efac78 100644 --- a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts +++ b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts @@ -6,7 +6,7 @@ import { Button } from 'primeng/button'; import { StepPanel, StepPanels, Stepper } from 'primeng/stepper'; import { TableModule } from 'primeng/table'; -import { Component, computed, effect, inject, signal, viewChild } from '@angular/core'; +import { Component, computed, DestroyRef, effect, inject, signal, viewChild } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; @@ -15,7 +15,7 @@ import { AddonServiceNames, AddonType, ProjectAddonsStepperValue } from '@osf/sh import { getAddonTypeString, isAuthorizedAddon } from '@osf/shared/helpers'; import { AddonSetupAccountFormComponent, AddonTermsComponent } from '@shared/components/addons'; import { AddonModel, AddonTerm, AuthorizedAccountModel, AuthorizedAddonRequestJsonApi } from '@shared/models'; -import { ToastService } from '@shared/services'; +import { AddonOAuthService, ToastService } from '@shared/services'; import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@shared/stores/addons'; @Component({ @@ -40,6 +40,8 @@ import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@ export class ConnectAddonComponent { private readonly router = inject(Router); private readonly toastService = inject(ToastService); + private readonly oauthService = inject(AddonOAuthService); + private readonly destroyRef = inject(DestroyRef); readonly stepper = viewChild(Stepper); readonly AddonType = AddonType; @@ -52,26 +54,17 @@ export class ConnectAddonComponent { addonsUserReference = select(AddonsSelectors.getAddonsUserReference); createdAddon = select(AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon); isCreatingAuthorizedAddon = select(AddonsSelectors.getCreatedOrUpdatedStorageAddonSubmitting); - isAuthorized = computed(() => { - return isAuthorizedAddon(this.addon()); - }); - addonTypeString = computed(() => { - return getAddonTypeString(this.addon()); - }); + + isAuthorized = computed(() => isAuthorizedAddon(this.addon())); + addonTypeString = computed(() => getAddonTypeString(this.addon())); + userReferenceId = computed(() => this.addonsUserReference()[0]?.id); + baseUrl = computed(() => this.router.url.split('/addons')[0]); actions = createDispatchMap({ createAuthorizedAddon: CreateAuthorizedAddon, updateAuthorizedAddon: UpdateAuthorizedAddon, }); - readonly userReferenceId = computed(() => { - return this.addonsUserReference()[0]?.id; - }); - readonly baseUrl = computed(() => { - const currentUrl = this.router.url; - return currentUrl.split('/addons')[0]; - }); - constructor() { const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAccountModel; if (!addon) { @@ -84,28 +77,47 @@ export class ConnectAddonComponent { this.stepper()?.value.set(ProjectAddonsStepperValue.SETUP_NEW_ACCOUNT); } }); + + this.destroyRef.onDestroy(() => { + this.oauthService.stopOAuthTracking(); + }); } handleConnectAuthorizedAddon(payload: AuthorizedAddonRequestJsonApi): void { if (!this.addon()) return; - (!this.isAuthorized() - ? this.actions.createAuthorizedAddon(payload, this.addonTypeString()) - : this.actions.updateAuthorizedAddon(payload, this.addonTypeString(), this.addon()!.id) - ).subscribe({ + const action = this.isAuthorized() + ? this.actions.updateAuthorizedAddon(payload, this.addonTypeString(), this.addon()!.id) + : this.actions.createAuthorizedAddon(payload, this.addonTypeString()); + + action.subscribe({ complete: () => { const createdAddon = this.createdAddon(); if (createdAddon?.authUrl) { - this.addonAuthUrl.set(createdAddon.authUrl); - window.open(createdAddon.authUrl, '_blank'); - this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); + this.startOauthFlow(createdAddon); } else { - this.router.navigate([`${this.baseUrl()}/addons`]); - this.toastService.showSuccess('settings.addons.toast.createSuccess', { - addonName: AddonServiceNames[createdAddon?.externalServiceName as keyof typeof AddonServiceNames], - }); + this.showSuccessAndRedirect(createdAddon); } }, }); } + + private startOauthFlow(createdAddon: AuthorizedAccountModel): void { + this.addonAuthUrl.set(createdAddon.authUrl!); + window.open(createdAddon.authUrl!, '_blank'); + this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); + + this.oauthService.startOAuthTracking(createdAddon, this.addonTypeString(), { + onSuccess: (updatedAddon) => { + this.showSuccessAndRedirect(updatedAddon); + }, + }); + } + + private showSuccessAndRedirect(createdAddon: AuthorizedAccountModel | null): void { + this.router.navigate([`${this.baseUrl()}/addons`]); + this.toastService.showSuccess('settings.addons.toast.createSuccess', { + addonName: AddonServiceNames[createdAddon?.externalServiceName as keyof typeof AddonServiceNames], + }); + } } diff --git a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html index dbcb2445c..22299e4ec 100644 --- a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html +++ b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html @@ -13,9 +13,10 @@

{{ 'settings.addons.form.fields.secretKey' | translate }}

- @@ -32,9 +33,10 @@

{{ 'settings.addons.form.fields.apiToken' | translate }}

- @@ -79,9 +81,10 @@

{{ 'settings.addons.form.fields.personalAccessToken' | translate }}

- diff --git a/src/app/shared/models/addons/index.ts b/src/app/shared/models/addons/index.ts index 5d227d4cf..e0ae0ab42 100644 --- a/src/app/shared/models/addons/index.ts +++ b/src/app/shared/models/addons/index.ts @@ -3,6 +3,7 @@ export * from './addon-terms.model'; export * from './addons.models'; export * from './authorized-account.model'; export * from './configured-addon.model'; +export * from './oauth-callbacks.model'; export * from './operation-invocation.models'; export * from './operation-invoke-data.model'; export * from './strorage-item.model'; diff --git a/src/app/shared/models/addons/oauth-callbacks.model.ts b/src/app/shared/models/addons/oauth-callbacks.model.ts new file mode 100644 index 000000000..2d72e41ad --- /dev/null +++ b/src/app/shared/models/addons/oauth-callbacks.model.ts @@ -0,0 +1,7 @@ +import { AuthorizedAccountModel } from '@shared/models'; + +export interface OAuthCallbacks { + onSuccess: (addon: AuthorizedAccountModel) => void; + onError?: () => void; + onCleanup?: () => void; +} diff --git a/src/app/shared/services/addons/addon-form.service.ts b/src/app/shared/services/addons/addon-form.service.ts index a2e0c79da..33a534f1c 100644 --- a/src/app/shared/services/addons/addon-form.service.ts +++ b/src/app/shared/services/addons/addon-form.service.ts @@ -86,7 +86,7 @@ export class AddonFormService { credentials, initiate_oauth: initiateOAuth, auth_url: null, - credentials_available: false, + credentials_available: ('credentialsAvailable' in addon && addon.credentialsAvailable) || false, }, relationships: { account_owner: { diff --git a/src/app/shared/services/addons/addon-oauth.service.ts b/src/app/shared/services/addons/addon-oauth.service.ts new file mode 100644 index 000000000..4b8bb1f32 --- /dev/null +++ b/src/app/shared/services/addons/addon-oauth.service.ts @@ -0,0 +1,103 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { AuthorizedAccountModel, OAuthCallbacks } from '@shared/models'; +import { AddonsSelectors, DeleteAuthorizedAddon, GetAuthorizedStorageOauthToken } from '@shared/stores/addons'; + +@Injectable({ + providedIn: 'root', +}) +export class AddonOAuthService { + private destroyRef = inject(DestroyRef); + + private pendingOauth = signal(false); + private createdAddon = signal(null); + private addonTypeString = signal(''); + private callbacks = signal(null); + + private authorizedStorageAddons = select(AddonsSelectors.getAuthorizedStorageAddons); + + private actions = createDispatchMap({ + getAuthorizedStorageOauthToken: GetAuthorizedStorageOauthToken, + deleteAuthorizedAddon: DeleteAuthorizedAddon, + }); + + startOAuthTracking(createdAddon: AuthorizedAccountModel, addonTypeString: string, callbacks: OAuthCallbacks): void { + this.pendingOauth.set(true); + this.createdAddon.set(createdAddon); + this.addonTypeString.set(addonTypeString); + this.callbacks.set(callbacks); + + document.addEventListener('visibilitychange', this.onVisibilityChange.bind(this)); + } + + stopOAuthTracking(): void { + this.cleanupService(); + } + + private onVisibilityChange(): void { + if (document.visibilityState === 'visible' && this.pendingOauth()) { + this.checkOauthSuccess(); + } + } + + private checkOauthSuccess(): void { + const addon = this.createdAddon(); + if (!addon?.id) return; + + this.actions + .getAuthorizedStorageOauthToken(addon.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + complete: () => { + const updatedAddon = this.authorizedStorageAddons().find( + (storageAddon: AuthorizedAccountModel) => storageAddon.id === addon.id + ); + + if (updatedAddon?.credentialsAvailable && updatedAddon?.authUrl === null) { + this.completeOauthFlow(updatedAddon); + } + }, + error: () => { + this.completeOauthFlow(); + }, + }); + } + + private completeOauthFlow(updatedAddon?: AuthorizedAccountModel): void { + this.pendingOauth.set(false); + document.removeEventListener('visibilitychange', this.onVisibilityChange.bind(this)); + + if (updatedAddon && this.callbacks()?.onSuccess) { + const originalAddon = this.createdAddon(); + const addonForCallback = { + ...updatedAddon, + externalServiceName: originalAddon?.externalServiceName || updatedAddon.externalServiceName, + }; + this.callbacks()?.onSuccess(addonForCallback); + } + + this.resetServiceData(); + } + + private cleanupService(): void { + this.cleanupIncompleteOAuthAddon(); + document.removeEventListener('visibilitychange', this.onVisibilityChange.bind(this)); + this.resetServiceData(); + } + + private cleanupIncompleteOAuthAddon(): void { + const addon = this.createdAddon(); + if (addon?.id && this.pendingOauth() && !addon.credentialsAvailable) { + this.actions.deleteAuthorizedAddon(addon.id, this.addonTypeString()).subscribe(); + } + } + + private resetServiceData(): void { + this.createdAddon.set(null); + this.addonTypeString.set(''); + this.callbacks.set(null); + } +} diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index 05e317e8c..ba6b7c39a 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -70,7 +70,7 @@ export class AddonsService { getAuthorizedAddons(addonType: string, referenceId: string): Observable { const params = { - [`fields[external-${addonType}-services]`]: 'external_service_name', + [`fields[external-${addonType}-services]`]: 'external_service_name,credentials_format', }; return this.jsonApiService .get< diff --git a/src/app/shared/services/addons/index.ts b/src/app/shared/services/addons/index.ts index 2f53913ee..b3a89de0d 100644 --- a/src/app/shared/services/addons/index.ts +++ b/src/app/shared/services/addons/index.ts @@ -1,4 +1,5 @@ export { AddonDialogService } from './addon-dialog.service'; export { AddonFormService } from './addon-form.service'; +export { AddonOAuthService } from './addon-oauth.service'; export { AddonOperationInvocationService } from './addon-operation-invocation.service'; export { AddonsService } from './addons.service'; From d7dade145b42ba0b2ecc8e627b3011e0d308ade8 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Fri, 12 Sep 2025 14:05:32 +0300 Subject: [PATCH 04/21] fix(addons): added data attributes --- src/app/features/project/addons/addons.component.html | 4 ++-- .../confirm-account-connection-modal.component.html | 2 ++ .../connect-configured-addon.component.html | 8 ++++++++ .../disconnect-addon-modal.component.html | 2 ++ src/app/features/settings/addons/addons.component.html | 4 ++-- .../components/connect-addon/connect-addon.component.html | 2 ++ .../addons/addon-card/addon-card.component.html | 4 +++- .../addon-setup-account-form.component.html | 4 ++++ 8 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/app/features/project/addons/addons.component.html b/src/app/features/project/addons/addons.component.html index 8ebdf9cfb..78a7e2085 100644 --- a/src/app/features/project/addons/addons.component.html +++ b/src/app/features/project/addons/addons.component.html @@ -2,10 +2,10 @@
diff --git a/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html index 0a296e7e6..b24357152 100644 --- a/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html +++ b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html @@ -7,11 +7,13 @@ severity="info" (click)="dialogRef.close()" [disabled]="isSubmitting()" + data-test-addon-cancel-button /> diff --git a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html index fcf990484..3ad785625 100644 --- a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html +++ b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html @@ -31,6 +31,7 @@

severity="info" class="w-10rem btn-full-width" [routerLink]="[baseUrl() + '/addons']" + data-test-addon-cancel-button >

@@ -57,6 +59,7 @@

{{ loginOrChooseAccountText() }}

severity="info" class="w-7rem btn-full-width" (click)="activateCallback(AddonStepperValue.TERMS)" + data-test-addon-back-button > @@ -64,12 +67,14 @@

{{ loginOrChooseAccountText() }}

@@ -99,12 +104,14 @@

{{ 'settings.addons.connectAddon.chooseExistingAccount' | trans [label]="'settings.addons.form.buttons.back' | translate" severity="info" (onClick)="activateCallback(AddonStepperValue.CHOOSE_CONNECTION)" + data-test-addon-back-button > @@ -166,6 +173,7 @@

severity="info" class="w-7rem btn-full-width" [routerLink]="[baseUrl() + '/addons']" + data-test-addon-back-button > diff --git a/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html index e4447ba44..7fdcbf053 100644 --- a/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html +++ b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html @@ -14,6 +14,7 @@ severity="info" (click)="dialogRef.close()" [disabled]="isSubmitting()" + data-test-addon-cancel-button /> diff --git a/src/app/features/settings/addons/addons.component.html b/src/app/features/settings/addons/addons.component.html index d6ea70db0..35f135ad3 100644 --- a/src/app/features/settings/addons/addons.component.html +++ b/src/app/features/settings/addons/addons.component.html @@ -2,11 +2,11 @@
diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html index 84b2feff5..4d23698e6 100644 --- a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html +++ b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html @@ -31,11 +31,13 @@

severity="info" class="w-10rem btn-full-width" routerLink="/settings/addons" + data-test-addon-cancel-button >

diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.html b/src/app/shared/components/addons/addon-card/addon-card.component.html index c1cea7502..189987acb 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.html +++ b/src/app/shared/components/addons/addon-card/addon-card.component.html @@ -5,12 +5,13 @@ class="addon-image" alt="Addon card image" [src]="'assets/icons/addons/' + card()!.externalServiceName + '.svg'" + data-test-addon-card-logo /> }
-

{{ card()?.displayName }}

+

{{ card()?.displayName }}

@if (showDangerButton()) { @@ -18,6 +19,7 @@

{{ card()?.displayName }}

[label]="'settings.addons.form.buttons.disable' | translate" severity="danger" (onClick)="showDisableDialog()" + data-test-addon-card-disconnect > } diff --git a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html index 22299e4ec..9f27832d6 100644 --- a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html +++ b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html @@ -107,6 +107,7 @@

class="w-10rem btn-full-width" (onClick)="handleBack()" [disabled]="isSubmitting()" + data-test-addon-back-button > type="submit" [disabled]="!isFormValid || isSubmitting()" [loading]="isSubmitting()" + data-test-addon-authorize-button > } @else { class="w-10rem btn-full-width" routerLink="/settings/addons" [disabled]="isSubmitting()" + data-test-addon-cancel-button > [label]="'settings.addons.form.buttons.reconnect' | translate" type="submit" [disabled]="!isFormValid || isSubmitting()" + data-test-addon-reconnect-button > }

From 4e6ca6091758e944f74127a5019a649e88404fc4 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Fri, 12 Sep 2025 14:16:54 +0300 Subject: [PATCH 05/21] fix(addons): fixed comments --- src/app/features/home/pages/dashboard/dashboard.component.ts | 2 +- src/app/features/my-projects/my-projects.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index 430b036d6..e2b1288a0 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -204,7 +204,7 @@ export class DashboardComponent implements OnInit { closable: true, }) .onClose.pipe( - filter((result) => result && result.project.id), + filter((result) => result?.project.id), tap((result) => this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id)), takeUntilDestroyed(this.destroyRef) ) diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 6effb6d52..3f392c093 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -338,7 +338,7 @@ export class MyProjectsComponent implements OnInit { closable: true, }) .onClose.pipe( - filter((result) => result && result.project.id), + filter((result) => result?.project.id), tap((result) => this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id)), takeUntilDestroyed(this.destroyRef) ) From d8da3d0875c920261215e8f999d545ebdf5ac297 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Fri, 12 Sep 2025 14:37:01 +0300 Subject: [PATCH 06/21] fix(addons): fixed binding visibility change issues --- src/app/shared/services/addons/addon-oauth.service.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/shared/services/addons/addon-oauth.service.ts b/src/app/shared/services/addons/addon-oauth.service.ts index 4b8bb1f32..2d341e032 100644 --- a/src/app/shared/services/addons/addon-oauth.service.ts +++ b/src/app/shared/services/addons/addon-oauth.service.ts @@ -24,13 +24,15 @@ export class AddonOAuthService { deleteAuthorizedAddon: DeleteAuthorizedAddon, }); + private boundOnVisibilityChange = this.onVisibilityChange.bind(this); + startOAuthTracking(createdAddon: AuthorizedAccountModel, addonTypeString: string, callbacks: OAuthCallbacks): void { this.pendingOauth.set(true); this.createdAddon.set(createdAddon); this.addonTypeString.set(addonTypeString); this.callbacks.set(callbacks); - document.addEventListener('visibilitychange', this.onVisibilityChange.bind(this)); + document.addEventListener('visibilitychange', this.boundOnVisibilityChange); } stopOAuthTracking(): void { @@ -68,7 +70,7 @@ export class AddonOAuthService { private completeOauthFlow(updatedAddon?: AuthorizedAccountModel): void { this.pendingOauth.set(false); - document.removeEventListener('visibilitychange', this.onVisibilityChange.bind(this)); + document.removeEventListener('visibilitychange', this.boundOnVisibilityChange); if (updatedAddon && this.callbacks()?.onSuccess) { const originalAddon = this.createdAddon(); @@ -84,7 +86,7 @@ export class AddonOAuthService { private cleanupService(): void { this.cleanupIncompleteOAuthAddon(); - document.removeEventListener('visibilitychange', this.onVisibilityChange.bind(this)); + document.removeEventListener('visibilitychange', this.boundOnVisibilityChange); this.resetServiceData(); } From 9cb2cba924e76760a654c428b1a70623e4dc0c7c Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Mon, 15 Sep 2025 13:05:09 +0300 Subject: [PATCH 07/21] fix(addons): fixed issue with connecting citations addons --- .../google-file-picker/google-file-picker.component.ts | 4 +++- .../storage-item-selector.component.html | 1 + src/app/shared/services/addons/addon-oauth.service.ts | 2 +- src/app/shared/services/addons/addons.service.ts | 6 +++--- src/app/shared/stores/addons/addons.actions.ts | 5 ++++- src/app/shared/stores/addons/addons.state.ts | 2 +- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts index 5b2f40d02..316b666a1 100644 --- a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts +++ b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts @@ -11,6 +11,7 @@ import { StorageItemModel } from '@osf/shared/models'; import { GoogleFileDataModel } from '@osf/shared/models/files/google-file.data.model'; import { GoogleFilePickerModel } from '@osf/shared/models/files/google-file.picker.model'; import { AddonsSelectors, GetAuthorizedStorageOauthToken } from '@osf/shared/stores'; +import { AddonType } from '@shared/enums'; import { GoogleFilePickerDownloadService } from './service/google-file-picker.download.service'; @@ -31,6 +32,7 @@ export class GoogleFilePickerComponent implements OnInit { public rootFolder = input(null); public accountId = input(''); public handleFolderSelection = input<(folder: StorageItemModel) => void>(); + currentAddonType = input(AddonType.STORAGE); public accessToken = signal(null); public visible = signal(false); @@ -111,7 +113,7 @@ export class GoogleFilePickerComponent implements OnInit { #loadOauthToken(): void { if (this.accountId()) { - this.store.dispatch(new GetAuthorizedStorageOauthToken(this.accountId())).subscribe({ + this.store.dispatch(new GetAuthorizedStorageOauthToken(this.accountId(), this.currentAddonType())).subscribe({ next: () => { this.accessToken.set( this.store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(this.accountId())) diff --git a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.html b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.html index 9ca6aa910..bdfb5555d 100644 --- a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.html +++ b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.html @@ -35,6 +35,7 @@

diff --git a/src/app/shared/services/addons/addon-oauth.service.ts b/src/app/shared/services/addons/addon-oauth.service.ts index 2d341e032..5d9275254 100644 --- a/src/app/shared/services/addons/addon-oauth.service.ts +++ b/src/app/shared/services/addons/addon-oauth.service.ts @@ -50,7 +50,7 @@ export class AddonOAuthService { if (!addon?.id) return; this.actions - .getAuthorizedStorageOauthToken(addon.id) + .getAuthorizedStorageOauthToken(addon.id, this.addonTypeString()) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ complete: () => { diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index 0a4c0c047..e04060364 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -79,12 +79,12 @@ export class AddonsService { ); } - getAuthorizedStorageOauthToken(accountId: string): Observable { + getAuthorizedStorageOauthToken(accountId: string, addonType: string): Observable { return this.jsonApiService - .patch(`${this.apiUrl}/authorized-storage-accounts/${accountId}`, { + .patch(`${this.apiUrl}/authorized-${addonType}-accounts/${accountId}`, { data: { id: accountId, - type: 'authorized-storage-accounts', + type: `authorized-${addonType}-accounts`, attributes: { serialize_oauth_token: 'true' }, }, }) diff --git a/src/app/shared/stores/addons/addons.actions.ts b/src/app/shared/stores/addons/addons.actions.ts index 4e7a40d17..8af995655 100644 --- a/src/app/shared/stores/addons/addons.actions.ts +++ b/src/app/shared/stores/addons/addons.actions.ts @@ -25,7 +25,10 @@ export class GetAuthorizedStorageAddons { export class GetAuthorizedStorageOauthToken { static readonly type = '[Addons] Get Authorized Storage Oauth Token'; - constructor(public accountId: string) {} + constructor( + public accountId: string, + public addonType: string + ) {} } export class GetAuthorizedCitationAddons { diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index 840ec4661..67b0e46fd 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -148,7 +148,7 @@ export class AddonsState { }, }); - return this.addonsService.getAuthorizedStorageOauthToken(action.accountId).pipe( + return this.addonsService.getAuthorizedStorageOauthToken(action.accountId, action.addonType).pipe( tap((addon) => { ctx.setState((state) => { const existing = state.authorizedStorageAddons.data.find( From 7127f46f16d51dc1cc0a0f8bde4879cdfdc0685e Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 15 Sep 2025 14:32:08 +0300 Subject: [PATCH 08/21] Fix/625 view duplicates bug (#389) * fix(tests): fixed unit tests * fix(bug): fixed view duplicates bug * fix(bugs): fixed permission bug --- src/app/app.config.ts | 2 +- .../view-duplicates.component.html | 102 +++++++++--------- .../view-duplicates.component.scss | 9 +- .../view-duplicates.component.ts | 8 ++ .../files/pages/files/files.component.html | 4 +- .../files/pages/files/files.component.spec.ts | 6 ++ .../files/pages/files/files.component.ts | 14 ++- src/app/features/files/store/files.model.ts | 2 +- src/app/features/files/store/files.state.ts | 8 +- .../profile-information.component.html | 22 ++-- .../shared/mappers/search/search.mapper.ts | 1 + src/app/shared/mocks/addon.mock.ts | 1 + src/app/shared/mocks/base-node.mock.ts | 27 +++++ src/app/shared/mocks/data.mock.ts | 1 + src/app/shared/mocks/index.ts | 1 + src/app/shared/mocks/project-overview.mock.ts | 1 - src/app/shared/mocks/resource.mock.ts | 56 +++++++--- src/styles/components/md-editor.scss | 1 + 18 files changed, 173 insertions(+), 93 deletions(-) create mode 100644 src/app/shared/mocks/base-node.mock.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index d913ba378..ac3980d36 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -24,7 +24,7 @@ import * as Sentry from '@sentry/angular'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' })), + provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })), provideStore(STATES, withNgxsReduxDevtoolsPlugin({ disabled: false })), providePrimeNG({ theme: { diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html index 64258dcb7..515a58a39 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html @@ -13,66 +13,68 @@

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

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

- - {{ duplicate.title }} -

+
+
+

+ + {{ duplicate.title }} +

-
- @if (duplicate.currentUserPermissions.includes(UserPermissions.Write)) { - - - } +
+ @if (showMoreOptions(duplicate)) { + + + } - - - {{ item.label | translate }} - - -
+ + + {{ 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.forked' | translate }}: +

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

+
-
- {{ 'common.labels.contributors' | translate }}: - @for (contributor of duplicate.contributors; track contributor.id) { -
- {{ contributor.fullName }} - {{ $last ? '' : ',' }} -
- } -
+
+ {{ 'common.labels.lastUpdated' | translate }}: +

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

+
-
-
- {{ 'common.labels.description' | translate }}: - +
+ {{ 'common.labels.contributors' | translate }}: + @for (contributor of duplicate.contributors; track contributor.id) { +
+ {{ contributor.fullName }} + {{ $last ? '' : ',' }}
+ } +
+ +
+
+ {{ 'common.labels.description' | translate }}: +
-
- } + +
} @if (totalDuplicates() > pageSize) { diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss index 878a3d10c..32a20a323 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss @@ -1,6 +1,3 @@ -@use "styles/variables" as var; -@use "styles/mixins" as mix; - :host { display: flex; flex-direction: column; @@ -8,7 +5,7 @@ } .duplicate-wrapper { - border: 1px solid var.$grey-2; - border-radius: mix.rem(12px); - color: var.$dark-blue-1; + border: 1px solid var(--grey-2); + border-radius: 0.75rem; + color: var(--dark-blue-1); } diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts index 50b567184..bea1cb9c1 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts @@ -40,6 +40,7 @@ import { import { ResourceType, UserPermissions } from '@osf/shared/enums'; import { IS_SMALL } from '@osf/shared/helpers'; import { ToolbarResource } from '@osf/shared/models'; +import { Duplicate } from '@osf/shared/models/duplicates'; import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@osf/shared/stores'; @Component({ @@ -165,6 +166,13 @@ export class ViewDuplicatesComponent { return null; }); + showMoreOptions(duplicate: Duplicate) { + return ( + duplicate.currentUserPermissions.includes(UserPermissions.Admin) || + duplicate.currentUserPermissions.includes(UserPermissions.Write) + ); + } + handleForkResource(): void { const toolbarResource = this.toolbarResource(); const dialogWidth = !this.isSmall() ? '95vw' : '450px'; diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 235609f3a..c23a06b7c 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -59,7 +59,7 @@ - @if (!isReadonly() && !hasViewOnly()) { + @if (canEdit() && !hasViewOnly()) { { DialogService, provideMockStore({ signals: [ + { + selector: CurrentResourceSelectors.getResourceDetails, + value: testNode, + }, { selector: FilesSelectors.getRootFolders, value: getNodeFilesMappedData(), diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 274020e0d..eb6f6bdcd 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -178,11 +178,15 @@ export class FilesComponent { readonly hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - readonly isReadonly = computed( - () => - this.resourceDetails().isRegistration || - this.resourceDetails().currentUserPermissions.includes(UserPermissions.Read) - ); + readonly canEdit = computed(() => { + const details = this.resourceDetails(); + const hasAdminOrWrite = details.currentUserPermissions.some( + (permission) => permission === UserPermissions.Admin || permission === UserPermissions.Write + ); + + return !details.isRegistration && hasAdminOrWrite; + }); + readonly isViewOnlyDownloadable = computed(() => this.resourceType() === ResourceType.Registration); isButtonDisabled = computed(() => this.fileIsUploading() || this.isFilesLoading()); diff --git a/src/app/features/files/store/files.model.ts b/src/app/features/files/store/files.model.ts index 895245488..c00cdbe00 100644 --- a/src/app/features/files/store/files.model.ts +++ b/src/app/features/files/store/files.model.ts @@ -24,7 +24,7 @@ export interface FilesStateModel { isAnonymous: boolean; } -export const filesStateDefaults: FilesStateModel = { +export const FILES_STATE_DEFAULTS: FilesStateModel = { files: { data: [], isLoading: false, diff --git a/src/app/features/files/store/files.state.ts b/src/app/features/files/store/files.state.ts index 1bc51a8df..b5e2f7f8e 100644 --- a/src/app/features/files/store/files.state.ts +++ b/src/app/features/files/store/files.state.ts @@ -33,12 +33,12 @@ import { SetSort, UpdateTags, } from './files.actions'; -import { filesStateDefaults, FilesStateModel } from './files.model'; +import { FILES_STATE_DEFAULTS, FilesStateModel } from './files.model'; @Injectable() @State({ - name: 'filesState', - defaults: filesStateDefaults, + name: 'files', + defaults: FILES_STATE_DEFAULTS, }) export class FilesState { filesService = inject(FilesService); @@ -328,6 +328,6 @@ export class FilesState { @Action(ResetState) resetState(ctx: StateContext) { - ctx.patchState(filesStateDefaults); + ctx.patchState(FILES_STATE_DEFAULTS); } } diff --git a/src/app/features/profile/components/profile-information/profile-information.component.html b/src/app/features/profile/components/profile-information/profile-information.component.html index 7b1d7509a..37308942a 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.html +++ b/src/app/features/profile/components/profile-information/profile-information.component.html @@ -62,52 +62,52 @@

@if (currentUser()?.social?.github) { - + github } @if (currentUser()?.social?.scholar) { - + scholar } @if (currentUser()?.social?.twitter) { - + x(twitter) } @if (currentUser()?.social?.linkedIn) { - + linkedin } @if (currentUser()?.social?.impactStory) { - + impactstory } @if (currentUser()?.social?.baiduScholar) { - + baidu } @if (currentUser()?.social?.researchGate) { - + researchGate } @if (currentUser()?.social?.researcherId) { - + researchId } @if (currentUser()?.social?.ssrn) { - + ssrn } @if (currentUser()?.social?.academiaProfileID) { - + academia } @@ -118,7 +118,7 @@

} diff --git a/src/app/shared/mappers/search/search.mapper.ts b/src/app/shared/mappers/search/search.mapper.ts index 334cde403..59099d6d5 100644 --- a/src/app/shared/mappers/search/search.mapper.ts +++ b/src/app/shared/mappers/search/search.mapper.ts @@ -4,6 +4,7 @@ import { IndexCardDataJsonApi, ResourceModel } from '@shared/models'; export function MapResources(indexCardData: IndexCardDataJsonApi): ResourceModel { const resourceMetadata = indexCardData.attributes.resourceMetadata; const resourceIdentifier = indexCardData.attributes.resourceIdentifier; + return { absoluteUrl: resourceMetadata['@id'], resourceType: ResourceType[resourceMetadata.resourceType[0]['@id'] as keyof typeof ResourceType], diff --git a/src/app/shared/mocks/addon.mock.ts b/src/app/shared/mocks/addon.mock.ts index 068725a85..f547d3250 100644 --- a/src/app/shared/mocks/addon.mock.ts +++ b/src/app/shared/mocks/addon.mock.ts @@ -10,4 +10,5 @@ export const MOCK_ADDON: AddonModel = { supportedFeatures: ['ACCESS', 'UPDATE'], credentialsFormat: CredentialsFormat.ACCESS_SECRET_KEYS, providerName: 'Test Provider', + wbKey: 'github', }; diff --git a/src/app/shared/mocks/base-node.mock.ts b/src/app/shared/mocks/base-node.mock.ts new file mode 100644 index 000000000..808bf3203 --- /dev/null +++ b/src/app/shared/mocks/base-node.mock.ts @@ -0,0 +1,27 @@ +import { BaseNodeModel } from '../models'; + +export const testNode: BaseNodeModel = { + id: 'abc123', + title: 'Long-Term Effects of Climate Change', + description: + 'This project collects and analyzes climate change data across multiple regions to understand long-term environmental impacts.', + category: 'project', + customCitation: 'Doe, J. (2024). Long-Term Effects of Climate Change. OSF.', + dateCreated: '2024-05-10T14:23:00Z', + dateModified: '2025-09-01T09:45:00Z', + isRegistration: false, + isPreprint: true, + isFork: false, + isCollection: false, + isPublic: true, + tags: ['climate', 'environment', 'data-analysis'], + accessRequestsEnabled: true, + nodeLicense: { + copyrightHolders: ['CC0 1.0 Universal'], + year: '2025', + }, + currentUserPermissions: ['admin', 'read', 'write'], + currentUserIsContributor: true, + wikiEnabled: true, + rootParentId: 'nt29k', +}; diff --git a/src/app/shared/mocks/data.mock.ts b/src/app/shared/mocks/data.mock.ts index 1aeb92a45..60584caa3 100644 --- a/src/app/shared/mocks/data.mock.ts +++ b/src/app/shared/mocks/data.mock.ts @@ -11,6 +11,7 @@ export const MOCK_USER: User = { middleNames: '', suffix: '', dateRegistered: new Date('2024-01-01'), + acceptedTermsOfService: true, employment: [ { title: 'Software Engineer', diff --git a/src/app/shared/mocks/index.ts b/src/app/shared/mocks/index.ts index 9468edc46..6e4f9e011 100644 --- a/src/app/shared/mocks/index.ts +++ b/src/app/shared/mocks/index.ts @@ -1,5 +1,6 @@ export { MOCK_ADDON } from './addon.mock'; export * from './analytics.mock'; +export * from './base-node.mock'; export { CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK } from './cedar-metadata-data-template-json-api.mock'; export * from './contributors.mock'; export * from './custom-сonfirmation.service.mock'; diff --git a/src/app/shared/mocks/project-overview.mock.ts b/src/app/shared/mocks/project-overview.mock.ts index dcbecc95a..a513cde33 100644 --- a/src/app/shared/mocks/project-overview.mock.ts +++ b/src/app/shared/mocks/project-overview.mock.ts @@ -54,7 +54,6 @@ export const MOCK_PROJECT_OVERVIEW: ProjectOverview = { currentUserIsContributor: true, currentUserIsContributorOrGroupMember: true, wikiEnabled: false, - subjects: [], contributors: [], customCitation: null, forksCount: 0, diff --git a/src/app/shared/mocks/resource.mock.ts b/src/app/shared/mocks/resource.mock.ts index 7de6b6947..9c44df257 100644 --- a/src/app/shared/mocks/resource.mock.ts +++ b/src/app/shared/mocks/resource.mock.ts @@ -3,43 +3,75 @@ import { ResourceType } from '@shared/enums'; import { ResourceModel, ResourceOverview } from '@shared/models'; export const MOCK_RESOURCE: ResourceModel = { - id: 'https://api.osf.io/v2/resources/resource-123', + absoluteUrl: 'https://api.osf.io/v2/resources/resource-123', resourceType: ResourceType.Registration, title: 'Test Resource', description: 'This is a test resource', dateCreated: new Date('2024-01-15'), dateModified: new Date('2024-01-20'), creators: [ - { id: 'https://api.osf.io/v2/users/user1', name: 'John Doe' }, - { id: 'https://api.osf.io/v2/users/user2', name: 'Jane Smith' }, + { absoluteUrl: 'https://api.osf.io/v2/users/user1', name: 'John Doe' }, + { absoluteUrl: 'https://api.osf.io/v2/users/user2', name: 'Jane Smith' }, ], - from: { id: 'https://api.osf.io/v2/projects/project1', name: 'Test Project' }, - provider: { id: 'https://api.osf.io/v2/providers/provider1', name: 'Test Provider' }, - license: { id: 'https://api.osf.io/v2/licenses/license1', name: 'MIT License' }, + provider: { absoluteUrl: 'https://api.osf.io/v2/providers/provider1', name: 'Test Provider' }, + license: { absoluteUrl: 'https://api.osf.io/v2/licenses/license1', name: 'MIT License' }, registrationTemplate: 'Test Template', - identifier: '10.1234/test.123', - conflictOfInterestResponse: 'no-conflict-of-interest', - orcid: 'https://orcid.org/0000-0000-0000-0000', - hasDataResource: true, + identifiers: ['https://staging4.osf.io/a42ysd'], + doi: ['10.1234/abcd.5678'], + addons: ['github', 'dropbox'], + hasDataResource: 'true', hasAnalyticCodeResource: false, hasMaterialsResource: true, hasPapersResource: false, hasSupplementalResource: true, + language: 'en', + isPartOfCollection: { absoluteUrl: 'https://staging4.osf.io/123asd', name: 'collection' }, + funders: [ + { + absoluteUrl: 'https://funder.org/nasa/', + name: 'NASA', + }, + ], + affiliations: [{ absoluteUrl: 'https://university.edu/', name: 'Example University' }], + qualifiedAttribution: [ + { + agentId: 'agentId', + order: 1, + }, + ], }; export const MOCK_AGENT_RESOURCE: ResourceModel = { - id: 'https://api.osf.io/v2/users/user-123', + absoluteUrl: 'https://api.osf.io/v2/users/user-123', resourceType: ResourceType.Agent, title: 'Test User', description: 'This is a test user', dateCreated: new Date('2024-01-15'), dateModified: new Date('2024-01-20'), creators: [], - hasDataResource: false, + hasDataResource: 'false', hasAnalyticCodeResource: false, hasMaterialsResource: false, hasPapersResource: false, hasSupplementalResource: false, + identifiers: ['https://staging4.osf.io/123xca'], + language: 'en', + isPartOfCollection: { absoluteUrl: 'https://staging4.osf.io/123asd', name: 'collection' }, + doi: ['10.1234/abcd.5678'], + addons: ['github', 'dropbox'], + funders: [ + { + absoluteUrl: 'https://funder.org/nasa/', + name: 'NASA', + }, + ], + affiliations: [{ absoluteUrl: 'https://university.edu/', name: 'Example University' }], + qualifiedAttribution: [ + { + agentId: 'agentId', + order: 1, + }, + ], }; export const MOCK_RESOURCE_OVERVIEW: ResourceOverview = { diff --git a/src/styles/components/md-editor.scss b/src/styles/components/md-editor.scss index 844fb67ab..2d2be958a 100644 --- a/src/styles/components/md-editor.scss +++ b/src/styles/components/md-editor.scss @@ -5,6 +5,7 @@ position: relative; display: inline-flex; vertical-align: middle; + flex-wrap: wrap; .btn { padding: 0.3rem 0.5rem; From d28d72ff5e25b57f082f79e26ddb67159d2a03de Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 15 Sep 2025 16:32:46 +0300 Subject: [PATCH 09/21] fix(users): added 5 more columns options (#390) * fix(users): added 5 more columns options * fix(summary): fixed charts numbers display --- .../admin-table/admin-table.component.html | 6 +++-- .../constants/user-table-columns.constant.ts | 22 ++++++++++++++++++- .../institution-user-to-table-data.mapper.ts | 5 +++++ .../mappers/institution-users.mapper.ts | 12 +++++----- .../models/institution-user.model.ts | 6 +++++ .../institution-users-json-api.model.ts | 7 ++++++ .../institutions-summary.component.ts | 4 ++-- .../institutions-users.component.ts | 4 +--- .../bar-chart/bar-chart.component.html | 11 +++++++--- .../doughnut-chart.component.html | 11 +++++++--- src/assets/i18n/en.json | 8 +++---- 11 files changed, 73 insertions(+), 23 deletions(-) diff --git a/src/app/features/admin-institutions/components/admin-table/admin-table.component.html b/src/app/features/admin-institutions/components/admin-table/admin-table.component.html index 8a14efc91..587a466a8 100644 --- a/src/app/features/admin-institutions/components/admin-table/admin-table.component.html +++ b/src/app/features/admin-institutions/components/admin-table/admin-table.component.html @@ -123,10 +123,12 @@ } } @else { - @if (col.dateFormat) { + @if (col.dateFormat && rowData[col.field]) { {{ getCellValue(rowData[col.field]) | date: col.dateFormat }} - } @else { + } @else if (!col.dateFormat && rowData[col.field]) { {{ getCellValue(rowData[col.field]) }} + } @else { + {{ rowData[col.field] ?? '-' }} } } diff --git a/src/app/features/admin-institutions/constants/user-table-columns.constant.ts b/src/app/features/admin-institutions/constants/user-table-columns.constant.ts index 4e1d8a6df..98de6e3ec 100644 --- a/src/app/features/admin-institutions/constants/user-table-columns.constant.ts +++ b/src/app/features/admin-institutions/constants/user-table-columns.constant.ts @@ -1,4 +1,4 @@ -import { TableColumn } from '@osf/features/admin-institutions/models'; +import { TableColumn } from '../models'; export const userTableColumns: TableColumn[] = [ { @@ -20,4 +20,24 @@ export const userTableColumns: TableColumn[] = [ { field: 'publicRegistrationCount', header: 'adminInstitutions.summary.publicRegistrations', sortable: true }, { field: 'embargoedRegistrationCount', header: 'adminInstitutions.summary.embargoedRegistrations', sortable: true }, { field: 'publishedPreprintCount', header: 'adminInstitutions.institutionUsers.preprints', sortable: true }, + { field: 'publicFileCount', header: 'adminInstitutions.institutionUsers.filesOnOsf', sortable: true }, + { field: 'totalDataStored', header: 'adminInstitutions.institutionUsers.totalDataStored', sortable: true }, + { + field: 'accountCreationDate', + header: 'adminInstitutions.institutionUsers.accountCreated', + sortable: true, + dateFormat: 'dd/MM/yyyy', + }, + { + field: 'monthLasLogin', + header: 'adminInstitutions.institutionUsers.lastLogin', + sortable: true, + dateFormat: 'dd/MM/yyyy', + }, + { + field: 'monthLastActive', + header: 'adminInstitutions.institutionUsers.lastActive', + sortable: true, + dateFormat: 'dd/MM/yyyy', + }, ]; diff --git a/src/app/features/admin-institutions/mappers/institution-user-to-table-data.mapper.ts b/src/app/features/admin-institutions/mappers/institution-user-to-table-data.mapper.ts index 914c4f62a..96e293d8a 100644 --- a/src/app/features/admin-institutions/mappers/institution-user-to-table-data.mapper.ts +++ b/src/app/features/admin-institutions/mappers/institution-user-to-table-data.mapper.ts @@ -24,5 +24,10 @@ export function mapUserToTableCellData(user: InstitutionUser): TableCellData { publicRegistrationCount: user.publicRegistrationCount, embargoedRegistrationCount: user.embargoedRegistrationCount, publishedPreprintCount: user.publishedPreprintCount, + publicFileCount: user.publicFileCount, + totalDataStored: user.storageByteCount ? `${(user.storageByteCount / (1024 * 1024)).toFixed(1)} MB` : '0 B', + monthLasLogin: user.monthLasLogin, + monthLastActive: user.monthLastActive, + accountCreationDate: user.accountCreationDate, }; } diff --git a/src/app/features/admin-institutions/mappers/institution-users.mapper.ts b/src/app/features/admin-institutions/mappers/institution-users.mapper.ts index cd9c49942..7f9785b2e 100644 --- a/src/app/features/admin-institutions/mappers/institution-users.mapper.ts +++ b/src/app/features/admin-institutions/mappers/institution-users.mapper.ts @@ -1,8 +1,4 @@ -import { - InstitutionUser, - InstitutionUserDataJsonApi, - InstitutionUsersJsonApi, -} from '@osf/features/admin-institutions/models'; +import { InstitutionUser, InstitutionUserDataJsonApi, InstitutionUsersJsonApi } from '../models'; export function mapInstitutionUsers(jsonApiData: InstitutionUsersJsonApi): InstitutionUser[] { return jsonApiData.data.map((user: InstitutionUserDataJsonApi) => ({ @@ -16,5 +12,11 @@ export function mapInstitutionUsers(jsonApiData: InstitutionUsersJsonApi): Insti publicRegistrationCount: user.attributes.public_registration_count, embargoedRegistrationCount: user.attributes.embargoed_registration_count, publishedPreprintCount: user.attributes.published_preprint_count, + monthLasLogin: user.attributes.month_last_login, + monthLastActive: user.attributes.month_last_active, + accountCreationDate: user.attributes.account_creation_date, + storageByteCount: user.attributes.storage_byte_count, + reportYearMonth: user.attributes.report_yearmonth, + publicFileCount: user.attributes.public_file_count, })); } diff --git a/src/app/features/admin-institutions/models/institution-user.model.ts b/src/app/features/admin-institutions/models/institution-user.model.ts index d8063d55d..a3862b2cd 100644 --- a/src/app/features/admin-institutions/models/institution-user.model.ts +++ b/src/app/features/admin-institutions/models/institution-user.model.ts @@ -9,4 +9,10 @@ export interface InstitutionUser { publicRegistrationCount: number; embargoedRegistrationCount: number; publishedPreprintCount: number; + monthLasLogin: string; + monthLastActive: string; + accountCreationDate: string; + storageByteCount: number; + publicFileCount: number; + reportYearMonth: string; } diff --git a/src/app/features/admin-institutions/models/institution-users-json-api.model.ts b/src/app/features/admin-institutions/models/institution-users-json-api.model.ts index a8c7d8424..3b90db240 100644 --- a/src/app/features/admin-institutions/models/institution-users-json-api.model.ts +++ b/src/app/features/admin-institutions/models/institution-users-json-api.model.ts @@ -9,6 +9,13 @@ export interface InstitutionUserAttributesJsonApi { public_registration_count: number; embargoed_registration_count: number; published_preprint_count: number; + account_creation_date: string; + contacts: unknown[]; + month_last_active: string; + month_last_login: string; + public_file_count: number; + report_yearmonth: string; + storage_byte_count: number; } export interface InstitutionUserRelationshipDataJsonApi { diff --git a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts index 24a443e7e..150fa0883 100644 --- a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts +++ b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts @@ -158,9 +158,9 @@ export class InstitutionsSummaryComponent implements OnInit { ]; this.osfProjectsLabels = [ - 'resourceCard.labels.publicRegistrations', + 'adminInstitutions.summary.publicRegistrations', 'adminInstitutions.summary.embargoedRegistrations', - 'resourceCard.labels.publicProjects', + 'adminInstitutions.summary.publicProjects', 'adminInstitutions.summary.privateProjects', 'common.search.tabs.preprints', ].map((el) => this.translateService.instant(el)); diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts index 5c0bf0d1a..d6373ad0d 100644 --- a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts +++ b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts @@ -68,9 +68,7 @@ export class InstitutionsUsersComponent { currentUser = select(UserSelectors.getCurrentUser); - tableData = computed(() => { - return this.users().map((user: InstitutionUser): TableCellData => mapUserToTableCellData(user)); - }); + tableData = computed(() => this.users().map((user: InstitutionUser): TableCellData => mapUserToTableCellData(user))); amountText = computed(() => { const count = this.totalCount(); diff --git a/src/app/shared/components/bar-chart/bar-chart.component.html b/src/app/shared/components/bar-chart/bar-chart.component.html index c3ebc58a0..66f2adbd6 100644 --- a/src/app/shared/components/bar-chart/bar-chart.component.html +++ b/src/app/shared/components/bar-chart/bar-chart.component.html @@ -20,9 +20,14 @@

{{ title() | translate }}

@for (label of labels(); let i = $index; track i) { -
  • -
    - {{ label }} +
  • +
    +
    + {{ label }} +
    + @if (datasets().length) { + {{ datasets()[0].data[i] }} + }
  • }
    diff --git a/src/app/shared/components/doughnut-chart/doughnut-chart.component.html b/src/app/shared/components/doughnut-chart/doughnut-chart.component.html index e8d3f10e2..eefcdeccb 100644 --- a/src/app/shared/components/doughnut-chart/doughnut-chart.component.html +++ b/src/app/shared/components/doughnut-chart/doughnut-chart.component.html @@ -20,9 +20,14 @@

    {{ title() | translate }}

    @for (label of labels(); let i = $index; track i) { -
  • -
    - {{ label }} +
  • +
    +
    + {{ label }} +
    + @if (datasets().length) { + {{ datasets()[0].data[i] }} + }
  • }
    diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index bd00ee06c..15371e8f3 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2708,8 +2708,8 @@ "totalStorageInGb": "Total Storage in GB", "embargoedRegistrations": "Embargoed registrations", "privateProjects": "Private projects", - "publicProjects": "Public Projects", - "publicRegistrations": "Public Registrations" + "publicProjects": "Public projects", + "publicRegistrations": "Public registrations" }, "contact": { "requestAccess": "Request Access", @@ -2724,8 +2724,8 @@ "lastActive": "Last Active", "accountCreated": "Account Created", "preprints": "Preprints", - "publicFiles": "Public Files", - "storageBytes": "Storage (Bytes)", + "filesOnOsf": "Files on OSF", + "totalDataStored": "Total data stored on OSF", "contacts": "Contacts", "customize": "Customize", "sendEmail": "Send Email", From 8a85f6fad55d9bf483459aa9ca52238def04a992 Mon Sep 17 00:00:00 2001 From: mfraezz Date: Mon, 15 Sep 2025 09:58:17 -0400 Subject: [PATCH 10/21] Fix test env SHARE url (#393) --- src/environments/environment.test-osf.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/environments/environment.test-osf.ts b/src/environments/environment.test-osf.ts index c2bef93fb..19fed8086 100644 --- a/src/environments/environment.test-osf.ts +++ b/src/environments/environment.test-osf.ts @@ -5,7 +5,7 @@ export const environment = { production: false, webUrl: 'https://test.osf.io', apiDomainUrl: 'https://api.test.osf.io', - shareTroveUrl: 'https://test-share.osf.io/trove', + shareTroveUrl: 'https://staging-share.osf.io/trove', addonsApiUrl: 'https://addons.test.osf.io/v1', funderApiUrl: 'https://api.crossref.org/', casUrl: 'https://accounts.test.osf.io', From e3b752c9a4ef5e08d70fdcbea43a6911910c24a1 Mon Sep 17 00:00:00 2001 From: nmykhalkevych-exoft Date: Mon, 15 Sep 2025 18:02:43 +0300 Subject: [PATCH 11/21] Fix/wiki (#392) * fix(scroll): scroll to top after navigation end * fix(scroll): scroll to top after navigation end * fix(wiki): wiki bugs --- .../features/project/wiki/wiki.component.html | 3 +- .../registry-wiki.component.html | 4 +-- .../registry-wiki/registry-wiki.component.ts | 5 ++++ .../compare-section.component.scss | 1 + .../compare-section.component.ts | 6 ++-- .../edit-section/edit-section.component.scss | 1 + .../wiki/wiki-list/wiki-list.component.html | 29 ++++++++----------- .../wiki/wiki-list/wiki-list.component.ts | 1 - 8 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/app/features/project/wiki/wiki.component.html b/src/app/features/project/wiki/wiki.component.html index b3ede123b..0f3601362 100644 --- a/src/app/features/project/wiki/wiki.component.html +++ b/src/app/features/project/wiki/wiki.component.html @@ -26,7 +26,7 @@

    } -
    +
    diff --git a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html index 2f5ee68ad..05f489d7a 100644 --- a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html +++ b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html @@ -21,11 +21,11 @@
    diff --git a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.ts b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.ts index 31c66f2c6..f7acf3f79 100644 --- a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.ts +++ b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.ts @@ -18,6 +18,7 @@ import { hasViewOnlyParam } from '@osf/shared/helpers'; import { WikiModes } from '@osf/shared/models'; import { GetCompareVersionContent, + GetComponentsWikiList, GetWikiContent, GetWikiList, GetWikiVersionContent, @@ -57,6 +58,7 @@ export class RegistryWikiComponent { currentWikiId = select(WikiSelectors.getCurrentWikiId); wikiVersions = select(WikiSelectors.getWikiVersions); isWikiVersionLoading = select(WikiSelectors.getWikiVersionsLoading); + componentsWikiList = select(WikiSelectors.getComponentsWikiList); hasViewOnly = computed(() => hasViewOnlyParam(this.router)); @@ -70,6 +72,7 @@ export class RegistryWikiComponent { getWikiVersions: GetWikiVersions, getWikiVersionContent: GetWikiVersionContent, getCompareVersionContent: GetCompareVersionContent, + getComponentsWikiList: GetComponentsWikiList, }); wikiIdFromQueryParams = this.route.snapshot.queryParams['wiki']; @@ -87,6 +90,8 @@ export class RegistryWikiComponent { ) .subscribe(); + this.actions.getComponentsWikiList(ResourceType.Registration, this.resourceId); + this.route.queryParams .pipe( takeUntilDestroyed(), diff --git a/src/app/shared/components/wiki/compare-section/compare-section.component.scss b/src/app/shared/components/wiki/compare-section/compare-section.component.scss index de605d06a..9c4829ccd 100644 --- a/src/app/shared/components/wiki/compare-section/compare-section.component.scss +++ b/src/app/shared/components/wiki/compare-section/compare-section.component.scss @@ -1,3 +1,4 @@ :host { flex: 1 1 25%; + min-height: 300px; } diff --git a/src/app/shared/components/wiki/compare-section/compare-section.component.ts b/src/app/shared/components/wiki/compare-section/compare-section.component.ts index f1af512db..36bd6d22a 100644 --- a/src/app/shared/components/wiki/compare-section/compare-section.component.ts +++ b/src/app/shared/components/wiki/compare-section/compare-section.component.ts @@ -53,8 +53,10 @@ export class CompareSectionComponent { constructor() { effect(() => { - this.selectedVersion = this.versions()[0].id; - this.selectVersion.emit(this.selectedVersion); + this.selectedVersion = this.versions()[0]?.id; + if (this.selectedVersion) { + this.selectVersion.emit(this.selectedVersion); + } }); } onVersionChange(versionId: string): void { diff --git a/src/app/shared/components/wiki/edit-section/edit-section.component.scss b/src/app/shared/components/wiki/edit-section/edit-section.component.scss index a0aadc379..95dc71b83 100644 --- a/src/app/shared/components/wiki/edit-section/edit-section.component.scss +++ b/src/app/shared/components/wiki/edit-section/edit-section.component.scss @@ -1,6 +1,7 @@ :host { flex: 1 1 25%; min-width: 25%; + min-height: 400px; md-editor { display: block; diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html index 30265bcc8..8e7d82866 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html @@ -11,20 +11,15 @@ } @else { @if (expanded()) { -
    - @if (showAddBtn()) { - - } +
    +
    - @if (!viewOnly()) { - @if (!isHomeWikiSelected() || !list().length) { + @if (!viewOnly() && list().length) { + @if (!isHomeWikiSelected()) { {{ item.label | translate }}

    - @if (showAddBtn()) { + @if (!viewOnly()) { (); readonly componentsList = input.required(); - readonly showAddBtn = input(false); readonly isLoading = input(false); readonly viewOnly = input(false); From 68955dc2406ee5c9ae2504b9b3f5d2eaba008c77 Mon Sep 17 00:00:00 2001 From: Lord Business <113387478+bp-cos@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:56:53 -0500 Subject: [PATCH 12/21] Feature/eslint pipeline Fixed all the lint errors and updated sentry (#396) * chore(eslint): added updates to eslint for the pipeline * chore(linting): fixed some linting * chore(error-updates): added hints to the sentry --- src/app/app.config.ts | 27 ++++---- src/app/core/factory/sentry.factory.spec.ts | 19 ++++++ src/app/core/factory/sentry.factory.ts | 49 +++++++++++++++ src/app/core/handlers/global-error.handler.ts | 8 --- src/app/core/handlers/index.ts | 1 - src/app/core/store/user/user.state.ts | 30 +++++---- .../collections-query-sync.service.ts | 4 +- .../google-file-picker.component.spec.ts | 63 ++++++++++++++++--- .../google-file-picker.component.ts | 8 +-- .../helpers/state-error.handler.spec.ts | 4 +- src/app/shared/helpers/state-error.handler.ts | 7 ++- src/app/shared/mappers/user/user.mapper.ts | 4 +- 12 files changed, 169 insertions(+), 55 deletions(-) create mode 100644 src/app/core/factory/sentry.factory.spec.ts create mode 100644 src/app/core/factory/sentry.factory.ts delete mode 100644 src/app/core/handlers/global-error.handler.ts delete mode 100644 src/app/core/handlers/index.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index ac3980d36..47c42c662 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -13,6 +13,7 @@ import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { STATES } from '@core/constants'; import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/factory/application.initialization.factory'; +import { SENTRY_PROVIDER } from '@core/factory/sentry.factory'; import { provideTranslation } from '@core/helpers'; import { authInterceptor, errorInterceptor, viewOnlyInterceptor } from './core/interceptors'; @@ -23,9 +24,15 @@ import * as Sentry from '@sentry/angular'; export const appConfig: ApplicationConfig = { providers: [ - provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })), - provideStore(STATES, withNgxsReduxDevtoolsPlugin({ disabled: false })), + APPLICATION_INITIALIZATION_PROVIDER, + ConfirmationService, + { + provide: ErrorHandler, + useFactory: () => Sentry.createErrorHandler({ showDialog: false }), + }, + importProvidersFrom(TranslateModule.forRoot(provideTranslation())), + MessageService, + provideAnimations(), providePrimeNG({ theme: { preset: CustomPreset, @@ -38,16 +45,10 @@ export const appConfig: ApplicationConfig = { }, }, }), - provideAnimations(), provideHttpClient(withInterceptors([authInterceptor, viewOnlyInterceptor, errorInterceptor])), - importProvidersFrom(TranslateModule.forRoot(provideTranslation())), - ConfirmationService, - MessageService, - - APPLICATION_INITIALIZATION_PROVIDER, - { - provide: ErrorHandler, - useFactory: () => Sentry.createErrorHandler({ showDialog: false }), - }, + provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })), + provideStore(STATES, withNgxsReduxDevtoolsPlugin({ disabled: false })), + provideZoneChangeDetection({ eventCoalescing: true }), + SENTRY_PROVIDER, ], }; diff --git a/src/app/core/factory/sentry.factory.spec.ts b/src/app/core/factory/sentry.factory.spec.ts new file mode 100644 index 000000000..f3af9aa6f --- /dev/null +++ b/src/app/core/factory/sentry.factory.spec.ts @@ -0,0 +1,19 @@ +import { TestBed } from '@angular/core/testing'; + +import { SENTRY_PROVIDER, SENTRY_TOKEN } from './sentry.factory'; + +import * as Sentry from '@sentry/angular'; + +describe('Factory: Sentry', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [SENTRY_PROVIDER], + }); + }); + + it('should provide the Sentry module via the injection token', () => { + const provided = TestBed.inject(SENTRY_TOKEN); + expect(provided).toBe(Sentry); + expect(typeof provided.captureException).toBe('function'); + }); +}); diff --git a/src/app/core/factory/sentry.factory.ts b/src/app/core/factory/sentry.factory.ts new file mode 100644 index 000000000..baac5068e --- /dev/null +++ b/src/app/core/factory/sentry.factory.ts @@ -0,0 +1,49 @@ +import { InjectionToken } from '@angular/core'; + +import * as Sentry from '@sentry/angular'; + +/** + * Injection token used to provide the Sentry module via Angular's dependency injection system. + * + * This token represents the entire Sentry module (`@sentry/angular`), allowing you to inject + * and use Sentry APIs (e.g., `captureException`, `init`, `setUser`, etc.) in Angular services + * or components. + * + * @example + * ```ts + * const Sentry = inject(SENTRY_TOKEN); + * Sentry.captureException(new Error('Something went wrong')); + * ``` + */ +export const SENTRY_TOKEN = new InjectionToken('Sentry'); + +/** + * Angular provider that binds the `SENTRY_TOKEN` to the actual `@sentry/angular` module. + * + * Use this provider in your module or application configuration to make Sentry injectable. + * + * @example + * ```ts + * providers: [ + * SENTRY_PROVIDER, + * ] + * ``` + * + * Inject the Sentry module via the factory token + * private readonly Sentry = inject(SENTRY_TOKEN); + * + * throwError(): void { + * try { + * throw new Error('Test error for Sentry capture'); + * } catch (error) { + * Send the error to Sentry + * this.Sentry.captureException(error); + * } + * } + * + * @see SENTRY_TOKEN + */ +export const SENTRY_PROVIDER = { + provide: SENTRY_TOKEN, + useValue: Sentry, +}; diff --git a/src/app/core/handlers/global-error.handler.ts b/src/app/core/handlers/global-error.handler.ts deleted file mode 100644 index 6d6bcc907..000000000 --- a/src/app/core/handlers/global-error.handler.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ErrorHandler, Injectable } from '@angular/core'; - -@Injectable() -export class GlobalErrorHandler implements ErrorHandler { - handleError(error: unknown): void { - console.error('Error:', error); - } -} diff --git a/src/app/core/handlers/index.ts b/src/app/core/handlers/index.ts deleted file mode 100644 index 400695f14..000000000 --- a/src/app/core/handlers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { GlobalErrorHandler } from './global-error.handler'; diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index da41b3e23..58e2dfd72 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -268,23 +268,21 @@ export class UserState { }; const apiRequest = UserMapper.toAcceptedTermsOfServiceRequest(updatePayload); - return this.userService - .updateUserAcceptedTermsOfService(currentUser.id, apiRequest) - .pipe( - tap((response: User): void => { - if (response.acceptedTermsOfService) { - ctx.patchState({ - currentUser: { - ...state.currentUser, - data: { - ...currentUser, - acceptedTermsOfService: true, - }, + return this.userService.updateUserAcceptedTermsOfService(currentUser.id, apiRequest).pipe( + tap((response: User): void => { + if (response.acceptedTermsOfService) { + ctx.patchState({ + currentUser: { + ...state.currentUser, + data: { + ...currentUser, + acceptedTermsOfService: true, }, - }); - } - }) - ); + }, + }); + } + }) + ); } @Action(ClearCurrentUser) diff --git a/src/app/features/collections/services/collections-query-sync.service.ts b/src/app/features/collections/services/collections-query-sync.service.ts index 4aaaecc33..af92b2e50 100644 --- a/src/app/features/collections/services/collections-query-sync.service.ts +++ b/src/app/features/collections/services/collections-query-sync.service.ts @@ -4,6 +4,7 @@ import { effect, inject, Injectable, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; +import { SENTRY_TOKEN } from '@core/factory/sentry.factory'; import { collectionsSortOptions } from '@osf/features/collections/constants'; import { queryParamsKeys } from '@osf/features/collections/constants/query-params-keys.const'; import { CollectionQueryParams } from '@osf/features/collections/models'; @@ -13,6 +14,7 @@ import { SetPageNumber } from '@shared/stores/collections/collections.actions'; @Injectable() export class CollectionsQuerySyncService { + private readonly Sentry = inject(SENTRY_TOKEN); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); @@ -119,7 +121,7 @@ export class CollectionsQuerySyncService { const parsedFilters: CollectionsFilters = JSON.parse(activeFilters); this.handleParsedFilters(parsedFilters); } catch (error) { - console.error('Error parsing activeFilters from URL:', error); + this.Sentry.captureException(error); } } diff --git a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.spec.ts b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.spec.ts index 8cd5480b6..ac8db2afc 100644 --- a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.spec.ts +++ b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.spec.ts @@ -1,9 +1,11 @@ import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { Observable, of, throwError } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SENTRY_TOKEN } from '@core/factory/sentry.factory'; + import { GoogleFilePickerDownloadService } from './service/google-file-picker.download.service'; import { GoogleFilePickerComponent } from './google-file-picker.component'; @@ -12,11 +14,21 @@ import { OSFTestingModule, OSFTestingStoreModule } from '@testing/osf.testing.mo describe('Component: Google File Picker', () => { let component: GoogleFilePickerComponent; let fixture: ComponentFixture; + const googlePickerServiceSpy = { - loadScript: jest.fn().mockReturnValue(of(void 0)), - loadGapiModules: jest.fn().mockReturnValue(of(void 0)), + loadScript: jest.fn((): Observable => { + return throwLoadScriptError ? throwError(() => new Error('loadScript failed')) : of(void 0); + }), + loadGapiModules: jest.fn((): Observable => { + return throwLoadGapiError ? throwError(() => new Error('loadGapiModules failed')) : of(void 0); + }), }; + let sentrySpy: any; + + let throwLoadScriptError = false; + let throwLoadGapiError = false; + const handleFolderSelection = jest.fn(); const setDeveloperKey = jest.fn().mockReturnThis(); const setAppId = jest.fn().mockReturnThis(); @@ -40,7 +52,16 @@ describe('Component: Google File Picker', () => { selectSnapshot: jest.fn().mockReturnValue('mock-token'), }; + beforeEach(() => { + throwLoadScriptError = false; + throwLoadGapiError = false; + jest.clearAllMocks(); + }); + beforeAll(() => { + throwLoadScriptError = false; + throwLoadGapiError = false; + window.google = { picker: { Action: null, @@ -54,8 +75,6 @@ describe('Component: Google File Picker', () => { describe('isFolderPicker - true', () => { beforeEach(async () => { - jest.clearAllMocks(); - (window as any).google = { picker: { ViewId: { @@ -86,6 +105,7 @@ describe('Component: Google File Picker', () => { await TestBed.configureTestingModule({ imports: [OSFTestingModule, GoogleFilePickerComponent], providers: [ + { provide: SENTRY_TOKEN, useValue: { captureException: jest.fn() } }, { provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy }, { provide: Store, @@ -94,6 +114,9 @@ describe('Component: Google File Picker', () => { ], }).compileComponents(); + sentrySpy = TestBed.inject(SENTRY_TOKEN); + jest.spyOn(sentrySpy, 'captureException'); + fixture = TestBed.createComponent(GoogleFilePickerComponent); component = fixture.componentInstance; fixture.componentRef.setInput('isFolderPicker', true); @@ -108,6 +131,7 @@ describe('Component: Google File Picker', () => { it('should load script and then GAPI modules and initialize picker', () => { expect(googlePickerServiceSpy.loadScript).toHaveBeenCalled(); expect(googlePickerServiceSpy.loadGapiModules).toHaveBeenCalled(); + expect(sentrySpy.captureException).not.toHaveBeenCalled(); expect(component.visible()).toBeTruthy(); expect(component.isGFPDisabled()).toBeFalsy(); @@ -172,7 +196,6 @@ describe('Component: Google File Picker', () => { describe('isFolderPicker - false', () => { beforeEach(async () => { - jest.clearAllMocks(); (window as any).google = { picker: { ViewId: { @@ -203,6 +226,7 @@ describe('Component: Google File Picker', () => { await TestBed.configureTestingModule({ imports: [OSFTestingStoreModule, GoogleFilePickerComponent], providers: [ + { provide: SENTRY_TOKEN, useValue: { captureException: jest.fn() } }, { provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy }, { provide: Store, @@ -211,6 +235,9 @@ describe('Component: Google File Picker', () => { ], }).compileComponents(); + sentrySpy = TestBed.inject(SENTRY_TOKEN); + jest.spyOn(sentrySpy, 'captureException'); + fixture = TestBed.createComponent(GoogleFilePickerComponent); component = fixture.componentInstance; fixture.componentRef.setInput('isFolderPicker', false); @@ -218,18 +245,38 @@ describe('Component: Google File Picker', () => { itemId: 'root-folder-id', }); fixture.componentRef.setInput('handleFolderSelection', jest.fn()); + }); + + it('should fail to load script', () => { + throwLoadScriptError = true; fixture.detectChanges(); + expect(googlePickerServiceSpy.loadScript).toHaveBeenCalled(); + expect(sentrySpy.captureException).toHaveBeenCalledWith(Error('loadScript failed'), { + tags: { + feature: 'google-picker load', + }, + }); + + expect(component.visible()).toBeFalsy(); + expect(component.isGFPDisabled()).toBeTruthy(); }); - it('should load script and then GAPI modules and initialize picker', () => { + it('should load script and then failr GAPI modules', () => { + throwLoadGapiError = true; + fixture.detectChanges(); expect(googlePickerServiceSpy.loadScript).toHaveBeenCalled(); expect(googlePickerServiceSpy.loadGapiModules).toHaveBeenCalled(); - + expect(sentrySpy.captureException).toHaveBeenCalledWith(Error('loadGapiModules failed'), { + tags: { + feature: 'google-picker auth', + }, + }); expect(component.visible()).toBeFalsy(); expect(component.isGFPDisabled()).toBeTruthy(); }); it('should build the picker with correct configuration', () => { + fixture.detectChanges(); component.createPicker(); expect(window.google.picker.DocsView).toHaveBeenCalledWith('docs'); diff --git a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts index 316b666a1..80b8e7f87 100644 --- a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts +++ b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts @@ -7,6 +7,7 @@ import { Button } from 'primeng/button'; import { ChangeDetectionStrategy, Component, inject, input, OnInit, signal } from '@angular/core'; import { ENVIRONMENT } from '@core/constants/environment.token'; +import { SENTRY_TOKEN } from '@core/factory/sentry.factory'; import { StorageItemModel } from '@osf/shared/models'; import { GoogleFileDataModel } from '@osf/shared/models/files/google-file.data.model'; import { GoogleFilePickerModel } from '@osf/shared/models/files/google-file.picker.model'; @@ -24,6 +25,7 @@ import { GoogleFilePickerDownloadService } from './service/google-file-picker.do changeDetection: ChangeDetectionStrategy.OnPush, }) export class GoogleFilePickerComponent implements OnInit { + private readonly Sentry = inject(SENTRY_TOKEN); readonly #translateService = inject(TranslateService); readonly #googlePicker = inject(GoogleFilePickerDownloadService); readonly #environment = inject(ENVIRONMENT); @@ -68,12 +70,10 @@ export class GoogleFilePickerComponent implements OnInit { this.#initializePicker(); this.#loadOauthToken(); }, - // TODO add this error when the Sentry service is working - //error: (err) => console.error('GAPI modules failed:', err), + error: (err) => this.Sentry.captureException(err, { tags: { feature: 'google-picker auth' } }), }); }, - // TODO add this error when the Sentry service is working - // error: (err) => console.error('Script load failed:', err), + error: (err) => this.Sentry.captureException(err, { tags: { feature: 'google-picker load' } }), }); } diff --git a/src/app/shared/helpers/state-error.handler.spec.ts b/src/app/shared/helpers/state-error.handler.spec.ts index ec4a4ba4d..703d3ca90 100644 --- a/src/app/shared/helpers/state-error.handler.spec.ts +++ b/src/app/shared/helpers/state-error.handler.spec.ts @@ -46,7 +46,9 @@ describe('Helper: State Error Handler', () => { }, }); - expect(Sentry.captureException).toHaveBeenCalledWith(error); + expect(Sentry.captureException).toHaveBeenCalledWith(error, { + tags: { feature: 'state error section: mySection', 'state.section': 'mySection' }, + }); await expect(firstValueFrom(result$)).rejects.toThrow('Something went wrong'); }); }); diff --git a/src/app/shared/helpers/state-error.handler.ts b/src/app/shared/helpers/state-error.handler.ts index 6b2fa3dcf..73e60b995 100644 --- a/src/app/shared/helpers/state-error.handler.ts +++ b/src/app/shared/helpers/state-error.handler.ts @@ -6,7 +6,12 @@ import * as Sentry from '@sentry/angular'; export function handleSectionError(ctx: StateContext, section: keyof T, error: Error) { // Report error to Sentry - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { + 'state.section': section.toString(), + feature: `state error section: ${section.toString()}`, + }, + }); // Patch the state to update loading/submitting flags and set the error message ctx.patchState({ diff --git a/src/app/shared/mappers/user/user.mapper.ts b/src/app/shared/mappers/user/user.mapper.ts index 0389a7873..f26a27793 100644 --- a/src/app/shared/mappers/user/user.mapper.ts +++ b/src/app/shared/mappers/user/user.mapper.ts @@ -1,5 +1,6 @@ import { - User, UserAcceptedTermsOfServiceJsonApi, + User, + UserAcceptedTermsOfServiceJsonApi, UserData, UserDataJsonApi, UserDataResponseJsonApi, @@ -74,5 +75,4 @@ export class UserMapper { accepted_terms_of_service: name.acceptedTermsOfService ?? false, }; } - } From c2778cf3c2a18c4548e10dbb599b0aef4d26ab23 Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 15 Sep 2025 22:54:49 +0300 Subject: [PATCH 13/21] Fix/main to dev (#395) * Update develop from main (#322) * Fix/improvements (#319) * fix(meetings): fixed meetings small issues * fix(tooltips): added tooltips * fix(table): updated sorting * fix(settings): fixed update project * fix(bookmarks): updated bookmarks * fix(my-registrations): fixed my registrations * fix(developer-apps): fixed developer apps * fix(settings): updated tokens and notifications * fix(translation): removed dot * fix(info-icon): updated info icon translate * fix(profile-settings): fixed profile settings * fix(test): updated tests * fix(settings): updated settings * fix(user-emails): updated adding emails to user account * fix(tests): updated tests * fix(clean-up): clean up * fix(models): updated models * fix(models): updated region and license models * fix(styles): moved styles from assets * fix(test): fixed institution loading and test for view only link * fix(analytics): added check if is public * fix(analytics): updated analytics feature * fix(analytics): show message when data loaded * fix(view-only-links): updated view only links * fix(view only links): added shared components * fix(tests): fixed tests * fix(unit-tests): updated jest config * fix(view-only-links): update view only links for components * fix(create-view-link): added logic for uncheck --------- Co-authored-by: Nazar Semets * Test/387 settings page tokens (#318) * test(tokens): added new tests * test(tokens): added new unit tests * test(tokens): fixed tests and jest.config * test(tokens): fixed pr comments * Fix - Search (#286) * feat(search): added generic search component * feat(search): improved search for institutions * feat(search): remove unused files * feat(search): fixed some issues * fix(search): removed comments * fix(profile): renamed it to profile * fix(updates): updates * fix(branding): Minor fixed regarding provider hero for preprints and registry * refactor(search-results-container): Encapsulated some logic, reduced duplication * refactor(search-results-container): Encapsulated tabs logic * refactor(search): Refactored partly search section for preprints and profile * refactor(search): Refactored search logic for global, institutions page, registrations page search * refactor(search): Refactored search logic for global, institutions page, registrations page search * refactor(search): Refactored search logic for profile * feat(profile): Implemented my-profile and user/:id pages * refactor(preprint-provider-discover): Removed search section that uses old approach * refactor(search): Create shared component that encapsulates search logic and reused across the app * refactor(shared-search): Extracted state model. Reduced duplications. Fixed IndexValueSearch filters * refactor(search): Using ResourceType instead of ResourceTab. Fixed params for index-value-search * refactor(search-models): Cleaned up models - renamed files, moved models to appropriate locations * refactor(index-card-search): Refactored models * fix(resource-card): Fixed resource-card component * fix(resource-card-secondary-metadata): Fixed resource-card component * fix(search): Fixed PR comments and conflicts after merge * refactor(search): Renamed OsfSearch- to GlobalSearch- * fix(unit-tests): fixed unit tests --------- Co-authored-by: volodyayakubovskyy Co-authored-by: nsemets * Fix/557 missing tooltip (#320) * fix(tooltip): added tooltip to next button * fix(emails): fixed emails bug * chore(test-env): added test env (#321) * Chore/test docs added more docs and updated docs in the ever expanding evolution. (#309) * chore(testing-docs): incremental update to the testing docs * chore(diagram): updated the ngx application diagram * chore(indexes): added indexes to all files * docs(updates): added new docs and explanations * chore(pr-updates): updated the files based on pr feedback --------- Co-authored-by: nsemets Co-authored-by: Nazar Semets Co-authored-by: dinlvkdn <104976612+dinlvkdn@users.noreply.github.com> Co-authored-by: rrromchIk <90086332+rrromchIk@users.noreply.github.com> Co-authored-by: volodyayakubovskyy * Feat(8653): Implement view tracking for registrations and preprints (#308) * feat(datacite-tracker): implemented datacite view tracking for registries and preprints * chore(datacite-tracker): refactored doi extraction to be less repetitive * fix(datacite-tracker): reverted undesired refactor * chore(datacite-tracker): added tests to registry component * fix(datacite-tracker): fixed datacite tracker effect * chore(datacite-tracker): added tests to project and preprint components * [ENG-8624] feat(registries): add context to registration submission + rearrange title and description layout (#304) * feat(registries): add context to registration submission + rearrange layout * feat(registries): CR followup * feat(registries): Add test for TagsComponent * feat(registries): Add moar tests * feat(registries): CR followup * [ENG-8504] Show Osf introduction video and Collections,Institutions, Registries, Preprints url banners if user have not created any project for home (/dashboard) tab (#301) * Show Osf introduction video and Collections,Institutions, Registries, Preprints url banners if user have not created any project for home (/dashboard) tab * 1. add translations 2. add loading ( ) - otherwise interface show video and afterward search (shown in details in github PR 301 video) 3. maybe there is better solution for existsProjects() || cd angular-osfsearchControl?.value?.length to check if user has at least one project * align footer content left * use angular routerLink approach for * add margin bottom for Visit button to look it better on resizing * update footer formatting * fix(dashboard): Fix [WARNING] NG8107 * fix(dashboard): remove not used variable * fix(dashboard): fix code format by running "npm run lint:fix && npm run format" * fix(dashboard): use .png names without guid * implement unit testing for dashboard when there is a project and there is no project * remove redundant footer RPCB and RCP links * move dashboard images alt text to en.json * add test for openInfoLink() * update(dashboard): use dashboard.data for mocking * update(dashboard): add test to show that products footer images no exists on spinner run and are rendering after it finished running * fix(dashboard): npm run lint:fix && npm run format * add missing code for dashboard * remove redundant imports for dashboard test * use providers -> useValue mock approach for dashboard * resolve CR comments * Fix/develop conflicts (#332) * Fix/improvements (#319) * fix(meetings): fixed meetings small issues * fix(tooltips): added tooltips * fix(table): updated sorting * fix(settings): fixed update project * fix(bookmarks): updated bookmarks * fix(my-registrations): fixed my registrations * fix(developer-apps): fixed developer apps * fix(settings): updated tokens and notifications * fix(translation): removed dot * fix(info-icon): updated info icon translate * fix(profile-settings): fixed profile settings * fix(test): updated tests * fix(settings): updated settings * fix(user-emails): updated adding emails to user account * fix(tests): updated tests * fix(clean-up): clean up * fix(models): updated models * fix(models): updated region and license models * fix(styles): moved styles from assets * fix(test): fixed institution loading and test for view only link * fix(analytics): added check if is public * fix(analytics): updated analytics feature * fix(analytics): show message when data loaded * fix(view-only-links): updated view only links * fix(view only links): added shared components * fix(tests): fixed tests * fix(unit-tests): updated jest config * fix(view-only-links): update view only links for components * fix(create-view-link): added logic for uncheck --------- Co-authored-by: Nazar Semets * Test/387 settings page tokens (#318) * test(tokens): added new tests * test(tokens): added new unit tests * test(tokens): fixed tests and jest.config * test(tokens): fixed pr comments * Fix - Search (#286) * feat(search): added generic search component * feat(search): improved search for institutions * feat(search): remove unused files * feat(search): fixed some issues * fix(search): removed comments * fix(profile): renamed it to profile * fix(updates): updates * fix(branding): Minor fixed regarding provider hero for preprints and registry * refactor(search-results-container): Encapsulated some logic, reduced duplication * refactor(search-results-container): Encapsulated tabs logic * refactor(search): Refactored partly search section for preprints and profile * refactor(search): Refactored search logic for global, institutions page, registrations page search * refactor(search): Refactored search logic for global, institutions page, registrations page search * refactor(search): Refactored search logic for profile * feat(profile): Implemented my-profile and user/:id pages * refactor(preprint-provider-discover): Removed search section that uses old approach * refactor(search): Create shared component that encapsulates search logic and reused across the app * refactor(shared-search): Extracted state model. Reduced duplications. Fixed IndexValueSearch filters * refactor(search): Using ResourceType instead of ResourceTab. Fixed params for index-value-search * refactor(search-models): Cleaned up models - renamed files, moved models to appropriate locations * refactor(index-card-search): Refactored models * fix(resource-card): Fixed resource-card component * fix(resource-card-secondary-metadata): Fixed resource-card component * fix(search): Fixed PR comments and conflicts after merge * refactor(search): Renamed OsfSearch- to GlobalSearch- * fix(unit-tests): fixed unit tests --------- Co-authored-by: volodyayakubovskyy Co-authored-by: nsemets * Fix/557 missing tooltip (#320) * fix(tooltip): added tooltip to next button * fix(emails): fixed emails bug * chore(test-env): added test env (#321) * Chore/test docs added more docs and updated docs in the ever expanding evolution. (#309) * chore(testing-docs): incremental update to the testing docs * chore(diagram): updated the ngx application diagram * chore(indexes): added indexes to all files * docs(updates): added new docs and explanations * chore(pr-updates): updated the files based on pr feedback * Fix/registrations (#326) * fix(settings): updated settings routes * fix(registry-links): updated registry links * fix(registration): updated components, resource and links * fix(wiki): updated wiki * fix(redirect-link): removed it (#327) * [ENG-8505] Finished adding the GFP to the files page (#325) * feat(eng-8505): Added the initial google drive button * feat(eng-8505): add the accountid with tests * feat(more-tests): updated tests * feat(eng-8505): updates for tests * feat(eng-8505): finishing updates for the google file picker * chore(test fixes): updates to broken tests * chore(pr updates): add updates based on pr feedback and more docs --------- Co-authored-by: Nazar Semets Co-authored-by: dinlvkdn <104976612+dinlvkdn@users.noreply.github.com> Co-authored-by: rrromchIk <90086332+rrromchIk@users.noreply.github.com> Co-authored-by: volodyayakubovskyy Co-authored-by: Lord Business <113387478+bp-cos@users.noreply.github.com> * [ENG-8639] add tags to files detail (#324) * chore(meta-tags): cleaner meta-tag cleanup (without urls) * chore(meta-tags): add full name to contributor tag * feat(meta-tags): add meta tags to file-detail page * fix(meta-tags): use image that exists * feat(ci): separate linting from tests (#345) * Feat(datacite-tracker): implemented file view and download tracking (#335) * feat(datacite-tracker): implemented file view and download tracking * feat(datacite-tracker): implemented preprint version download tracking * chore(datacite-tracker): rewritten existing tests to respect recent refactor * chore(datacite-tracker): added tests for file downloads tracking * chore(datacite-tracker): added tests for leftover components and pr comment fixes * Feat(ENG-8778): Implement Cookie consent message (#353) * feat(cookie-consent): added toast which asks for cookie consent * chore(cookie-consent): added tests for cookie consent * chore(datacite-tracker): fixed review comments * [eng-8741] Added Sentry to the app (#340) * chore(config-service): added a config service with tests * feat(sentry): added sentry to the app and state-error handler with tests * feat(promise): added a promise for application loading * refactor(rename): renamed the files and consts to be more explicit * feat(google-tag-manager): added a google tag manager factor * feat(gtm): added the logic to get the google tag manager working * feat(pr-review): add code from pr * feat(eng-8741): added conditions if the config variables are not present * chore(nit-pick-for-brian-g): add a gitignore * Fix/dev to main (#368) * Fix/improvements (#319) * fix(meetings): fixed meetings small issues * fix(tooltips): added tooltips * fix(table): updated sorting * fix(settings): fixed update project * fix(bookmarks): updated bookmarks * fix(my-registrations): fixed my registrations * fix(developer-apps): fixed developer apps * fix(settings): updated tokens and notifications * fix(translation): removed dot * fix(info-icon): updated info icon translate * fix(profile-settings): fixed profile settings * fix(test): updated tests * fix(settings): updated settings * fix(user-emails): updated adding emails to user account * fix(tests): updated tests * fix(clean-up): clean up * fix(models): updated models * fix(models): updated region and license models * fix(styles): moved styles from assets * fix(test): fixed institution loading and test for view only link * fix(analytics): added check if is public * fix(analytics): updated analytics feature * fix(analytics): show message when data loaded * fix(view-only-links): updated view only links * fix(view only links): added shared components * fix(tests): fixed tests * fix(unit-tests): updated jest config * fix(view-only-links): update view only links for components * fix(create-view-link): added logic for uncheck --------- Co-authored-by: Nazar Semets * Test/387 settings page tokens (#318) * test(tokens): added new tests * test(tokens): added new unit tests * test(tokens): fixed tests and jest.config * test(tokens): fixed pr comments * Fix - Search (#286) * feat(search): added generic search component * feat(search): improved search for institutions * feat(search): remove unused files * feat(search): fixed some issues * fix(search): removed comments * fix(profile): renamed it to profile * fix(updates): updates * fix(branding): Minor fixed regarding provider hero for preprints and registry * refactor(search-results-container): Encapsulated some logic, reduced duplication * refactor(search-results-container): Encapsulated tabs logic * refactor(search): Refactored partly search section for preprints and profile * refactor(search): Refactored search logic for global, institutions page, registrations page search * refactor(search): Refactored search logic for global, institutions page, registrations page search * refactor(search): Refactored search logic for profile * feat(profile): Implemented my-profile and user/:id pages * refactor(preprint-provider-discover): Removed search section that uses old approach * refactor(search): Create shared component that encapsulates search logic and reused across the app * refactor(shared-search): Extracted state model. Reduced duplications. Fixed IndexValueSearch filters * refactor(search): Using ResourceType instead of ResourceTab. Fixed params for index-value-search * refactor(search-models): Cleaned up models - renamed files, moved models to appropriate locations * refactor(index-card-search): Refactored models * fix(resource-card): Fixed resource-card component * fix(resource-card-secondary-metadata): Fixed resource-card component * fix(search): Fixed PR comments and conflicts after merge * refactor(search): Renamed OsfSearch- to GlobalSearch- * fix(unit-tests): fixed unit tests --------- Co-authored-by: volodyayakubovskyy Co-authored-by: nsemets * Fix/557 missing tooltip (#320) * fix(tooltip): added tooltip to next button * fix(emails): fixed emails bug * chore(test-env): added test env (#321) * Chore/test docs added more docs and updated docs in the ever expanding evolution. (#309) * chore(testing-docs): incremental update to the testing docs * chore(diagram): updated the ngx application diagram * chore(indexes): added indexes to all files * docs(updates): added new docs and explanations * chore(pr-updates): updated the files based on pr feedback * Fix/registrations (#326) * fix(settings): updated settings routes * fix(registry-links): updated registry links * fix(registration): updated components, resource and links * fix(wiki): updated wiki * fix(redirect-link): removed it (#327) * [ENG-8505] Finished adding the GFP to the files page (#325) * feat(eng-8505): Added the initial google drive button * feat(eng-8505): add the accountid with tests * feat(more-tests): updated tests * feat(eng-8505): updates for tests * feat(eng-8505): finishing updates for the google file picker * chore(test fixes): updates to broken tests * chore(pr updates): add updates based on pr feedback and more docs * Test/565 my projects (#334) * test(my-projects): added unit tests * test(create-project-dialog): fixed errors * test(my-projects): fixed errors * test(create-project-dialog): fixed * Feat/550 file widget (#323) * feat(file): file-widget * feat(file): fixed move file * Update src/app/features/project/overview/components/files-widget/files-widget.component.html Co-authored-by: nsemets * feat(file): resolve comments * feat(file): refactoring * feat(file): remove sorting storage * feat(file): navigate to file --------- Co-authored-by: nsemets * Fix/metadata (#336) * fix(metadata): updated metadata * fix(metadata): fixes * fix(my-profile): fixed route * fix(models): updated some models * fix(tests): fixed unit tests * chore(env): updated env files (#328) * chore(env): updated env files * fix(env): updated api url * fix(env): updated env for files widget * fix(redirect): fixed redirect after registration (#339) * Test/576 analytics (#341) * test(analytics): added new unit tests * test(analytics): fixed * Feat - Admin institution filters (#333) * refactor(admin-institutions): Refactored and simplified code * fix(admin-institutions): Fixed columns and sorting * feat(institutions-projects): Implemented filters for projects tab. Fixed search result parsing * feat(institutions-preprints): Implemented filters for preprints tab. Fixed search result parsing * feat(institutions-registration): Implemented filters for registration tab * fix(institutions-admin): Fixed resources to table mapping * fix(institutions-admin): Fixed hardcoded institution iri for index-value-search * refactor(institutions-admin-service): Extracted apiUrl to local variable * fix(resource-model): Fix after merge conflict * fix(institution-users): Fixed links to user * fix(institutions-dashboard): Added translations * fix(institutions-dashboard): Fixed tests * fix(admin-institutions): Refactored filters to reusable component * fix(admin-institutions): Fixed comments * Fix - Preprints bugs (#337) * fix(metadata-step): Made Publication DOI field optional * fix(preprints-landing): Contact Us button titlecased, Show example button link fixed * fix(create-new-version): Handled back button * fix(preprint-moderation): Fixed sorting for submissions * fix(license-component): Clearing all fields on cancel button click * fix(preprint-stepper): Fixed add-project-form * Fix/affiliated institutions (#342) * fix(metadata): updated metadata * fix(metadata): fixes * fix(my-profile): fixed route * fix(models): updated some models * fix(tests): fixed unit tests * fix(institutions): updated institutions * fix(my-projects): bookmarks * fix(institutions): updated affiliated institutions and fixed some bugs * fix(tests): fixed tests * fix(tests): updated if statement * fix(bugs): fixed some bugs * Fix/affiliated institutions (#344) * fix(metadata): updated metadata * fix(metadata): fixes * fix(my-profile): fixed route * fix(models): updated some models * fix(tests): fixed unit tests * fix(institutions): updated institutions * fix(my-projects): bookmarks * fix(institutions): updated affiliated institutions and fixed some bugs * fix(tests): fixed tests * fix(tests): updated if statement * fix(bugs): fixed some bugs * fix(files): updated files * Feat/185 - Linked services (#338) * feat(verified-links): fixed minor linked resources bugs * feat(linked-services): added link addons configuration logic to the project settings * feat(linked-services): added linked services page and nav tab, added link addons to settings * feat(linked-services): fixed issues after merging * feat(linked-services): fixed pre-push husky file * fix(linked-services): updated tables * feat(linked-services): added link disabling logic for storage item selector * feat(linked-services): fixed comments and minor bugs * feat(linked-services): fixed test issues * feat(linked-services): fixed storage description display issue on addon terms page * fix(recent-activity): resolved view only link addon logging issue --------- Co-authored-by: nsemets * Fix/clean code (#347) * fix(packages): removed unused packages * fix(protected): removed protected in all files * fix(styles): removed some mixins and scss files * fix(classes): removed old classes --------- Co-authored-by: Nazar Semets * fix(file): downloads link (#346) * Test/580 home page (#348) * test(router): added route mocks * test(router): added tests for dashboard page * test(router): added tests for home page * test(analytics): fixed * Fix/overview (#349) * fix(add): updated add project and component dialog * fix(move-file): updated button * fix(overview): updated project overview * fix(children): refactor get children request (#351) * Fix/overview (#352) * fix(add): updated add project and component dialog * fix(move-file): updated button * fix(overview): updated project overview * fix(updates): clean up some code * fix(regions): updated regions state and removed duplication * fix(providers): updated models for providers * fix(test): fixed unit test * fix(bugs): fixed bugs (#354) * Feat(project-redirect-modal): added project redirect modal (#350) * fix(add-project-redirect-modal): added project redirect modal * refactor(add-project-redirect-modal): added pipe to the modal subscription * Test/395 institutions components (#358) * test(institutions): tested institutions, institutions-list, institutions-search components * fix(tests): fixed tests * Fix/registration bugs (#356) * fix(registration): fixed 594 * fix(registration-bugs): fixed some registrations bugs * fix(registration): fixed registrations fr project * fix(registration): remove useless font class * fix(tags-input): Fix strange behaviour when removing first tag (#361) * Fix/accessibility (#359) * fix(accessibility): update accessibility for some pages * fix(accessibility): added aria labels * fix(updates): clean up code * fix(routes): removed unused route * fix(accessibility): added aria labels to institutions * fix(accessibility): removed aria label for header button * fix(metadata): fixed spacing * fix(resource-metadata): fixed url --------- Co-authored-by: Nazar Semets * Fix/search filters (#360) * fix(global-search-filters): Resolved TODOs * fix(global-search-filters): Fixed and simplified filters logic code * fix(global-search-resource-card): Made links open in a new tab * fix(global-search-filters): Added cardSearchResultCount to filter option * fix(global-search-filters): Fixed boolean filters selected state * fix(global-search-filters): Fixed after merge conflict * fix(global-search-filters): Fixed PR comments * Feat(ENG-8625): add Recent Activity to registrations (#315) * feat(registration-recent-activity): add Recent Activity to registrations * feat(registration-recent-activity): code fixes regarding comments * feat(registration-recent-activity): second round of code fixes regarding review comments * feat(registration-recent-activity): run lint fix * fix(recent-activity): updated some code (#364) * fix(errors): fixed some issues * fix(dashboard): fixed bugs * fix(tests): fixed unit tests * fix(config): added error handling --------- Co-authored-by: Nazar Semets Co-authored-by: dinlvkdn <104976612+dinlvkdn@users.noreply.github.com> Co-authored-by: rrromchIk <90086332+rrromchIk@users.noreply.github.com> Co-authored-by: volodyayakubovskyy Co-authored-by: Lord Business <113387478+bp-cos@users.noreply.github.com> Co-authored-by: nmykhalkevych-exoft Co-authored-by: Roman Nastyuk Co-authored-by: sh-andriy <105591819+sh-andriy@users.noreply.github.com> * [eng-8768] Added helpscout to various pages. (#357) * feat(eng-8768): add the help-scout to preprints * feat(eng-8768): add the help-scout to projects * feat(eng-8768): add the help-scout to registration * feat(eng-8768): add the help-scout to settings and registries files * feat(pr-updates): updated code based on the pr * [ENG-8777] Add scheduled banner (#371) * feat(banner): Add services and models for scheduled banners * feat(osf): Add scheduled banner * feat(banner): CR followup * feat(banners): CR followup again * feat(banners): Use primeflex classesl; remove scss file --------- Co-authored-by: Lord Business <113387478+bp-cos@users.noreply.github.com> Co-authored-by: Nazar Semets Co-authored-by: dinlvkdn <104976612+dinlvkdn@users.noreply.github.com> Co-authored-by: rrromchIk <90086332+rrromchIk@users.noreply.github.com> Co-authored-by: volodyayakubovskyy Co-authored-by: Oleh Paduchak <158075011+opaduchak@users.noreply.github.com> Co-authored-by: Yuhuai Liu Co-authored-by: mkovalua Co-authored-by: abram axel booth Co-authored-by: nmykhalkevych-exoft Co-authored-by: Roman Nastyuk Co-authored-by: sh-andriy <105591819+sh-andriy@users.noreply.github.com> --- src/@types/global.d.ts | 41 ++++++++++ src/app/app.config.ts | 23 +++++- .../core/constants/ngxs-states.constant.ts | 2 + src/app/core/factory/window.factory.spec.ts | 32 ++++++++ src/app/core/factory/window.factory.ts | 26 ++++++ .../core/services/help-scout.service.spec.ts | 65 +++++++++++++++ src/app/core/services/help-scout.service.ts | 79 +++++++++++++++++++ .../pages/dashboard/dashboard.component.html | 3 + .../pages/dashboard/dashboard.component.ts | 3 + .../institutions-list.component.html | 2 +- .../institutions-list.component.ts | 2 + .../enums/preprint-submissions-sort.enum.ts | 4 +- .../preprints/preprints.component.spec.ts | 29 +++++-- .../features/preprints/preprints.component.ts | 14 +++- .../project/project.component.spec.ts | 36 ++++++++- src/app/features/project/project.component.ts | 15 +++- .../files-control.component.spec.ts | 37 ++++++++- .../files-control/files-control.component.ts | 21 ++++- .../registries-landing.component.html | 2 +- .../registries-landing.component.ts | 2 + .../registries/registries.component.spec.ts | 34 ++++++-- .../registries/registries.component.ts | 15 +++- .../settings-container.component.spec.ts | 38 +++++++-- .../settings/settings-container.component.ts | 16 +++- src/app/shared/components/index.ts | 1 + .../reusable-filter.component.ts | 8 ++ .../schedule-banner.component.spec.ts} | 0 .../scheduled-banner.component.html | 22 ++++++ .../scheduled-banner.component.ts | 33 ++++++++ src/app/shared/mappers/banner.mapper.ts | 20 +++++ .../shared/models/banner.json-api.model.ts | 18 +++++ src/app/shared/models/banner.model.ts | 13 +++ src/app/shared/services/banners.service.ts | 38 +++++++++ .../shared/stores/banners/banners.actions.ts | 3 + .../shared/stores/banners/banners.model.ts | 14 ++++ .../stores/banners/banners.selectors.ts | 16 ++++ .../shared/stores/banners/banners.state.ts | 43 ++++++++++ src/app/shared/stores/banners/index.ts | 4 + src/app/shared/stores/index.ts | 1 + src/testing/osf.testing.module.ts | 9 ++- 40 files changed, 740 insertions(+), 44 deletions(-) create mode 100644 src/app/core/factory/window.factory.spec.ts create mode 100644 src/app/core/factory/window.factory.ts create mode 100644 src/app/core/services/help-scout.service.spec.ts create mode 100644 src/app/core/services/help-scout.service.ts rename src/app/shared/{models/addons/configured-storage-addon.model.ts => components/scheduled-banner/schedule-banner.component.spec.ts} (100%) create mode 100644 src/app/shared/components/scheduled-banner/scheduled-banner.component.html create mode 100644 src/app/shared/components/scheduled-banner/scheduled-banner.component.ts create mode 100644 src/app/shared/mappers/banner.mapper.ts create mode 100644 src/app/shared/models/banner.json-api.model.ts create mode 100644 src/app/shared/models/banner.model.ts create mode 100644 src/app/shared/services/banners.service.ts create mode 100644 src/app/shared/stores/banners/banners.actions.ts create mode 100644 src/app/shared/stores/banners/banners.model.ts create mode 100644 src/app/shared/stores/banners/banners.selectors.ts create mode 100644 src/app/shared/stores/banners/banners.state.ts create mode 100644 src/app/shared/stores/banners/index.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 582a45a49..1d6eba8ad 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -1,10 +1,51 @@ import type gapi from 'gapi-script'; // or just `import gapi from 'gapi-script';` +/** + * Extends the global `Window` interface to include additional properties used by the application, + * such as Google APIs (`gapi`, `google.picker`) and the `dataLayer` for analytics or GTM integration. + */ declare global { interface Window { + /** + * Represents the Google API client library (`gapi`) attached to the global window. + * Used for OAuth, Picker API, Drive API, etc. + * + * @see https://developers.google.com/api-client-library/javascript/ + */ gapi: typeof gapi; + + /** + * Contains Google-specific UI services attached to the global window, + * such as the `google.picker` API. + * + * @see https://developers.google.com/picker/docs/ + */ google: { + /** + * Reference to the Google Picker API used for file selection and Drive integration. + */ picker: typeof google.picker; }; + + /** + * Global analytics `dataLayer` object used by Google Tag Manager (GTM). + * Can store custom application metadata for tracking and event push. + * + * @property resourceType - The type of resource currently being viewed (e.g., 'project', 'file', etc.) + * @property loggedIn - Indicates whether the user is currently authenticated. + */ + dataLayer: { + /** + * The type of content or context being viewed (e.g., "project", "node", etc.). + * Optional — may be undefined depending on when or where GTM initializes. + */ + resourceType: string | undefined; + + /** + * Indicates if the current user is authenticated. + * Used for segmenting analytics based on login state. + */ + loggedIn: boolean; + }; } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 47c42c662..b96898162 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -7,12 +7,19 @@ import { ConfirmationService, MessageService } from 'primeng/api'; import { providePrimeNG } from 'primeng/config'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; -import { ApplicationConfig, ErrorHandler, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; +import { + ApplicationConfig, + ErrorHandler, + importProvidersFrom, + PLATFORM_ID, + provideZoneChangeDetection, +} from '@angular/core'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { STATES } from '@core/constants'; import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/factory/application.initialization.factory'; +import { WINDOW, windowFactory } from '@core/factory/window.factory'; import { SENTRY_PROVIDER } from '@core/factory/sentry.factory'; import { provideTranslation } from '@core/helpers'; @@ -46,6 +53,20 @@ export const appConfig: ApplicationConfig = { }, }), provideHttpClient(withInterceptors([authInterceptor, viewOnlyInterceptor, errorInterceptor])), + importProvidersFrom(TranslateModule.forRoot(provideTranslation())), + ConfirmationService, + MessageService, + + APPLICATION_INITIALIZATION_PROVIDER, + { + provide: ErrorHandler, + useFactory: () => Sentry.createErrorHandler({ showDialog: false }), + }, + { + provide: WINDOW, + useFactory: windowFactory, + deps: [PLATFORM_ID], + }, provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })), provideStore(STATES, withNgxsReduxDevtoolsPlugin({ disabled: false })), provideZoneChangeDetection({ eventCoalescing: true }), diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index fec700212..10337da01 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -6,6 +6,7 @@ import { MetadataState } from '@osf/features/metadata/store'; import { ProjectOverviewState } from '@osf/features/project/overview/store'; import { RegistrationsState } from '@osf/features/project/registrations/store'; import { AddonsState, CurrentResourceState, WikiState } from '@osf/shared/stores'; +import { BannersState } from '@osf/shared/stores/banners'; import { GlobalSearchState } from '@shared/stores/global-search'; import { InstitutionsState } from '@shared/stores/institutions'; import { LicensesState } from '@shared/stores/licenses'; @@ -28,4 +29,5 @@ export const STATES = [ MetadataState, CurrentResourceState, GlobalSearchState, + BannersState, ]; diff --git a/src/app/core/factory/window.factory.spec.ts b/src/app/core/factory/window.factory.spec.ts new file mode 100644 index 000000000..0211cf905 --- /dev/null +++ b/src/app/core/factory/window.factory.spec.ts @@ -0,0 +1,32 @@ +// src/app/window.spec.ts +import { isPlatformBrowser } from '@angular/common'; + +import { windowFactory } from './window.factory'; + +jest.mock('@angular/common', () => ({ + isPlatformBrowser: jest.fn(), +})); + +describe('windowFactory', () => { + const mockWindow = globalThis.window; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return window object if platform is browser', () => { + (isPlatformBrowser as jest.Mock).mockReturnValue(true); + + const result = windowFactory('browser'); + expect(isPlatformBrowser).toHaveBeenCalledWith('browser'); + expect(result).toBe(mockWindow); + }); + + it('should return empty object if platform is not browser', () => { + (isPlatformBrowser as jest.Mock).mockReturnValue(false); + + const result = windowFactory('server'); + expect(isPlatformBrowser).toHaveBeenCalledWith('server'); + expect(result).toEqual({}); + }); +}); diff --git a/src/app/core/factory/window.factory.ts b/src/app/core/factory/window.factory.ts new file mode 100644 index 000000000..395524e44 --- /dev/null +++ b/src/app/core/factory/window.factory.ts @@ -0,0 +1,26 @@ +import { isPlatformBrowser } from '@angular/common'; +import { InjectionToken } from '@angular/core'; + +export const WINDOW = new InjectionToken('Global Window Object'); + +/** + * A factory function to provide the global `window` object in Angular. + * + * This is useful for making Angular applications **Universal-compatible** (i.e., supporting server-side rendering). + * It conditionally returns the real `window` only when the code is running in the **browser**, not on the server. + * + * @param platformId - The Angular platform ID token (injected by Angular) that helps detect the execution environment. + * @returns The actual `window` object if running in the browser, otherwise a mock object `{}` for SSR environments. + * + * @see https://angular.io/api/core/PLATFORM_ID + * @see https://angular.io/guide/universal + */ +export function windowFactory(platformId: string): Window | object { + // Check if we're running in the browser (vs server-side) + if (isPlatformBrowser(platformId)) { + return window; + } + + // Return an empty object as a safe fallback during server-side rendering + return {}; +} diff --git a/src/app/core/services/help-scout.service.spec.ts b/src/app/core/services/help-scout.service.spec.ts new file mode 100644 index 000000000..3bb9469dc --- /dev/null +++ b/src/app/core/services/help-scout.service.spec.ts @@ -0,0 +1,65 @@ +import { Store } from '@ngxs/store'; + +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { WINDOW } from '@core/factory/window.factory'; +import { UserSelectors } from '@core/store/user/user.selectors'; + +import { HelpScoutService } from './help-scout.service'; + +describe('HelpScoutService', () => { + let storeMock: Partial; + let service: HelpScoutService; + let mockWindow: any; + const authSignal = signal(false); + + beforeEach(() => { + mockWindow = { + dataLayer: {}, + }; + + storeMock = { + selectSignal: jest.fn().mockImplementation((selector) => { + if (selector === UserSelectors.isAuthenticated) { + return authSignal; + } + return signal(null); // fallback + }), + }; + + TestBed.configureTestingModule({ + providers: [{ provide: WINDOW, useValue: mockWindow }, HelpScoutService, { provide: Store, useValue: storeMock }], + }); + + service = TestBed.inject(HelpScoutService); + }); + + it('should initialize dataLayer with default values', () => { + expect(mockWindow.dataLayer).toEqual({ + loggedIn: false, + resourceType: undefined, + }); + }); + + it('should set the resourceType', () => { + service.setResourceType('project'); + expect(mockWindow.dataLayer.resourceType).toBe('project'); + }); + + it('should unset the resourceType', () => { + service.setResourceType('node'); + service.unsetResourceType(); + expect(mockWindow.dataLayer.resourceType).toBeUndefined(); + }); + + it('should set loggedIn to true or false', () => { + authSignal.set(true); + TestBed.flushEffects(); + expect(mockWindow.dataLayer.loggedIn).toBeTruthy(); + + authSignal.set(false); + TestBed.flushEffects(); + expect(mockWindow.dataLayer.loggedIn).toBeFalsy(); + }); +}); diff --git a/src/app/core/services/help-scout.service.ts b/src/app/core/services/help-scout.service.ts new file mode 100644 index 000000000..1be92f992 --- /dev/null +++ b/src/app/core/services/help-scout.service.ts @@ -0,0 +1,79 @@ +import { Store } from '@ngxs/store'; + +import { effect, inject, Injectable } from '@angular/core'; + +import { WINDOW } from '@core/factory/window.factory'; +import { UserSelectors } from '@osf/core/store/user'; + +/** + * HelpScoutService manages GTM-compatible `dataLayer` state + * related to user authentication and resource type. + * + * This service ensures that specific fields in the global + * `window.dataLayer` object are set correctly for downstream + * tools like Google Tag Manager or HelpScout integrations. + */ +@Injectable({ + providedIn: 'root', +}) +export class HelpScoutService { + /** + * Reference to the global window object, injected via a factory. + * Used to access and manipulate `dataLayer` for tracking. + */ + private window = inject(WINDOW); + + /** + * Angular Store instance used to access application state via NgRx. + * Injected using Angular's `inject()` function. + * + * @private + * @type {Store} + */ + private store = inject(Store); + + /** + * Signal that represents the current authentication state of the user. + * Derived from the NgRx selector `UserSelectors.isAuthenticated`. + * + * Can be used reactively in effects or template bindings to update UI or behavior + * based on whether the user is logged in. + * + * @private + * @type {Signal} + */ + private isAuthenticated = this.store.selectSignal(UserSelectors.isAuthenticated); + + /** + * Initializes the `dataLayer` with default values. + * + * - `loggedIn`: false + * - `resourceType`: undefined + */ + constructor() { + this.window.dataLayer = { + loggedIn: false, + resourceType: undefined, + }; + + effect(() => { + this.window.dataLayer.loggedIn = this.isAuthenticated(); + }); + } + + /** + * Sets the current resource type in the `dataLayer`. + * + * @param resourceType - The name of the resource (e.g., 'project', 'node') + */ + setResourceType(resourceType: string): void { + this.window.dataLayer.resourceType = resourceType; + } + + /** + * Clears the `resourceType` from the `dataLayer`, setting it to `undefined`. + */ + unsetResourceType(): void { + this.window.dataLayer.resourceType = undefined; + } +} diff --git a/src/app/features/home/pages/dashboard/dashboard.component.html b/src/app/features/home/pages/dashboard/dashboard.component.html index 7b88d1c56..40194e726 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.html +++ b/src/app/features/home/pages/dashboard/dashboard.component.html @@ -11,6 +11,7 @@ (buttonClick)="createProject()" /> +

    @@ -76,7 +77,9 @@

    {{ 'home.loggedIn.hosting.title' | translate }}

    [buttonLabel]="'home.loggedIn.dashboard.createProject' | translate" (buttonClick)="createProject()" /> + <<<<<<< HEAD + ======= >>>>>>> origin/develop

    {{ 'home.loggedIn.dashboard.noCreatedProject' | translate }}

    diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index bdd6fb896..745e1a84a 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -19,6 +19,7 @@ import { IconComponent, LoadingSpinnerComponent, MyProjectsTableComponent, + ScheduledBannerComponent, SubHeaderComponent, } from '@osf/shared/components'; import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants'; @@ -41,6 +42,8 @@ import { TosConsentBannerComponent } from '../../components'; TranslatePipe, LoadingSpinnerComponent, TosConsentBannerComponent, + ScheduledBannerComponent, + LoadingSpinnerComponent, ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.scss', diff --git a/src/app/features/institutions/pages/institutions-list/institutions-list.component.html b/src/app/features/institutions/pages/institutions-list/institutions-list.component.html index 6e2b8f855..7c435dd4d 100644 --- a/src/app/features/institutions/pages/institutions-list/institutions-list.component.html +++ b/src/app/features/institutions/pages/institutions-list/institutions-list.component.html @@ -5,7 +5,7 @@ [title]="'institutions.title' | translate" [icon]="'custom-icon-institutions-dark'" /> - +
    diff --git a/src/app/features/institutions/pages/institutions-list/institutions-list.component.ts b/src/app/features/institutions/pages/institutions-list/institutions-list.component.ts index 6314795a1..6dfa5b561 100644 --- a/src/app/features/institutions/pages/institutions-list/institutions-list.component.ts +++ b/src/app/features/institutions/pages/institutions-list/institutions-list.component.ts @@ -24,6 +24,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { CustomPaginatorComponent, LoadingSpinnerComponent, + ScheduledBannerComponent, SearchInputComponent, SubHeaderComponent, } from '@osf/shared/components'; @@ -42,6 +43,7 @@ import { FetchInstitutions, InstitutionsSelectors } from '@osf/shared/stores'; CustomPaginatorComponent, LoadingSpinnerComponent, RouterLink, + ScheduledBannerComponent, ], templateUrl: './institutions-list.component.html', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/features/moderation/enums/preprint-submissions-sort.enum.ts b/src/app/features/moderation/enums/preprint-submissions-sort.enum.ts index ca1f886f1..dbf6c5633 100644 --- a/src/app/features/moderation/enums/preprint-submissions-sort.enum.ts +++ b/src/app/features/moderation/enums/preprint-submissions-sort.enum.ts @@ -1,6 +1,6 @@ export enum PreprintSubmissionsSort { TitleAZ = 'title', TitleZA = '-title', - Oldest = 'date_last_transitioned', - Newest = '-date_last_transitioned', + Oldest = '-date_last_transitioned', + Newest = 'date_last_transitioned', } diff --git a/src/app/features/preprints/preprints.component.spec.ts b/src/app/features/preprints/preprints.component.spec.ts index f24fc1a9f..005f147a5 100644 --- a/src/app/features/preprints/preprints.component.spec.ts +++ b/src/app/features/preprints/preprints.component.spec.ts @@ -3,31 +3,48 @@ import { MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { HelpScoutService } from '@core/services/help-scout.service'; import { IS_WEB } from '@osf/shared/helpers'; import { PreprintsComponent } from './preprints.component'; -describe('PreprintsComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('Component: Preprint', () => { let component: PreprintsComponent; let fixture: ComponentFixture; let isWebSubject: BehaviorSubject; + let helpScountService: HelpScoutService; beforeEach(async () => { isWebSubject = new BehaviorSubject(true); await TestBed.configureTestingModule({ - imports: [PreprintsComponent], - providers: [provideRouter([]), MockProvider(IS_WEB, isWebSubject)], + imports: [PreprintsComponent, OSFTestingModule], + providers: [ + MockProvider(IS_WEB, isWebSubject), + { + provide: HelpScoutService, + useValue: { + setResourceType: jest.fn(), + unsetResourceType: jest.fn(), + }, + }, + ], }).compileComponents(); + helpScountService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(PreprintsComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should have a default value', () => { + expect(component.isDesktop()).toBeTruthy(); + }); + + it('should called the helpScoutService', () => { + expect(helpScountService.setResourceType).toHaveBeenCalledWith('preprint'); }); }); diff --git a/src/app/features/preprints/preprints.component.ts b/src/app/features/preprints/preprints.component.ts index 86cc62761..128a8b949 100644 --- a/src/app/features/preprints/preprints.component.ts +++ b/src/app/features/preprints/preprints.component.ts @@ -1,7 +1,8 @@ -import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostBinding, inject, OnDestroy } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { RouterOutlet } from '@angular/router'; +import { HelpScoutService } from '@core/services/help-scout.service'; import { IS_WEB } from '@osf/shared/helpers'; @Component({ @@ -11,7 +12,16 @@ import { IS_WEB } from '@osf/shared/helpers'; styleUrl: './preprints.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PreprintsComponent { +export class PreprintsComponent implements OnDestroy { + private readonly helpScoutService = inject(HelpScoutService); readonly isDesktop = toSignal(inject(IS_WEB)); @HostBinding('class') classes = 'flex flex-1 flex-column w-full'; + + constructor() { + this.helpScoutService.setResourceType('preprint'); + } + + ngOnDestroy(): void { + this.helpScoutService.unsetResourceType(); + } } diff --git a/src/app/features/project/project.component.spec.ts b/src/app/features/project/project.component.spec.ts index 34a2dde82..a79bb91bb 100644 --- a/src/app/features/project/project.component.spec.ts +++ b/src/app/features/project/project.component.spec.ts @@ -1,22 +1,50 @@ +import { MockProvider } from 'ng-mocks'; + +import { BehaviorSubject } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HelpScoutService } from '@core/services/help-scout.service'; +import { IS_WEB } from '@osf/shared/helpers'; + import { ProjectComponent } from './project.component'; -describe('ProjectComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('Component: Project', () => { let component: ProjectComponent; let fixture: ComponentFixture; + let isWebSubject: BehaviorSubject; + let helpScountService: HelpScoutService; beforeEach(async () => { + isWebSubject = new BehaviorSubject(true); + await TestBed.configureTestingModule({ - imports: [ProjectComponent], + imports: [ProjectComponent, OSFTestingModule], + providers: [ + MockProvider(IS_WEB, isWebSubject), + { + provide: HelpScoutService, + useValue: { + setResourceType: jest.fn(), + unsetResourceType: jest.fn(), + }, + }, + ], }).compileComponents(); + helpScountService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(ProjectComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should have a default value', () => { + expect(component.classes).toBe('flex flex-1 flex-column w-full'); + }); + + it('should called the helpScoutService', () => { + expect(helpScountService.setResourceType).toHaveBeenCalledWith('project'); }); }); diff --git a/src/app/features/project/project.component.ts b/src/app/features/project/project.component.ts index e6191eac5..b331f2ac3 100644 --- a/src/app/features/project/project.component.ts +++ b/src/app/features/project/project.component.ts @@ -1,7 +1,9 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostBinding, inject, OnDestroy } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { HelpScoutService } from '@core/services/help-scout.service'; + @Component({ selector: 'osf-project', imports: [RouterOutlet, CommonModule], @@ -9,6 +11,15 @@ import { RouterOutlet } from '@angular/router'; styleUrl: './project.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProjectComponent { +export class ProjectComponent implements OnDestroy { + private readonly helpScoutService = inject(HelpScoutService); @HostBinding('class') classes = 'flex flex-1 flex-column w-full'; + + constructor() { + this.helpScoutService.setResourceType('project'); + } + + ngOnDestroy(): void { + this.helpScoutService.unsetResourceType(); + } } diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts index 004f5e814..25094a789 100644 --- a/src/app/features/registries/components/files-control/files-control.component.spec.ts +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -1,22 +1,51 @@ +import { MockProvider } from 'ng-mocks'; + +import { BehaviorSubject } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HelpScoutService } from '@core/services/help-scout.service'; +import { IS_WEB } from '@osf/shared/helpers'; + import { FilesControlComponent } from './files-control.component'; -describe('FilesControlComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('Component: File Control', () => { let component: FilesControlComponent; let fixture: ComponentFixture; + let isWebSubject: BehaviorSubject; + let helpScountService: HelpScoutService; beforeEach(async () => { + isWebSubject = new BehaviorSubject(true); + await TestBed.configureTestingModule({ - imports: [FilesControlComponent], + imports: [FilesControlComponent, OSFTestingModule], + providers: [ + MockProvider(IS_WEB, isWebSubject), + { + provide: HelpScoutService, + useValue: { + setResourceType: jest.fn(), + unsetResourceType: jest.fn(), + }, + }, + ], }).compileComponents(); + helpScountService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(FilesControlComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should have a default value', () => { + expect(component.fileIsUploading()).toBeFalsy(); + expect(component.isFolderOpening()).toBeFalsy(); + }); + + it('should called the helpScoutService', () => { + expect(helpScountService.setResourceType).toHaveBeenCalledWith('files'); }); }); diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index 6bff47341..6a4005a64 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -10,10 +10,21 @@ import { DialogService } from 'primeng/dynamicdialog'; import { EMPTY, filter, finalize, Observable, shareReplay, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, output, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + inject, + input, + OnDestroy, + output, + signal, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { HelpScoutService } from '@core/services/help-scout.service'; import { CreateFolderDialogComponent } from '@osf/features/files/components'; import { FilesTreeComponent, LoadingSpinnerComponent } from '@osf/shared/components'; import { FILE_SIZE_LIMIT } from '@osf/shared/constants'; @@ -46,7 +57,7 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, providers: [DialogService, TreeDragDropService], }) -export class FilesControlComponent { +export class FilesControlComponent implements OnDestroy { attachedFiles = input.required[]>(); attachFile = output(); filesLink = input.required(); @@ -59,6 +70,7 @@ export class FilesControlComponent { private readonly translateService = inject(TranslateService); private readonly destroyRef = inject(DestroyRef); private toastService = inject(ToastService); + private readonly helpScoutService = inject(HelpScoutService); readonly files = select(RegistriesSelectors.getFiles); readonly filesTotalCount = select(RegistriesSelectors.getFilesTotalCount); @@ -89,6 +101,7 @@ export class FilesControlComponent { }; constructor() { + this.helpScoutService.setResourceType('files'); effect(() => { const filesLink = this.filesLink(); if (filesLink) { @@ -200,4 +213,8 @@ export class FilesControlComponent { folderIsOpening(value: boolean): void { this.isFolderOpening.set(value); } + + ngOnDestroy(): void { + this.helpScoutService.unsetResourceType(); + } } diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.html b/src/app/features/registries/pages/registries-landing/registries-landing.component.html index 800b3548f..378a6080d 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.html +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.html @@ -8,7 +8,7 @@ [buttonLabel]="'registries.addRegistration' | translate" (buttonClick)="goToCreateRegistration()" /> - + { - let component: RegistriesComponent; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('Component: Registries', () => { let fixture: ComponentFixture; + let isWebSubject: BehaviorSubject; + let helpScountService: HelpScoutService; beforeEach(async () => { + isWebSubject = new BehaviorSubject(true); + await TestBed.configureTestingModule({ - imports: [RegistriesComponent], + imports: [RegistriesComponent, OSFTestingModule], + providers: [ + MockProvider(IS_WEB, isWebSubject), + { + provide: HelpScoutService, + useValue: { + setResourceType: jest.fn(), + unsetResourceType: jest.fn(), + }, + }, + ], }).compileComponents(); + helpScountService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(RegistriesComponent); - component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should called the helpScoutService', () => { + expect(helpScountService.setResourceType).toHaveBeenCalledWith('registration'); }); }); diff --git a/src/app/features/registries/registries.component.ts b/src/app/features/registries/registries.component.ts index 78716bee1..841cff891 100644 --- a/src/app/features/registries/registries.component.ts +++ b/src/app/features/registries/registries.component.ts @@ -1,6 +1,8 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { HelpScoutService } from '@core/services/help-scout.service'; + @Component({ selector: 'osf-registries', imports: [RouterOutlet], @@ -8,4 +10,13 @@ import { RouterOutlet } from '@angular/router'; styleUrl: './registries.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistriesComponent {} +export class RegistriesComponent implements OnDestroy { + private readonly helpScoutService = inject(HelpScoutService); + constructor() { + this.helpScoutService.setResourceType('registration'); + } + + ngOnDestroy(): void { + this.helpScoutService.unsetResourceType(); + } +} diff --git a/src/app/features/settings/settings-container.component.spec.ts b/src/app/features/settings/settings-container.component.spec.ts index 8b56cb825..7fd061b5c 100644 --- a/src/app/features/settings/settings-container.component.spec.ts +++ b/src/app/features/settings/settings-container.component.spec.ts @@ -1,28 +1,50 @@ +import { MockProvider } from 'ng-mocks'; + +import { BehaviorSubject } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { HelpScoutService } from '@core/services/help-scout.service'; +import { IS_WEB } from '@osf/shared/helpers'; + import { SettingsContainerComponent } from './settings-container.component'; -describe('SettingsContainerComponent', () => { - let component: SettingsContainerComponent; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('Component: Settings', () => { let fixture: ComponentFixture; + let isWebSubject: BehaviorSubject; + let helpScountService: HelpScoutService; beforeEach(async () => { + isWebSubject = new BehaviorSubject(true); + await TestBed.configureTestingModule({ - imports: [SettingsContainerComponent], + imports: [SettingsContainerComponent, OSFTestingModule], + providers: [ + MockProvider(IS_WEB, isWebSubject), + { + provide: HelpScoutService, + useValue: { + setResourceType: jest.fn(), + unsetResourceType: jest.fn(), + }, + }, + ], }).compileComponents(); + helpScountService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(SettingsContainerComponent); - component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should render router outlet', () => { const routerOutlet = fixture.debugElement.query(By.css('router-outlet')); expect(routerOutlet).toBeTruthy(); }); + + it('should called the helpScoutService', () => { + expect(helpScountService.setResourceType).toHaveBeenCalledWith('user'); + }); }); diff --git a/src/app/features/settings/settings-container.component.ts b/src/app/features/settings/settings-container.component.ts index ee731c455..12c980d7e 100644 --- a/src/app/features/settings/settings-container.component.ts +++ b/src/app/features/settings/settings-container.component.ts @@ -1,6 +1,8 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { HelpScoutService } from '@core/services/help-scout.service'; + @Component({ selector: 'osf-settings-container', imports: [RouterOutlet], @@ -8,4 +10,14 @@ import { RouterOutlet } from '@angular/router'; styleUrl: './settings-container.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SettingsContainerComponent {} +export class SettingsContainerComponent implements OnDestroy { + private readonly helpScoutService = inject(HelpScoutService); + + constructor() { + this.helpScoutService.setResourceType('user'); + } + + ngOnDestroy(): void { + this.helpScoutService.unsetResourceType(); + } +} diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index a21ff8138..85f0981cd 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -38,6 +38,7 @@ export { RegistrationCardComponent } from './registration-card/registration-card export { ResourceCardComponent } from './resource-card/resource-card.component'; export { ResourceMetadataComponent } from './resource-metadata/resource-metadata.component'; export { ReusableFilterComponent } from './reusable-filter/reusable-filter.component'; +export { ScheduledBannerComponent } from './scheduled-banner/scheduled-banner.component'; export { SearchHelpTutorialComponent } from './search-help-tutorial/search-help-tutorial.component'; export { SearchInputComponent } from './search-input/search-input.component'; export { SearchResultsContainerComponent } from './search-results-container/search-results-container.component'; diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.ts b/src/app/shared/components/reusable-filter/reusable-filter.component.ts index 1031b44b3..791a0f9b8 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.ts +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.ts @@ -168,6 +168,14 @@ export class ReusableFilterComponent { return filter.isLoading || false; } + isFilterPaginationLoading(filter: DiscoverableFilter): boolean { + return filter.isPaginationLoading || false; + } + + isFilterSearchLoading(filter: DiscoverableFilter): boolean { + return filter.isSearchLoading || false; + } + getSelectedValue(filterKey: string): string | null { return this.selectedValues()[filterKey] || null; } diff --git a/src/app/shared/models/addons/configured-storage-addon.model.ts b/src/app/shared/components/scheduled-banner/schedule-banner.component.spec.ts similarity index 100% rename from src/app/shared/models/addons/configured-storage-addon.model.ts rename to src/app/shared/components/scheduled-banner/schedule-banner.component.spec.ts diff --git a/src/app/shared/components/scheduled-banner/scheduled-banner.component.html b/src/app/shared/components/scheduled-banner/scheduled-banner.component.html new file mode 100644 index 000000000..d1cac0cdd --- /dev/null +++ b/src/app/shared/components/scheduled-banner/scheduled-banner.component.html @@ -0,0 +1,22 @@ +@if (this.shouldShowBanner()) { + +} diff --git a/src/app/shared/components/scheduled-banner/scheduled-banner.component.ts b/src/app/shared/components/scheduled-banner/scheduled-banner.component.ts new file mode 100644 index 000000000..996842773 --- /dev/null +++ b/src/app/shared/components/scheduled-banner/scheduled-banner.component.ts @@ -0,0 +1,33 @@ +import { select, Store } from '@ngxs/store'; + +import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; + +import { IS_XSMALL } from '@osf/shared/helpers'; +import { BannersSelector, FetchCurrentScheduledBanner } from '@osf/shared/stores/banners'; + +@Component({ + selector: 'osf-scheduled-banner', + templateUrl: './scheduled-banner.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ScheduledBannerComponent implements OnInit { + private readonly store = inject(Store); + currentBanner = select(BannersSelector.getCurrentBanner); + isMobile = toSignal(inject(IS_XSMALL)); + + ngOnInit() { + this.store.dispatch(new FetchCurrentScheduledBanner()); + } + + shouldShowBanner = computed(() => { + const banner = this.currentBanner(); + if (banner) { + const bannerStartTime = banner.startDate; + const bannderEndTime = banner.endDate; + const currentTime = new Date(); + return bannerStartTime < currentTime && bannderEndTime > currentTime; + } + return false; + }); +} diff --git a/src/app/shared/mappers/banner.mapper.ts b/src/app/shared/mappers/banner.mapper.ts new file mode 100644 index 000000000..8526fa40f --- /dev/null +++ b/src/app/shared/mappers/banner.mapper.ts @@ -0,0 +1,20 @@ +import { BannerJsonApi } from '../models/banner.json-api.model'; +import { BannerModel } from '../models/banner.model'; + +export class BannerMapper { + static fromResponse(response: BannerJsonApi): BannerModel { + return { + id: response.id, + startDate: new Date(response.attributes.start_date), + endDate: new Date(response.attributes.end_date), + color: response.attributes.color, + license: response.attributes.license, + name: response.attributes.name, + defaultAltText: response.attributes.default_alt_text, + mobileAltText: response.attributes.mobile_alt_text, + defaultPhoto: response.links.default_photo, + mobilePhoto: response.links.mobile_photo, + link: response.attributes.link, + }; + } +} diff --git a/src/app/shared/models/banner.json-api.model.ts b/src/app/shared/models/banner.json-api.model.ts new file mode 100644 index 000000000..707c896e3 --- /dev/null +++ b/src/app/shared/models/banner.json-api.model.ts @@ -0,0 +1,18 @@ +export interface BannerJsonApi { + id: string; + attributes: { + start_date: string; + end_date: string; + color: string; + license: string; + name: string; + default_alt_text: string; + mobile_alt_text: string; + link: string; + }; + links: { + default_photo: string; + mobile_photo: string; + }; + type: string; +} diff --git a/src/app/shared/models/banner.model.ts b/src/app/shared/models/banner.model.ts new file mode 100644 index 000000000..87e7f85d9 --- /dev/null +++ b/src/app/shared/models/banner.model.ts @@ -0,0 +1,13 @@ +export interface BannerModel { + id: string; + startDate: Date; + endDate: Date; + color: string; + license: string; + name: string; + defaultAltText: string; + mobileAltText: string; + defaultPhoto: string; + mobilePhoto: string; + link: string; +} diff --git a/src/app/shared/services/banners.service.ts b/src/app/shared/services/banners.service.ts new file mode 100644 index 000000000..b8eccd401 --- /dev/null +++ b/src/app/shared/services/banners.service.ts @@ -0,0 +1,38 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiResponse } from '@shared/models'; +import { JsonApiService } from '@shared/services'; + +import { BannerMapper } from '../mappers/banner.mapper'; +import { BannerJsonApi } from '../models/banner.json-api.model'; +import { BannerModel } from '../models/banner.model'; + +import { environment } from 'src/environments/environment'; + +/** + * Service for fetching scheduled banners from OSF API v2 + */ +@Injectable({ + providedIn: 'root', +}) +export class BannersService { + /** + * Injected instance of the JSON:API service used for making API requests. + * This service handles standardized JSON:API request and response formatting. + */ + private jsonApiService = inject(JsonApiService); + + /** + * Retrieves the current banner + * + * @returns Observable emitting a Banner object. + * + */ + fetchCurrentBanner(): Observable { + return this.jsonApiService + .get>(`${environment.apiDomainUrl}/_/banners/current`) + .pipe(map((response) => BannerMapper.fromResponse(response.data))); + } +} diff --git a/src/app/shared/stores/banners/banners.actions.ts b/src/app/shared/stores/banners/banners.actions.ts new file mode 100644 index 000000000..34ffb22f2 --- /dev/null +++ b/src/app/shared/stores/banners/banners.actions.ts @@ -0,0 +1,3 @@ +export class FetchCurrentScheduledBanner { + static readonly type = '[Banners] Fetch Current Scheduled Banner'; +} diff --git a/src/app/shared/stores/banners/banners.model.ts b/src/app/shared/stores/banners/banners.model.ts new file mode 100644 index 000000000..630578cf2 --- /dev/null +++ b/src/app/shared/stores/banners/banners.model.ts @@ -0,0 +1,14 @@ +import { BannerModel } from '@osf/shared/models/banner.model'; +import { AsyncStateModel } from '@shared/models/store'; + +export interface BannersStateModel { + currentBanner: AsyncStateModel; +} + +export const BANNERS_DEFAULTS: BannersStateModel = { + currentBanner: { + data: null, + isLoading: false, + error: null, + }, +}; diff --git a/src/app/shared/stores/banners/banners.selectors.ts b/src/app/shared/stores/banners/banners.selectors.ts new file mode 100644 index 000000000..5a7192821 --- /dev/null +++ b/src/app/shared/stores/banners/banners.selectors.ts @@ -0,0 +1,16 @@ +import { Selector } from '@ngxs/store'; + +import { BannersStateModel } from './banners.model'; +import { BannersState } from './banners.state'; + +export class BannersSelector { + @Selector([BannersState]) + static getCurrentBanner(state: BannersStateModel) { + return state.currentBanner.data; + } + + @Selector([BannersState]) + static getCurrentBannerIsLoading(state: BannersStateModel) { + return state.currentBanner.isLoading; + } +} diff --git a/src/app/shared/stores/banners/banners.state.ts b/src/app/shared/stores/banners/banners.state.ts new file mode 100644 index 000000000..dd153d32f --- /dev/null +++ b/src/app/shared/stores/banners/banners.state.ts @@ -0,0 +1,43 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { catchError, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@osf/shared/helpers'; +import { BannersService } from '@osf/shared/services/banners.service'; + +import { FetchCurrentScheduledBanner } from './banners.actions'; +import { BANNERS_DEFAULTS, BannersStateModel } from './banners.model'; + +@State({ + name: 'banners', + defaults: BANNERS_DEFAULTS, +}) +@Injectable() +export class BannersState { + bannersService = inject(BannersService); + + @Action(FetchCurrentScheduledBanner) + fetchCurrentScheduledBanner(ctx: StateContext) { + const state = ctx.getState(); + ctx.patchState({ + currentBanner: { + ...state.currentBanner, + isLoading: true, + }, + }); + return this.bannersService.fetchCurrentBanner().pipe( + tap((newValue) => { + ctx.patchState({ + currentBanner: { + data: newValue, + isLoading: false, + error: null, + }, + }); + catchError((error) => handleSectionError(ctx, 'currentBanner', error)); + }) + ); + } +} diff --git a/src/app/shared/stores/banners/index.ts b/src/app/shared/stores/banners/index.ts new file mode 100644 index 000000000..663fcdd14 --- /dev/null +++ b/src/app/shared/stores/banners/index.ts @@ -0,0 +1,4 @@ +export * from './banners.actions'; +export * from './banners.model'; +export * from './banners.selectors'; +export * from './banners.state'; diff --git a/src/app/shared/stores/index.ts b/src/app/shared/stores/index.ts index 7e306561d..bc94b95fa 100644 --- a/src/app/shared/stores/index.ts +++ b/src/app/shared/stores/index.ts @@ -1,4 +1,5 @@ export * from './addons'; +export * from './banners'; export * from './bookmarks'; export * from './citations'; export * from './collections'; diff --git a/src/testing/osf.testing.module.ts b/src/testing/osf.testing.module.ts index a4e376233..c7412daf6 100644 --- a/src/testing/osf.testing.module.ts +++ b/src/testing/osf.testing.module.ts @@ -3,11 +3,13 @@ import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { NgModule } from '@angular/core'; +import { NgModule, PLATFORM_ID } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { NoopAnimationsModule, provideNoopAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; +import { WINDOW, windowFactory } from '@core/factory/window.factory'; + import { DynamicDialogRefMock } from './mocks/dynamic-dialog-ref.mock'; import { EnvironmentTokenMock } from './mocks/environment.token.mock'; import { StoreMock } from './mocks/store.mock'; @@ -35,6 +37,11 @@ import { TranslationServiceMock } from './mocks/translation.service.mock'; DynamicDialogRefMock, EnvironmentTokenMock, ToastServiceMock, + { + provide: WINDOW, + useFactory: windowFactory, + deps: [PLATFORM_ID], + }, ], }) export class OSFTestingModule {} From 6bb5fd07e65742c12a50fcc760024c0da8072349 Mon Sep 17 00:00:00 2001 From: nmykhalkevych-exoft Date: Tue, 16 Sep 2025 12:14:10 +0300 Subject: [PATCH 14/21] Fix/wiki (#398) * fix(scroll): scroll to top after navigation end * fix(scroll): scroll to top after navigation end * fix(wiki): wiki bugs * fix(file): file limits * fix(wiki): responsive --- .../pages/registry-wiki/registry-wiki.component.html | 2 +- .../pages/registry-wiki/registry-wiki.component.scss | 5 +++++ .../wiki/compare-section/compare-section.component.html | 2 +- .../components/wiki/view-section/view-section.component.html | 2 +- src/app/shared/constants/files-limits.const.ts | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html index 05f489d7a..0c8dfc404 100644 --- a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html +++ b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html @@ -19,7 +19,7 @@
    } -
    +

    {{ 'project.wiki.compare' | translate }}

    -
    +
    Live preview to

    {{ 'project.wiki.view' | translate }}

    -
    +
    {{ 'project.wiki.version.title' | translate }}: Date: Tue, 16 Sep 2025 12:56:46 +0300 Subject: [PATCH 15/21] Fix - Contributors permissions (#391) * fix(contributors): Fixed contributors management permissions * fix(contributors): Fixed contributors mapping for registration card --- .../contributors-dialog.component.html | 16 ++-- .../contributors-dialog.component.ts | 27 +++++- .../contributors/contributors.component.html | 14 ++- .../contributors/contributors.component.ts | 12 +++ src/app/features/registry/models/index.ts | 1 - .../registry-contributor-json-api.model.ts | 96 ------------------- .../contributors-list.component.html | 2 +- .../contributors-list.component.ts | 20 +++- .../registration-card.component.html | 2 +- .../registration/registration.mapper.ts | 4 +- 10 files changed, 79 insertions(+), 115 deletions(-) delete mode 100644 src/app/features/registry/models/registry-contributor-json-api.model.ts diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html index 11ef9c4cf..f9212d8cc 100644 --- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html +++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html @@ -2,12 +2,14 @@

    {{ 'project.contributors.addContributor' | translate }}

    - + @if (isCurrentUserAdminContributor()) { + + }
    @@ -20,6 +22,8 @@

    {{ 'project.contributors.addContributor' | translate }}

    [showEducation]="false" [showEmployment]="false" [isLoading]="isLoading()" + [isCurrentUserAdminContributor]="isCurrentUserAdminContributor()" + [currentUserId]="currentUser()?.id" (remove)="removeContributor($event)" > diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts index 007daa5e7..c4e9cc827 100644 --- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts +++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts @@ -7,17 +7,27 @@ import { DialogService, DynamicDialogConfig, DynamicDialogRef } from 'primeng/dy import { filter, forkJoin } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + OnInit, + signal, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; +import { UserSelectors } from '@core/store/user'; import { SearchInputComponent } from '@osf/shared/components'; import { AddContributorDialogComponent, AddUnregisteredContributorDialogComponent, ContributorsListComponent, } from '@osf/shared/components/contributors'; -import { AddContributorType, ResourceType } from '@osf/shared/enums'; +import { AddContributorType, ContributorPermission, ResourceType } from '@osf/shared/enums'; import { findChangedItems } from '@osf/shared/helpers'; import { ContributorDialogAddModel, ContributorModel } from '@osf/shared/models'; import { CustomConfirmationService, ToastService } from '@osf/shared/services'; @@ -52,6 +62,19 @@ export class ContributorsDialogComponent implements OnInit { isLoading = select(ContributorsSelectors.isContributorsLoading); initialContributors = select(ContributorsSelectors.getContributors); contributors = signal([]); + + currentUser = select(UserSelectors.getCurrentUser); + + isCurrentUserAdminContributor = computed(() => { + const currentUserId = this.currentUser()?.id; + const initialContributors = this.initialContributors(); + if (!currentUserId) return false; + + return initialContributors.some((contributor: ContributorModel) => { + return contributor.userId === currentUserId && contributor.permission === ContributorPermission.Admin; + }); + }); + actions = createDispatchMap({ updateSearchValue: UpdateSearchValue, updatePermissionFilter: UpdatePermissionFilter, diff --git a/src/app/features/project/contributors/contributors.component.html b/src/app/features/project/contributors/contributors.component.html index 264057038..e87bcbeda 100644 --- a/src/app/features/project/contributors/contributors.component.html +++ b/src/app/features/project/contributors/contributors.component.html @@ -1,11 +1,13 @@

    {{ 'navigation.contributors' | translate }}

    - + @if (isCurrentUserAdminContributor()) { + + }
    @@ -64,6 +66,8 @@

    {{ 'navigation.contributors' | translate } class="w-full" [contributors]="contributors()" [isLoading]="isContributorsLoading()" + [isCurrentUserAdminContributor]="isCurrentUserAdminContributor()" + [currentUserId]="currentUser()?.id" [showCurator]="true" (remove)="removeContributor($event)" > diff --git a/src/app/features/project/contributors/contributors.component.ts b/src/app/features/project/contributors/contributors.component.ts index 5c61531c4..2e047fad3 100644 --- a/src/app/features/project/contributors/contributors.component.ts +++ b/src/app/features/project/contributors/contributors.component.ts @@ -23,6 +23,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; +import { UserSelectors } from '@core/store/user'; import { SearchInputComponent, ViewOnlyTableComponent } from '@osf/shared/components'; import { AddContributorDialogComponent, @@ -105,9 +106,20 @@ export class ContributorsComponent implements OnInit { readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); readonly isViewOnlyLinksLoading = select(ViewOnlyLinkSelectors.isViewOnlyLinksLoading); + readonly currentUser = select(UserSelectors.getCurrentUser); canCreateViewLink = computed(() => !!this.resourceDetails() && !!this.resourceId()); + isCurrentUserAdminContributor = computed(() => { + const currentUserId = this.currentUser()?.id; + const initialContributors = this.initialContributors(); + if (!currentUserId) return false; + + return initialContributors.some((contributor: ContributorModel) => { + return contributor.userId === currentUserId && contributor.permission === ContributorPermission.Admin; + }); + }); + actions = createDispatchMap({ getViewOnlyLinks: FetchViewOnlyLinks, getResourceDetails: GetResourceDetails, diff --git a/src/app/features/registry/models/index.ts b/src/app/features/registry/models/index.ts index 7d096d036..90c7008df 100644 --- a/src/app/features/registry/models/index.ts +++ b/src/app/features/registry/models/index.ts @@ -6,7 +6,6 @@ export * from './linked-registrations-json-api.model'; export * from './linked-response.models'; export * from './registry-components.models'; export * from './registry-components-json-api.model'; -export * from './registry-contributor-json-api.model'; export * from './registry-metadata.models'; export * from './registry-overview.models'; export * from './resources'; diff --git a/src/app/features/registry/models/registry-contributor-json-api.model.ts b/src/app/features/registry/models/registry-contributor-json-api.model.ts deleted file mode 100644 index a75b4391c..000000000 --- a/src/app/features/registry/models/registry-contributor-json-api.model.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { MetaJsonApi } from '@osf/shared/models'; - -export interface RegistryContributorJsonApi { - id: string; - type: 'contributors'; - attributes: { - index: number; - bibliographic: boolean; - permission: string; - unregistered_contributor: string | null; - is_curator: boolean; - }; - relationships: { - users: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: 'users'; - }; - }; - node: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: 'nodes'; - }; - }; - }; - embeds?: { - users: { - data: { - id: string; - type: 'users'; - attributes: { - full_name: string; - given_name: string; - middle_names: string; - family_name: string; - suffix: string; - date_registered: string; - active: boolean; - timezone: string; - locale: string; - social: Record; - employment: unknown[]; - education: unknown[]; - }; - relationships: Record; - links: { - html: string; - profile_image: string; - self: string; - iri: string; - }; - }; - }; - }; - links: { - self: string; - }; -} - -export interface RegistryContributorJsonApiResponse { - data: RegistryContributorJsonApi; - links: { - self: string; - }; - meta: MetaJsonApi; -} - -export interface RegistryContributorUpdateRequest { - data: { - id: string; - type: 'contributors'; - attributes: Record; - relationships: Record; - }; -} - -export interface RegistryContributorAddRequest { - data: { - type: 'contributors'; - attributes: Record; - relationships: Record; - }; -} diff --git a/src/app/shared/components/contributors/contributors-list/contributors-list.component.html b/src/app/shared/components/contributors/contributors-list/contributors-list.component.html index a4464c84b..ce6dc9162 100644 --- a/src/app/shared/components/contributors/contributors-list/contributors-list.component.html +++ b/src/app/shared/components/contributors/contributors-list/contributors-list.component.html @@ -187,7 +187,7 @@

    {{ 'project.contributors.curatorInfo.heading' | translate }}

    } - @if (isCurrentUserAdminContributor() || currentUserId() === contributor.userId) { + @if (canRemoveContributor().get(contributor.userId)) { (undefined); isCurrentUserAdminContributor = input(true); + canRemoveContributor = computed(() => { + const contributors = this.contributors(); + const currentUserId = this.currentUserId(); + const isAdmin = this.isCurrentUserAdminContributor(); + const adminCount = contributors.filter((c) => c.permission === ContributorPermission.Admin).length; + + const result = new Map(); + + for (const c of contributors) { + const canRemove = + (isAdmin || currentUserId === c.userId) && !(c.permission === ContributorPermission.Admin && adminCount <= 1); + + result.set(c.userId, canRemove); + } + + return result; + }); + remove = output(); dialogService = inject(DialogService); translateService = inject(TranslateService); diff --git a/src/app/shared/components/registration-card/registration-card.component.html b/src/app/shared/components/registration-card/registration-card.component.html index c1650c5e4..752f09c58 100644 --- a/src/app/shared/components/registration-card/registration-card.component.html +++ b/src/app/shared/components/registration-card/registration-card.component.html @@ -39,7 +39,7 @@

    {{ 'project.overview.metadata.contributors' | translate }}: @for (contributor of registrationData().contributors; track contributor) { - {{ contributor.fullName }} + {{ contributor.fullName }} @if (!$last) { , } diff --git a/src/app/shared/mappers/registration/registration.mapper.ts b/src/app/shared/mappers/registration/registration.mapper.ts index 6ef5830ce..ffcac4f5e 100644 --- a/src/app/shared/mappers/registration/registration.mapper.ts +++ b/src/app/shared/mappers/registration/registration.mapper.ts @@ -89,8 +89,8 @@ export class RegistrationMapper { revisionState: registration.attributes.revision_state, contributors: registration.embeds?.bibliographic_contributors?.data.map((contributor) => ({ - id: contributor.id, - fullName: contributor.embeds?.users?.data.attributes.full_name, + id: contributor.embeds.users.data.id, + fullName: contributor.embeds.users.data.attributes.full_name, })) || [], }; } From 9a26d6b67ff9faf19619c11a07f696179abc129c Mon Sep 17 00:00:00 2001 From: Lord Business <113387478+bp-cos@users.noreply.github.com> Date: Tue, 16 Sep 2025 06:54:38 -0500 Subject: [PATCH 16/21] fix(eng-8505): added config variable for google file picker (#397) --- docker-compose.yml | 1 + docker/scripts/check-config.js | 13 ++++++++++ package.json | 5 ++-- src/app/core/models/config.model.ts | 16 ++++++++++++ .../google-file-picker.component.spec.ts | 14 +++++++++++ .../google-file-picker.component.ts | 8 +++--- .../storage-item-selector.component.ts | 4 +-- src/app/shared/models/environment.model.ts | 6 ----- src/assets/config/template.json | 4 ++- src/environments/environment.development.ts | 25 ------------------- src/environments/environment.local.ts | 22 ---------------- src/environments/environment.test-osf.ts | 5 ---- src/environments/environment.ts | 9 ------- src/testing/mocks/environment.token.mock.ts | 4 --- 14 files changed, 56 insertions(+), 80 deletions(-) create mode 100644 docker/scripts/check-config.js diff --git a/docker-compose.yml b/docker-compose.yml index d5e025875..a8148f3b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - ./angular.json:/app/angular.json - ./tsconfig.json:/app/tsconfig.json - ./tsconfig.app.json:/app/tsconfig.app.json + - ./docker/scripts:/app/docker # (CMD comes from Dockerfile, but you could override here if you wanted) command: ['npm', 'run', 'start:docker'] diff --git a/docker/scripts/check-config.js b/docker/scripts/check-config.js new file mode 100644 index 000000000..6375bcd8a --- /dev/null +++ b/docker/scripts/check-config.js @@ -0,0 +1,13 @@ +const fs = require('fs'); +const path = require('path'); +const { config } = require('process'); + +const configPath = path.join(__dirname, '../src/assets/config/config.json'); +const templatePath = path.join(__dirname, '../src/assets/config/template.json'); + +if (!fs.existsSync(configPath)) { + console.log('[INFO] config.json not found. Copying from template.json...'); + fs.copyFileSync(templatePath, configPath); +} else { + console.log('[INFO] config.json already exists.'); +} diff --git a/package.json b/package.json index c523a00bb..b42736ec6 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "ng": "ng", "analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks", "build": "ng build", + "check:config": "node ./docker/check-config.js", "ci:test": "jest", "ci:test:coverage": "jest --coverage", "docs": "./node_modules/.bin/compodoc -p tsconfig.docs.json --name 'OSF Angular Documentation' --theme 'laravel' -s", @@ -19,8 +20,8 @@ "prepare": "husky", "start": "ng serve", "start:test": "ng serve --configuration test-osf", - "start:docker": "ng serve --host 0.0.0.0 --port 4200 --poll 2000", - "start:docker:local": "ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration local", + "start:docker": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000", + "start:docker:local": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration local", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage && npm run test:display", diff --git a/src/app/core/models/config.model.ts b/src/app/core/models/config.model.ts index e8e7d152a..de810b2ef 100644 --- a/src/app/core/models/config.model.ts +++ b/src/app/core/models/config.model.ts @@ -24,6 +24,22 @@ export interface ConfigModel { */ googleTagManagerId: string; + /** + * API Key used to load the Google Picker API. + * This key should be restricted in the Google Cloud Console to limit usage. + * + * @example "AIzaSyA...your_api_key" + */ + googleFilePickerApiKey: string; + + /** + * Google Cloud Project App ID used by the Google Picker SDK. + * This numeric ID identifies your Google project and is required for some configurations. + * + * @example 123456789012 + */ + googleFilePickerAppId: number; + /** * A catch-all for additional configuration keys not explicitly defined. * Each dynamic property maps to a `ConfigModelType` value. diff --git a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.spec.ts b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.spec.ts index ac8db2afc..578a1f54f 100644 --- a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.spec.ts +++ b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.spec.ts @@ -5,6 +5,7 @@ import { Observable, of, throwError } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SENTRY_TOKEN } from '@core/factory/sentry.factory'; +import { OSFConfigService } from '@core/services/osf-config.service'; import { GoogleFilePickerDownloadService } from './service/google-file-picker.download.service'; import { GoogleFilePickerComponent } from './google-file-picker.component'; @@ -24,6 +25,17 @@ describe('Component: Google File Picker', () => { }), }; + const OSFConfigServiceProvider = { + provide: OSFConfigService, + useValue: { + get: (key: string) => { + if (key === 'googleFilePickerApiKey') return 'test-api-key'; + if (key === 'googleFilePickerAppId') return 'test-app-id'; + return null; + }, + }, + }; + let sentrySpy: any; let throwLoadScriptError = false; @@ -111,6 +123,7 @@ describe('Component: Google File Picker', () => { provide: Store, useValue: storeMock, }, + OSFConfigServiceProvider, ], }).compileComponents(); @@ -232,6 +245,7 @@ describe('Component: Google File Picker', () => { provide: Store, useValue: storeMock, }, + OSFConfigServiceProvider, ], }).compileComponents(); diff --git a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts index 80b8e7f87..ac2272200 100644 --- a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts +++ b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts @@ -6,8 +6,8 @@ import { Button } from 'primeng/button'; import { ChangeDetectionStrategy, Component, inject, input, OnInit, signal } from '@angular/core'; -import { ENVIRONMENT } from '@core/constants/environment.token'; import { SENTRY_TOKEN } from '@core/factory/sentry.factory'; +import { OSFConfigService } from '@core/services/osf-config.service'; import { StorageItemModel } from '@osf/shared/models'; import { GoogleFileDataModel } from '@osf/shared/models/files/google-file.data.model'; import { GoogleFilePickerModel } from '@osf/shared/models/files/google-file.picker.model'; @@ -26,9 +26,9 @@ import { GoogleFilePickerDownloadService } from './service/google-file-picker.do }) export class GoogleFilePickerComponent implements OnInit { private readonly Sentry = inject(SENTRY_TOKEN); + private configService = inject(OSFConfigService); readonly #translateService = inject(TranslateService); readonly #googlePicker = inject(GoogleFilePickerDownloadService); - readonly #environment = inject(ENVIRONMENT); public isFolderPicker = input.required(); public rootFolder = input(null); @@ -39,8 +39,8 @@ export class GoogleFilePickerComponent implements OnInit { public accessToken = signal(null); public visible = signal(false); public isGFPDisabled = signal(true); - private readonly apiKey = this.#environment.google?.GOOGLE_FILE_PICKER_API_KEY ?? ''; - private readonly appId = this.#environment.google?.GOOGLE_FILE_PICKER_APP_ID ?? 0; + private readonly apiKey = this.configService.get('googleFilePickerApiKey'); + private readonly appId = this.configService.get('googleFilePickerAppId'); private readonly store = inject(Store); private parentId = ''; diff --git a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts index 5ba74e824..b06065200 100644 --- a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts +++ b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts @@ -211,10 +211,10 @@ export class StorageItemSelectorComponent implements OnInit { this.cancelSelection.emit(); } - handleFolderSelection(folder: StorageItem): void { + handleFolderSelection = (folder: StorageItem): void => { this.selectedStorageItem.set(folder); this.hasFolderChanged.set(folder?.itemId !== this.initiallySelectedStorageItem()?.itemId); - } + }; private updateBreadcrumbs( operationName: OperationNames, diff --git a/src/app/shared/models/environment.model.ts b/src/app/shared/models/environment.model.ts index 7671308db..024e42559 100644 --- a/src/app/shared/models/environment.model.ts +++ b/src/app/shared/models/environment.model.ts @@ -15,12 +15,6 @@ export interface AppEnvironment { dataciteTrackerRepoId: string | null; dataciteTrackerAddress: string; - google?: { - GOOGLE_FILE_PICKER_CLIENT_ID: string; - GOOGLE_FILE_PICKER_API_KEY: string; - GOOGLE_FILE_PICKER_APP_ID: number; - }; - activityLogs?: { pageSize?: number; }; diff --git a/src/assets/config/template.json b/src/assets/config/template.json index d407164e3..ab4553775 100644 --- a/src/assets/config/template.json +++ b/src/assets/config/template.json @@ -1,4 +1,6 @@ { "sentryDsn": "", - "googleTagManagerId": "" + "googleTagManagerId": "", + "googleFilePickerApiKey": "", + "googleFilePickerAppId": 0 } diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 5138269ee..4ddc9b332 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -56,29 +56,4 @@ export const environment = { defaultProvider: 'osf', dataciteTrackerRepoId: null, dataciteTrackerAddress: 'https://analytics.datacite.org/api/metric', - /** - * Google File Picker configuration values. - */ - google: { - /** - * OAuth 2.0 Client ID used to identify the application during Google authentication. - * Registered in Google Cloud Console under "OAuth 2.0 Client IDs". - * Safe to expose in frontend code. - * @see https://console.cloud.google.com/apis/credentials - */ - GOOGLE_FILE_PICKER_CLIENT_ID: '610901277352-m5krehjdtu8skh2teq85fb7mvk411qa6.apps.googleusercontent.com', - /** - * Public API key used to load Google Picker and other Google APIs that don’t require user auth. - * Must be restricted by referrer in Google Cloud Console. - * Exposing this key is acceptable if restricted properly. - * @see https://developers.google.com/maps/api-key-best-practices - */ - GOOGLE_FILE_PICKER_API_KEY: 'AIzaSyA3EnD0pOv4v7sJt7BGuR1i2Gcj-Gju6C0', - /** - * Google Cloud Project App ID. - * Used for associating API requests with the specific Google project. - * Required for Google Picker configuration. - */ - GOOGLE_FILE_PICKER_APP_ID: 610901277352, - }, }; diff --git a/src/environments/environment.local.ts b/src/environments/environment.local.ts index 342dc0d2c..05ff0cb9e 100644 --- a/src/environments/environment.local.ts +++ b/src/environments/environment.local.ts @@ -14,26 +14,4 @@ export const environment = { defaultProvider: 'osf', dataciteTrackerRepoId: null, dataciteTrackerAddress: 'https://analytics.datacite.org/api/metric', - google: { - /** - * OAuth 2.0 Client ID used to identify the application during Google authentication. - * Registered in Google Cloud Console under "OAuth 2.0 Client IDs". - * Safe to expose in frontend code. - * @see https://console.cloud.google.com/apis/credentials - */ - GOOGLE_FILE_PICKER_CLIENT_ID: '610901277352-m5krehjdtu8skh2teq85fb7mvk411qa6.apps.googleusercontent.com', - /** - * Public API key used to load Google Picker and other Google APIs that don’t require user auth. - * Must be restricted by referrer in Google Cloud Console. - * Exposing this key is acceptable if restricted properly. - * @see https://developers.google.com/maps/api-key-best-practices - */ - GOOGLE_FILE_PICKER_API_KEY: 'AIzaSyA3EnD0pOv4v7sJt7BGuR1i2Gcj-Gju6C0', - /** - * Google Cloud Project App ID. - * Used for associating API requests with the specific Google project. - * Required for Google Picker configuration. - */ - GOOGLE_FILE_PICKER_APP_ID: 610901277352, - }, }; diff --git a/src/environments/environment.test-osf.ts b/src/environments/environment.test-osf.ts index 19fed8086..eaa045a52 100644 --- a/src/environments/environment.test-osf.ts +++ b/src/environments/environment.test-osf.ts @@ -16,9 +16,4 @@ export const environment = { defaultProvider: 'osf', dataciteTrackerRepoId: null, dataciteTrackerAddress: 'https://analytics.datacite.org/api/metric', - google: { - GOOGLE_FILE_PICKER_CLIENT_ID: '610901277352-m5krehjdtu8skh2teq85fb7mvk411qa6.apps.googleusercontent.com', - GOOGLE_FILE_PICKER_API_KEY: 'AIzaSyA3EnD0pOv4v7sJt7BGuR1i2Gcj-Gju6C0', - GOOGLE_FILE_PICKER_APP_ID: 610901277352, - }, }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 1f58cbc79..8473fe0a9 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -56,13 +56,4 @@ export const environment = { defaultProvider: 'osf', dataciteTrackerRepoId: null, dataciteTrackerAddress: 'https://analytics.datacite.org/api/metric', - - /** - * Google File Picker configuration values. - */ - google: { - GOOGLE_FILE_PICKER_CLIENT_ID: '610901277352-m5krehjdtu8skh2teq85fb7mvk411qa6.apps.googleusercontent.com', - GOOGLE_FILE_PICKER_API_KEY: 'AIzaSyA3EnD0pOv4v7sJt7BGuR1i2Gcj-Gju6C0', - GOOGLE_FILE_PICKER_APP_ID: 610901277352, - }, }; diff --git a/src/testing/mocks/environment.token.mock.ts b/src/testing/mocks/environment.token.mock.ts index be12d7e14..7e95f9b68 100644 --- a/src/testing/mocks/environment.token.mock.ts +++ b/src/testing/mocks/environment.token.mock.ts @@ -24,9 +24,5 @@ export const EnvironmentTokenMock = { provide: ENVIRONMENT, useValue: { production: false, - google: { - GOOGLE_FILE_PICKER_API_KEY: 'test-api-key', - GOOGLE_FILE_PICKER_APP_ID: 'test-app-id', - }, }, }; From 2adbc646df80e0e9f19b8f1463156d4b92d0c97a Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 16 Sep 2025 17:35:18 +0300 Subject: [PATCH 17/21] Fix/649 moderation tab (#400) * fix(users): added 5 more columns options * fix(summary): fixed charts numbers display * fix(institutions): updates * fix(users-state): removed unused code * fix(collections): updated imports * fix(provider): updated collection and registration providers * fix(registry-provider): updated provider for registrations * fix(moderation-tab): updated provider setup * fix(tests): fixed unit tests * fix(conflict): removed code from conflict * fix(banner): fixed code in banner component * fix(collections): fixed visibility of collections tab * fix(collections): fixed subjects bug * fix(moderators): fixed moderators and contributors search --- .../components/nav-menu/nav-menu.component.ts | 12 +- src/app/core/constants/nav-items.constant.ts | 2 +- src/app/core/helpers/nav-menu.helper.ts | 28 +++- src/app/core/models/route-context.model.ts | 2 + .../core/store/provider/provider.actions.ts | 8 +- src/app/core/store/provider/provider.model.ts | 4 +- .../core/store/provider/provider.selectors.ts | 4 +- src/app/core/store/provider/provider.state.ts | 11 +- src/app/core/store/user/user.actions.ts | 4 - src/app/core/store/user/user.selectors.ts | 5 - src/app/core/store/user/user.state.ts | 21 --- .../institutions-projects.component.ts | 2 +- .../collections/collections.routes.ts | 18 ++- .../project-metadata-step.component.ts | 4 +- .../select-project-step.component.ts | 8 +- .../collections-discover.component.ts | 18 ++- .../collections-filter-chips.component.ts | 5 +- .../collections-filters.component.scss | 3 +- .../collections-filters.component.ts | 5 +- .../collections-help-dialog.component.html | 6 +- .../collections-help-dialog.component.scss | 6 +- .../collections-main-content.component.scss | 5 +- .../collections-main-content.component.ts | 12 +- .../collections-search-results.component.ts | 8 +- .../constants/filter-types.const.ts | 2 +- .../features/collections/constants/index.ts | 2 + .../services/project-metadata-form.service.ts | 8 +- .../pages/dashboard/dashboard.component.html | 2 - .../contributors-dialog.component.ts | 11 +- .../collection-moderation.routes.ts | 4 +- ...ection-moderation-submissions.component.ts | 20 +-- .../moderators-list.component.ts | 4 +- .../collection-moderation.component.spec.ts | 7 +- .../collection-moderation.component.ts | 25 +++- .../registries-moderation.component.spec.ts | 7 +- .../registries-moderation.component.ts | 27 +++- .../moderation/services/moderators.service.ts | 2 +- .../store/moderators/moderators.actions.ts | 2 +- .../store/moderators/moderators.state.ts | 6 +- .../preprint-provider-json-api.models.ts | 4 +- .../preprint-details.component.ts | 3 + .../preprint-providers.state.ts | 19 +++ .../contributors.component.spec.ts | 8 +- .../contributors/contributors.component.ts | 4 +- .../files-widget/files-widget.component.ts | 4 +- .../registry-provider-hero.component.html | 2 +- .../registry-provider-hero.component.ts | 4 +- .../select-components-dialog.component.ts | 6 +- src/app/features/registries/mappers/index.ts | 1 - .../registries/mappers/providers.mapper.ts | 33 ----- src/app/features/registries/models/index.ts | 5 +- .../models/project-short-info.model.ts | 5 + src/app/features/registries/models/project.ts | 5 - .../registry-provider-json-api.model.ts | 15 -- .../registries-landing.component.ts | 24 +++- .../registries-provider-search.component.ts | 19 +-- .../features/registries/registries.routes.ts | 4 +- src/app/features/registries/services/index.ts | 2 +- .../store/handlers/projects.handlers.ts | 13 +- .../store/handlers/providers.handlers.ts | 4 +- .../store/registries-provider-search/index.ts | 4 - .../registries-provider-search.model.ts | 15 -- .../registries-provider-search.selectors.ts | 16 --- .../registries-provider-search.state.ts | 51 ------- .../registries/store/registries.model.ts | 5 +- .../registries/store/registries.selectors.ts | 5 +- .../short-registration-info.component.html | 2 +- .../mappers/registry-overview.mapper.ts | 3 +- .../models/registry-overview.models.ts | 4 +- .../registry-overview.component.ts | 35 ++--- .../features/registry/registry.component.ts | 34 ++++- .../services/registry-overview.service.ts | 2 +- .../registry-overview.state.ts | 135 +++++++++--------- .../add-project-form.component.spec.ts | 4 +- .../add-project-form.component.ts | 6 +- .../project-selector.component.ts | 10 +- .../schedule-banner.component.spec.ts | 0 .../scheduled-banner.component.html | 24 ++-- .../scheduled-banner.component.ts | 4 +- src/app/shared/enums/resource-type.enum.ts | 1 + src/app/shared/mappers/index.ts | 1 + .../mappers/projects/projects.mapper.ts | 6 +- .../mappers/registration-provider.mapper.ts | 39 +++++ .../collections-json-api.models.ts | 32 +---- .../models/collections/collections.models.ts | 23 +-- .../shared/models/projects/projects.models.ts | 2 +- .../provider/base-provider-json-api.model.ts | 16 +-- .../collections-provider-json-api.model.ts | 12 ++ src/app/shared/models/provider/index.ts | 2 + .../preprints-provider-json-api.model.ts | 10 +- .../shared/models/provider/provider.model.ts | 25 +++- .../registration-provider-json-api.model.ts | 22 ++- .../provider}/registry-provider.model.ts | 2 +- .../registration/draft-registration.model.ts | 6 +- src/app/shared/models/registration/index.ts | 1 + .../registration}/provider-schema.model.ts | 0 .../services/addons/addons.service.spec.ts | 2 +- src/app/shared/services/index.ts | 1 + src/app/shared/services/projects.service.ts | 10 +- .../registration-provider.service.ts} | 22 +-- .../shared/stores/addons/addons.state.spec.ts | 6 +- .../stores/collections/collections.state.ts | 26 ++++ .../contributors/contributors.actions.ts | 2 +- .../stores/contributors/contributors.state.ts | 6 +- .../stores/projects/projects.actions.ts | 4 +- .../shared/stores/projects/projects.model.ts | 6 +- .../stores/registration-provider/index.ts | 4 + .../registration-provider.actions.ts} | 0 .../registration-provider.model.ts | 13 ++ .../registration-provider.selectors.ts | 16 +++ .../registration-provider.state.ts | 78 ++++++++++ 111 files changed, 711 insertions(+), 558 deletions(-) delete mode 100644 src/app/features/registries/mappers/providers.mapper.ts create mode 100644 src/app/features/registries/models/project-short-info.model.ts delete mode 100644 src/app/features/registries/models/project.ts delete mode 100644 src/app/features/registries/models/registry-provider-json-api.model.ts delete mode 100644 src/app/features/registries/store/registries-provider-search/index.ts delete mode 100644 src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts delete mode 100644 src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts delete mode 100644 src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts delete mode 100644 src/app/shared/components/scheduled-banner/schedule-banner.component.spec.ts create mode 100644 src/app/shared/mappers/registration-provider.mapper.ts create mode 100644 src/app/shared/models/provider/collections-provider-json-api.model.ts rename src/app/{features/registries/models => shared/models/provider}/registry-provider.model.ts (91%) rename src/app/{features/registries/models => shared/models/registration}/provider-schema.model.ts (100%) rename src/app/{features/registries/services/providers.service.ts => shared/services/registration-provider.service.ts} (53%) create mode 100644 src/app/shared/stores/registration-provider/index.ts rename src/app/{features/registries/store/registries-provider-search/registries-provider-search.actions.ts => shared/stores/registration-provider/registration-provider.actions.ts} (100%) create mode 100644 src/app/shared/stores/registration-provider/registration-provider.model.ts create mode 100644 src/app/shared/stores/registration-provider/registration-provider.selectors.ts create mode 100644 src/app/shared/stores/registration-provider/registration-provider.state.ts diff --git a/src/app/core/components/nav-menu/nav-menu.component.ts b/src/app/core/components/nav-menu/nav-menu.component.ts index 5b5ff3c46..f9d36bde5 100644 --- a/src/app/core/components/nav-menu/nav-menu.component.ts +++ b/src/app/core/components/nav-menu/nav-menu.component.ts @@ -12,12 +12,13 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router'; import { MENU_ITEMS } from '@core/constants'; +import { ProviderSelectors } from '@core/store/provider'; import { filterMenuItems, updateMenuItems } from '@osf/core/helpers'; import { RouteContext } from '@osf/core/models'; import { AuthService } from '@osf/core/services'; import { UserSelectors } from '@osf/core/store/user'; import { IconComponent } from '@osf/shared/components'; -import { CurrentResourceType } from '@osf/shared/enums'; +import { CurrentResourceType, ReviewPermissions } from '@osf/shared/enums'; import { getViewOnlyParam } from '@osf/shared/helpers'; import { WrapFnPipe } from '@osf/shared/pipes'; import { CurrentResourceSelectors } from '@osf/shared/stores'; @@ -37,6 +38,7 @@ export class NavMenuComponent { private readonly isAuthenticated = select(UserSelectors.isAuthenticated); private readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); + private readonly provider = select(ProviderSelectors.getCurrentProvider); readonly mainMenuItems = computed(() => { const isAuthenticated = this.isAuthenticated(); @@ -44,7 +46,7 @@ export class NavMenuComponent { const routeContext: RouteContext = { resourceId: this.currentResourceId(), - providerId: this.currentProviderId(), + providerId: this.provider()?.id, isProject: this.currentResource()?.type === CurrentResourceType.Projects && this.currentResourceId() === this.currentResource()?.id, @@ -53,6 +55,12 @@ export class NavMenuComponent { this.currentResourceId() === this.currentResource()?.id, isPreprint: this.isPreprintRoute(), preprintReviewsPageVisible: this.canUserViewReviews(), + registrationModerationPageVisible: + this.provider()?.type === CurrentResourceType.Registrations && + this.provider()?.permissions?.includes(ReviewPermissions.ViewSubmissions), + collectionModerationPageVisible: + this.provider()?.type === CurrentResourceType.Collections && + this.provider()?.permissions?.includes(ReviewPermissions.ViewSubmissions), isCollections: this.isCollectionsRoute() || false, currentUrl: this.router.url, isViewOnly: !!getViewOnlyParam(this.router), diff --git a/src/app/core/constants/nav-items.constant.ts b/src/app/core/constants/nav-items.constant.ts index c5cbe3a6b..5537cda8a 100644 --- a/src/app/core/constants/nav-items.constant.ts +++ b/src/app/core/constants/nav-items.constant.ts @@ -329,7 +329,7 @@ export const MENU_ITEMS: MenuItem[] = [ routerLink: 'moderation', label: 'navigation.moderation', visible: false, - routerLinkActiveOptions: { exact: true }, + routerLinkActiveOptions: { exact: false }, }, ], }, diff --git a/src/app/core/helpers/nav-menu.helper.ts b/src/app/core/helpers/nav-menu.helper.ts index 306801165..02b5a94a7 100644 --- a/src/app/core/helpers/nav-menu.helper.ts +++ b/src/app/core/helpers/nav-menu.helper.ts @@ -61,7 +61,7 @@ export function updateMenuItems(menuItems: MenuItem[], ctx: RouteContext): MenuI } if (item.id === 'collections') { - return { ...item, visible: ctx.isCollections }; + return updateCollectionMenuItem(item, ctx); } return item; @@ -137,6 +137,15 @@ function updateRegistryMenuItem(item: MenuItem, ctx: RouteContext): MenuItem { } return { ...subItem, visible: false, expanded: false }; } + + if (subItem.id === 'registries-moderation') { + return { + ...subItem, + visible: ctx.registrationModerationPageVisible, + routerLink: ['/registries', ctx.providerId, 'moderation'], + }; + } + return subItem; }); @@ -169,3 +178,20 @@ function updatePreprintMenuItem(item: MenuItem, ctx: RouteContext): MenuItem { return { ...item, expanded: ctx.isPreprint, items }; } + +function updateCollectionMenuItem(item: MenuItem, ctx: RouteContext): MenuItem { + const isCollections = ctx.isCollections; + + const items = (item.items || []).map((subItem) => { + if (subItem.id === 'collections-moderation') { + return { + ...subItem, + visible: isCollections && ctx.collectionModerationPageVisible, + routerLink: ['/collections', ctx.providerId, 'moderation'], + }; + } + return subItem; + }); + + return { ...item, items, visible: ctx.isCollections }; +} diff --git a/src/app/core/models/route-context.model.ts b/src/app/core/models/route-context.model.ts index 636bcf291..f87b3e82a 100644 --- a/src/app/core/models/route-context.model.ts +++ b/src/app/core/models/route-context.model.ts @@ -5,6 +5,8 @@ export interface RouteContext { isRegistry: boolean; isPreprint: boolean; preprintReviewsPageVisible?: boolean; + registrationModerationPageVisible?: boolean; + collectionModerationPageVisible?: boolean; isCollections: boolean; currentUrl?: string; isViewOnly?: boolean; diff --git a/src/app/core/store/provider/provider.actions.ts b/src/app/core/store/provider/provider.actions.ts index 698ca905b..aeed386b5 100644 --- a/src/app/core/store/provider/provider.actions.ts +++ b/src/app/core/store/provider/provider.actions.ts @@ -1,6 +1,10 @@ -import { ProviderModel } from '@osf/shared/models'; +import { ProviderShortInfoModel } from '@osf/shared/models'; export class SetCurrentProvider { static readonly type = '[Provider] Set Current Provider'; - constructor(public provider: ProviderModel) {} + constructor(public provider: ProviderShortInfoModel) {} +} + +export class ClearCurrentProvider { + static readonly type = '[Provider] Clear Current Provider'; } diff --git a/src/app/core/store/provider/provider.model.ts b/src/app/core/store/provider/provider.model.ts index 61d743858..bc5d90faa 100644 --- a/src/app/core/store/provider/provider.model.ts +++ b/src/app/core/store/provider/provider.model.ts @@ -1,7 +1,7 @@ -import { ProviderModel } from '@osf/shared/models'; +import { ProviderShortInfoModel } from '@osf/shared/models'; export interface ProviderStateModel { - currentProvider: ProviderModel | null; + currentProvider: ProviderShortInfoModel | null; } export const PROVIDER_STATE_INITIAL: ProviderStateModel = { diff --git a/src/app/core/store/provider/provider.selectors.ts b/src/app/core/store/provider/provider.selectors.ts index 9379ae8dc..c63f3b537 100644 --- a/src/app/core/store/provider/provider.selectors.ts +++ b/src/app/core/store/provider/provider.selectors.ts @@ -1,13 +1,13 @@ import { Selector } from '@ngxs/store'; -import { ProviderModel } from '@osf/shared/models'; +import { ProviderShortInfoModel } from '@osf/shared/models'; import { ProviderStateModel } from './provider.model'; import { ProviderState } from './provider.state'; export class ProviderSelectors { @Selector([ProviderState]) - static getCurrentProvider(state: ProviderStateModel): ProviderModel | null { + static getCurrentProvider(state: ProviderStateModel): ProviderShortInfoModel | null { return state.currentProvider; } } diff --git a/src/app/core/store/provider/provider.state.ts b/src/app/core/store/provider/provider.state.ts index 10cc0d83a..d0e62c67f 100644 --- a/src/app/core/store/provider/provider.state.ts +++ b/src/app/core/store/provider/provider.state.ts @@ -2,7 +2,7 @@ import { Action, State, StateContext } from '@ngxs/store'; import { Injectable } from '@angular/core'; -import { SetCurrentProvider } from './provider.actions'; +import { ClearCurrentProvider, SetCurrentProvider } from './provider.actions'; import { PROVIDER_STATE_INITIAL, ProviderStateModel } from './provider.model'; @State({ @@ -13,8 +13,11 @@ import { PROVIDER_STATE_INITIAL, ProviderStateModel } from './provider.model'; export class ProviderState { @Action(SetCurrentProvider) setCurrentProvider(ctx: StateContext, action: SetCurrentProvider) { - ctx.patchState({ - currentProvider: action.provider, - }); + ctx.patchState({ currentProvider: action.provider }); + } + + @Action(ClearCurrentProvider) + clearCurrentProvider(ctx: StateContext) { + ctx.setState(PROVIDER_STATE_INITIAL); } } diff --git a/src/app/core/store/user/user.actions.ts b/src/app/core/store/user/user.actions.ts index 591903ade..b76a8b834 100644 --- a/src/app/core/store/user/user.actions.ts +++ b/src/app/core/store/user/user.actions.ts @@ -45,10 +45,6 @@ export class UpdateProfileSettingsUser { constructor(public payload: Partial) {} } -export class SetUserAsModerator { - static readonly type = '[User] Set User As Moderator'; -} - export class AcceptTermsOfServiceByUser { static readonly type = '[User] Accept Terms Of Service'; } diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 88ab9dd9d..5aad4c852 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -58,11 +58,6 @@ export class UserSelectors { return state.currentUser.data?.social; } - @Selector([UserState]) - static isCurrentUserModerator(state: UserStateModel): boolean { - return !!state.currentUser.data?.isModerator; - } - @Selector([UserState]) static getCanViewReviews(state: UserStateModel): boolean { return state.currentUser.data?.canViewReviews || false; diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index 58e2dfd72..8404141ec 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -18,7 +18,6 @@ import { GetCurrentUser, GetCurrentUserSettings, SetCurrentUser, - SetUserAsModerator, UpdateProfileSettingsEducation, UpdateProfileSettingsEmployment, UpdateProfileSettingsSocialLinks, @@ -234,26 +233,6 @@ export class UserState { ); } - @Action(SetUserAsModerator) - setUserAsModerator(ctx: StateContext) { - const state = ctx.getState(); - const currentUser = state.currentUser.data; - - if (!currentUser) { - return; - } - - ctx.patchState({ - currentUser: { - ...state.currentUser, - data: { - ...currentUser, - isModerator: true, - }, - }, - }); - } - @Action(AcceptTermsOfServiceByUser) acceptTermsOfServiceByUser(ctx: StateContext) { const state = ctx.getState(); diff --git a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts index 36939b6f5..166109926 100644 --- a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts +++ b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts @@ -167,7 +167,7 @@ export class InstitutionsProjectsComponent implements OnInit, OnDestroy { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => this.toastService.showSuccess('adminInstitutions.institutionUsers.messageSent')); } else { - const projectId = (userRowData['title'] as TableCellLink).url.split('/').pop() || ''; + const projectId = (userRowData['link'] as TableCellLink).url.split('/').pop() || ''; this.actions .requestProjectAccess({ diff --git a/src/app/features/collections/collections.routes.ts b/src/app/features/collections/collections.routes.ts index 7f6b05c54..cd585fb10 100644 --- a/src/app/features/collections/collections.routes.ts +++ b/src/app/features/collections/collections.routes.ts @@ -6,7 +6,14 @@ import { authGuard } from '@osf/core/guards'; import { AddToCollectionState } from '@osf/features/collections/store/add-to-collection'; import { CollectionsModerationState } from '@osf/features/moderation/store/collections-moderation'; import { ConfirmLeavingGuard } from '@shared/guards'; -import { BookmarksState, CitationsState, ContributorsState, NodeLinksState, ProjectsState } from '@shared/stores'; +import { + BookmarksState, + CitationsState, + ContributorsState, + NodeLinksState, + ProjectsState, + SubjectsState, +} from '@shared/stores'; import { CollectionsState } from '@shared/stores/collections'; export const collectionsRoutes: Routes = [ @@ -58,7 +65,14 @@ export const collectionsRoutes: Routes = [ '@osf/features/moderation/components/collection-submission-overview/collection-submission-overview.component' ).then((mod) => mod.CollectionSubmissionOverviewComponent), providers: [ - provideStates([NodeLinksState, CitationsState, CollectionsModerationState, CollectionsState, BookmarksState]), + provideStates([ + NodeLinksState, + CitationsState, + CollectionsModerationState, + CollectionsState, + BookmarksState, + SubjectsState, + ]), ], }, ], diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts index 6fe5c6b1e..6f20dbe3f 100644 --- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts @@ -37,7 +37,7 @@ import { TagsInputComponent, TextInputComponent, TruncatedTextComponent } from ' import { InputLimits } from '@shared/constants'; import { ResourceType } from '@shared/enums'; import { LicenseModel } from '@shared/models'; -import { Project } from '@shared/models/projects'; +import { ProjectModel } from '@shared/models/projects'; import { InterpolatePipe } from '@shared/pipes'; import { ToastService } from '@shared/services'; import { ClearProjects, GetAllContributors, UpdateProjectMetadata } from '@shared/stores'; @@ -189,7 +189,7 @@ export class ProjectMetadataStepComponent { this.metadataSaved.emit(); } - private updateProjectMetadata(selectedProject: Project): void { + private updateProjectMetadata(selectedProject: ProjectModel): void { const metadata = this.formService.buildMetadataPayload(this.projectMetadataForm, selectedProject); this.actions diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts index f88889e4a..78d9953c3 100644 --- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts @@ -10,7 +10,7 @@ import { ChangeDetectionStrategy, Component, computed, input, output, signal } f import { AddToCollectionSteps } from '@osf/features/collections/enums'; import { SetSelectedProject } from '@osf/shared/stores'; import { ProjectSelectorComponent } from '@shared/components'; -import { Project } from '@shared/models/projects'; +import { ProjectModel } from '@shared/models/projects'; import { CollectionsSelectors, GetUserCollectionSubmissions } from '@shared/stores/collections'; import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; @@ -32,7 +32,7 @@ export class SelectProjectStepComponent { stepChange = output(); projectSelected = output(); - currentSelectedProject = signal(null); + currentSelectedProject = signal(null); excludedProjectIds = computed(() => { const submissions = this.currentUserSubmissions(); @@ -44,7 +44,7 @@ export class SelectProjectStepComponent { getUserCollectionSubmissions: GetUserCollectionSubmissions, }); - handleProjectChange(project: Project | null): void { + handleProjectChange(project: ProjectModel | null): void { if (project) { this.currentSelectedProject.set(project); this.actions.setSelectedProject(project); @@ -53,7 +53,7 @@ export class SelectProjectStepComponent { } } - handleProjectsLoaded(projects: Project[]): void { + handleProjectsLoaded(projects: ProjectModel[]): void { const collectionId = this.collectionId(); if (collectionId && projects.length) { const projectIds = projects.map((project) => project.id); diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.ts index 363456ffe..1f02a6d42 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts @@ -12,12 +12,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { CollectionsHelpDialogComponent, CollectionsMainContentComponent } from '@osf/features/collections/components'; -import { CollectionsQuerySyncService } from '@osf/features/collections/services'; -import { LoadingSpinnerComponent, SearchInputComponent } from '@shared/components'; -import { HeaderStyleHelper } from '@shared/helpers'; -import { CollectionsFilters } from '@shared/models'; -import { BrandService } from '@shared/services'; +import { ClearCurrentProvider } from '@core/store/provider'; +import { LoadingSpinnerComponent, SearchInputComponent } from '@osf/shared/components'; +import { HeaderStyleHelper } from '@osf/shared/helpers'; +import { CollectionsFilters } from '@osf/shared/models'; +import { BrandService } from '@osf/shared/services'; import { ClearCollections, ClearCollectionSubmissions, @@ -27,7 +26,11 @@ import { SearchCollectionSubmissions, SetPageNumber, SetSearchValue, -} from '@shared/stores/collections'; +} from '@osf/shared/stores'; + +import { CollectionsQuerySyncService } from '../../services'; +import { CollectionsHelpDialogComponent } from '../collections-help-dialog/collections-help-dialog.component'; +import { CollectionsMainContentComponent } from '../collections-main-content'; @Component({ selector: 'osf-collections-discover', @@ -72,6 +75,7 @@ export class CollectionsDiscoverComponent { setPageNumber: SetPageNumber, clearCollections: ClearCollections, clearCollectionsSubmissions: ClearCollectionSubmissions, + clearCurrentProvider: ClearCurrentProvider, }); constructor() { diff --git a/src/app/features/collections/components/collections-filter-chips/collections-filter-chips.component.ts b/src/app/features/collections/components/collections-filter-chips/collections-filter-chips.component.ts index 77f067bf1..67a36c4ff 100644 --- a/src/app/features/collections/components/collections-filter-chips/collections-filter-chips.component.ts +++ b/src/app/features/collections/components/collections-filter-chips/collections-filter-chips.component.ts @@ -4,8 +4,6 @@ import { Chip } from 'primeng/chip'; import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; -import { collectionFilterTypes } from '@osf/features/collections/constants/filter-types.const'; -import { CollectionFilterType } from '@osf/features/collections/enums'; import { CollectionsSelectors, SetCollectedTypeFilters, @@ -20,6 +18,9 @@ import { SetVolumeFilters, } from '@shared/stores/collections'; +import { collectionFilterTypes } from '../../constants'; +import { CollectionFilterType } from '../../enums'; + @Component({ selector: 'osf-collections-filter-chips', imports: [Chip], diff --git a/src/app/features/collections/components/collections-filters/collections-filters.component.scss b/src/app/features/collections/components/collections-filters/collections-filters.component.scss index 87860ca0c..bd2451693 100644 --- a/src/app/features/collections/components/collections-filters/collections-filters.component.scss +++ b/src/app/features/collections/components/collections-filters/collections-filters.component.scss @@ -1,8 +1,7 @@ -@use "styles/variables" as var; @use "styles/mixins" as mix; .filters { - border: 1px solid var.$grey-2; + border: 1px solid var(--grey-2); border-radius: mix.rem(12px); padding: 0 mix.rem(20px); display: flex; diff --git a/src/app/features/collections/components/collections-filters/collections-filters.component.ts b/src/app/features/collections/components/collections-filters/collections-filters.component.ts index cd560197c..683830251 100644 --- a/src/app/features/collections/components/collections-filters/collections-filters.component.ts +++ b/src/app/features/collections/components/collections-filters/collections-filters.component.ts @@ -8,8 +8,6 @@ import { MultiSelect, MultiSelectChangeEvent } from 'primeng/multiselect'; import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { collectionFilterTypes } from '@osf/features/collections/constants/filter-types.const'; -import { CollectionFilterType } from '@osf/features/collections/enums'; import { CollectionsSelectors, SetCollectedTypeFilters, @@ -24,6 +22,9 @@ import { SetVolumeFilters, } from '@shared/stores/collections'; +import { collectionFilterTypes } from '../../constants'; +import { CollectionFilterType } from '../../enums'; + @Component({ selector: 'osf-collections-filters', imports: [FormsModule, MultiSelect, Accordion, AccordionContent, AccordionHeader, AccordionPanel, TranslatePipe], diff --git a/src/app/features/collections/components/collections-help-dialog/collections-help-dialog.component.html b/src/app/features/collections/components/collections-help-dialog/collections-help-dialog.component.html index 4cd774891..fad0705a6 100644 --- a/src/app/features/collections/components/collections-help-dialog/collections-help-dialog.component.html +++ b/src/app/features/collections/components/collections-help-dialog/collections-help-dialog.component.html @@ -1,8 +1,8 @@ diff --git a/src/app/features/collections/components/collections-help-dialog/collections-help-dialog.component.scss b/src/app/features/collections/components/collections-help-dialog/collections-help-dialog.component.scss index c394c1464..a439c928e 100644 --- a/src/app/features/collections/components/collections-help-dialog/collections-help-dialog.component.scss +++ b/src/app/features/collections/components/collections-help-dialog/collections-help-dialog.component.scss @@ -1,7 +1,5 @@ -@use "styles/variables" as var; - .dialog-content { - border-top: 1px solid var.$grey-2; - border-bottom: 1px solid var.$grey-2; + border-top: 1px solid var(--grey-2); + border-bottom: 1px solid var(--grey-2); line-height: 2; } diff --git a/src/app/features/collections/components/collections-main-content/collections-main-content.component.scss b/src/app/features/collections/components/collections-main-content/collections-main-content.component.scss index ba7865aa7..7a7ac0cb2 100644 --- a/src/app/features/collections/components/collections-main-content/collections-main-content.component.scss +++ b/src/app/features/collections/components/collections-main-content/collections-main-content.component.scss @@ -1,16 +1,15 @@ -@use "styles/variables" as var; @use "styles/mixins" as mix; .sort-card { @include mix.flex-center; width: 100%; height: mix.rem(48px); - border: 1px solid var.$grey-2; + border: 1px solid var(--grey-2); border-radius: mix.rem(12px); padding: 0 mix.rem(28px); cursor: pointer; } .card-selected { - background: var.$bg-blue-2; + background: var(--bg-blue-2); } diff --git a/src/app/features/collections/components/collections-main-content/collections-main-content.component.ts b/src/app/features/collections/components/collections-main-content/collections-main-content.component.ts index 700cc6b69..3f01aa829 100644 --- a/src/app/features/collections/components/collections-main-content/collections-main-content.component.ts +++ b/src/app/features/collections/components/collections-main-content/collections-main-content.component.ts @@ -48,22 +48,16 @@ export class CollectionsMainContentComponent { isCollectionProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); isCollectionDetailsLoading = select(CollectionsSelectors.getCollectionDetailsLoading); - isCollectionLoading = computed(() => { - return this.isCollectionProviderLoading() || this.isCollectionDetailsLoading(); - }); + isCollectionLoading = computed(() => this.isCollectionProviderLoading() || this.isCollectionDetailsLoading()); hasAnySelectedFilters = computed(() => { const currentFilters = this.selectedFilters(); - const hasSelectedFiltersOptions = Object.values(currentFilters).some((value) => { - return value.length; - }); + const hasSelectedFiltersOptions = Object.values(currentFilters).some((value) => value.length); return hasSelectedFiltersOptions; }); - actions = createDispatchMap({ - setSortBy: SetSortBy, - }); + actions = createDispatchMap({ setSortBy: SetSortBy }); openFilters(): void { this.isFiltersOpen.set(!this.isFiltersOpen()); diff --git a/src/app/features/collections/components/collections-search-results/collections-search-results.component.ts b/src/app/features/collections/components/collections-search-results/collections-search-results.component.ts index c456fb6af..db710ea7d 100644 --- a/src/app/features/collections/components/collections-search-results/collections-search-results.component.ts +++ b/src/app/features/collections/components/collections-search-results/collections-search-results.component.ts @@ -27,13 +27,9 @@ export class CollectionsSearchResultsComponent { totalSubmissions = select(CollectionsSelectors.getTotalSubmissions); pageNumber = select(CollectionsSelectors.getPageNumber); - actions = createDispatchMap({ - setPageNumber: SetPageNumber, - }); + actions = createDispatchMap({ setPageNumber: SetPageNumber }); - isLoading = computed(() => { - return this.isCollectionDetailsLoading() || this.isCollectionSubmissionsLoading(); - }); + isLoading = computed(() => this.isCollectionDetailsLoading() || this.isCollectionSubmissionsLoading()); firstIndex = computed(() => (parseInt(this.pageNumber()) - 1) * 10); diff --git a/src/app/features/collections/constants/filter-types.const.ts b/src/app/features/collections/constants/filter-types.const.ts index 6645f4a75..0cfc6f232 100644 --- a/src/app/features/collections/constants/filter-types.const.ts +++ b/src/app/features/collections/constants/filter-types.const.ts @@ -1,4 +1,4 @@ -import { CollectionFilterType } from '@osf/features/collections/enums'; +import { CollectionFilterType } from '../enums'; export const collectionFilterTypes: CollectionFilterType[] = [ CollectionFilterType.ProgramArea, diff --git a/src/app/features/collections/constants/index.ts b/src/app/features/collections/constants/index.ts index f1e8192b6..5e7661ebb 100644 --- a/src/app/features/collections/constants/index.ts +++ b/src/app/features/collections/constants/index.ts @@ -1,2 +1,4 @@ export * from './filter-names.const'; +export * from './filter-types.const'; +export * from './query-params-keys.const'; export * from './sort-options.const'; diff --git a/src/app/features/collections/services/project-metadata-form.service.ts b/src/app/features/collections/services/project-metadata-form.service.ts index 75fb6e2cf..5c518b105 100644 --- a/src/app/features/collections/services/project-metadata-form.service.ts +++ b/src/app/features/collections/services/project-metadata-form.service.ts @@ -5,7 +5,7 @@ import { ProjectMetadataFormControls } from '@osf/features/collections/enums'; import { ProjectMetadataForm } from '@osf/features/collections/models'; import { CustomValidators } from '@osf/shared/helpers'; import { LicenseModel, ProjectMetadataUpdatePayload } from '@shared/models'; -import { Project } from '@shared/models/projects'; +import { ProjectModel } from '@shared/models/projects'; @Injectable({ providedIn: 'root', @@ -55,7 +55,7 @@ export class ProjectMetadataFormService { populateFormFromProject( form: FormGroup, - project: Project, + project: ProjectModel, license: LicenseModel | null ): { tags: string[] } { const tags = project.tags || []; @@ -73,7 +73,7 @@ export class ProjectMetadataFormService { return { tags }; } - patchLicenseData(form: FormGroup, license: LicenseModel, project: Project): void { + patchLicenseData(form: FormGroup, license: LicenseModel, project: ProjectModel): void { form.patchValue({ [ProjectMetadataFormControls.License]: license, [ProjectMetadataFormControls.LicenseYear]: @@ -87,7 +87,7 @@ export class ProjectMetadataFormService { form.get(ProjectMetadataFormControls.Tags)?.markAsTouched(); } - buildMetadataPayload(form: FormGroup, project: Project): ProjectMetadataUpdatePayload { + buildMetadataPayload(form: FormGroup, project: ProjectModel): ProjectMetadataUpdatePayload { const formValue = form.value; return { diff --git a/src/app/features/home/pages/dashboard/dashboard.component.html b/src/app/features/home/pages/dashboard/dashboard.component.html index 40194e726..24d95d494 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.html +++ b/src/app/features/home/pages/dashboard/dashboard.component.html @@ -77,9 +77,7 @@

    {{ 'home.loggedIn.hosting.title' | translate }}

    [buttonLabel]="'home.loggedIn.dashboard.createProject' | translate" (buttonClick)="createProject()" /> - <<<<<<< HEAD - ======= >>>>>>> origin/develop

    {{ 'home.loggedIn.dashboard.noCreatedProject' | translate }}

    diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts index c4e9cc827..7517fc8e4 100644 --- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts +++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts @@ -37,8 +37,8 @@ import { DeleteContributor, UpdateBibliographyFilter, UpdateContributor, + UpdateContributorsSearchValue, UpdatePermissionFilter, - UpdateSearchValue, } from '@osf/shared/stores'; @Component({ @@ -70,13 +70,14 @@ export class ContributorsDialogComponent implements OnInit { const initialContributors = this.initialContributors(); if (!currentUserId) return false; - return initialContributors.some((contributor: ContributorModel) => { - return contributor.userId === currentUserId && contributor.permission === ContributorPermission.Admin; - }); + return initialContributors.some( + (contributor: ContributorModel) => + contributor.userId === currentUserId && contributor.permission === ContributorPermission.Admin + ); }); actions = createDispatchMap({ - updateSearchValue: UpdateSearchValue, + updateSearchValue: UpdateContributorsSearchValue, updatePermissionFilter: UpdatePermissionFilter, updateBibliographyFilter: UpdateBibliographyFilter, deleteContributor: DeleteContributor, diff --git a/src/app/features/moderation/collection-moderation.routes.ts b/src/app/features/moderation/collection-moderation.routes.ts index 9264f9c16..421f1cee5 100644 --- a/src/app/features/moderation/collection-moderation.routes.ts +++ b/src/app/features/moderation/collection-moderation.routes.ts @@ -17,7 +17,7 @@ export const collectionModerationRoutes: Routes = [ import('@osf/features/moderation/pages/collection-moderation/collection-moderation.component').then( (m) => m.CollectionModerationComponent ), - providers: [provideStates([ActivityLogsState])], + providers: [provideStates([ActivityLogsState, CollectionsState])], children: [ { path: '', @@ -31,7 +31,7 @@ export const collectionModerationRoutes: Routes = [ (m) => m.CollectionModerationSubmissionsComponent ), data: { tab: CollectionModerationTab.AllItems }, - providers: [provideStates([CollectionsModerationState, CollectionsState])], + providers: [provideStates([CollectionsModerationState])], }, { path: 'moderators', diff --git a/src/app/features/moderation/components/collection-moderation-submissions/collection-moderation-submissions.component.ts b/src/app/features/moderation/components/collection-moderation-submissions/collection-moderation-submissions.component.ts index 066f90cc8..867e22cda 100644 --- a/src/app/features/moderation/components/collection-moderation-submissions/collection-moderation-submissions.component.ts +++ b/src/app/features/moderation/components/collection-moderation-submissions/collection-moderation-submissions.component.ts @@ -22,7 +22,6 @@ import { ClearCollectionSubmissions, CollectionsSelectors, GetCollectionDetails, - GetCollectionProvider, SearchCollectionSubmissions, SetPageNumber, } from '@osf/shared/stores'; @@ -62,15 +61,12 @@ export class CollectionModerationSubmissionsComponent { isSubmissionsLoading = select(CollectionsModerationSelectors.getCollectionSubmissionsLoading); collectionSubmissions = select(CollectionsModerationSelectors.getCollectionSubmissions); totalSubmissions = select(CollectionsModerationSelectors.getCollectionSubmissionsTotalCount); - providerId = signal(''); primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id); reviewStatus = signal(SubmissionReviewStatus.Pending); currentPage = signal('1'); pageSize = 10; - isLoading = computed(() => { - return this.isCollectionProviderLoading() || this.isSubmissionsLoading(); - }); + isLoading = computed(() => this.isCollectionProviderLoading() || this.isSubmissionsLoading()); sortOptions = COLLECTION_SUBMISSIONS_SORT_OPTIONS; selectedSortOption = signal(this.sortOptions[0].value); @@ -78,7 +74,6 @@ export class CollectionModerationSubmissionsComponent { firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * 10); actions = createDispatchMap({ - getCollectionProvider: GetCollectionProvider, getCollectionDetails: GetCollectionDetails, searchCollectionSubmissions: SearchCollectionSubmissions, getCollectionSubmissions: GetCollectionSubmissions, @@ -129,7 +124,6 @@ export class CollectionModerationSubmissionsComponent { constructor() { this.initializeFromQueryParams(); - this.initializeCollectionProvider(); this.setupEffects(); } @@ -193,16 +187,4 @@ export class CollectionModerationSubmissionsComponent { queryParamsHandling: 'merge', }); } - - private initializeCollectionProvider(): void { - const id = this.route.parent?.snapshot.paramMap.get('providerId'); - - if (!id) { - this.router.navigate(['/not-found']); - return; - } - - this.providerId.set(id); - this.actions.getCollectionProvider(id); - } } diff --git a/src/app/features/moderation/components/moderators-list/moderators-list.component.ts b/src/app/features/moderation/components/moderators-list/moderators-list.component.ts index 95bf596be..9e963da8c 100644 --- a/src/app/features/moderation/components/moderators-list/moderators-list.component.ts +++ b/src/app/features/moderation/components/moderators-list/moderators-list.component.ts @@ -26,7 +26,6 @@ import { UserSelectors } from '@core/store/user'; import { SearchInputComponent } from '@osf/shared/components'; import { ResourceType } from '@osf/shared/enums'; import { CustomConfirmationService, ToastService } from '@osf/shared/services'; -import { UpdateSearchValue } from '@osf/shared/stores'; import { AddModeratorType, ModeratorPermission } from '../../enums'; import { ModeratorDialogAddModel, ModeratorModel } from '../../models'; @@ -36,6 +35,7 @@ import { LoadModerators, ModeratorsSelectors, UpdateModerator, + UpdateModeratorsSearchValue, } from '../../store/moderators'; import { AddModeratorDialogComponent } from '../add-moderator-dialog/add-moderator-dialog.component'; import { InviteModeratorDialogComponent } from '../invite-moderator-dialog/invite-moderator-dialog.component'; @@ -83,7 +83,7 @@ export class ModeratorsListComponent implements OnInit { actions = createDispatchMap({ loadModerators: LoadModerators, - updateSearchValue: UpdateSearchValue, + updateSearchValue: UpdateModeratorsSearchValue, addModerators: AddModerator, updateModerator: UpdateModerator, deleteModerator: DeleteModerator, diff --git a/src/app/features/moderation/pages/collection-moderation/collection-moderation.component.spec.ts b/src/app/features/moderation/pages/collection-moderation/collection-moderation.component.spec.ts index 8015bb200..ed46afac6 100644 --- a/src/app/features/moderation/pages/collection-moderation/collection-moderation.component.spec.ts +++ b/src/app/features/moderation/pages/collection-moderation/collection-moderation.component.spec.ts @@ -10,6 +10,9 @@ import { TranslateServiceMock } from '@shared/mocks'; import { CollectionModerationComponent } from './collection-moderation.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('CollectionModerationComponent', () => { let component: CollectionModerationComponent; let fixture: ComponentFixture; @@ -22,6 +25,7 @@ describe('CollectionModerationComponent', () => { tab: null, }, }, + params: { providerId: 'osf' }, }, }; @@ -33,11 +37,12 @@ describe('CollectionModerationComponent', () => { isMediumSubject = new BehaviorSubject(true); await TestBed.configureTestingModule({ - imports: [CollectionModerationComponent], + imports: [CollectionModerationComponent, OSFTestingModule], providers: [ { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: Router, useValue: mockRouter }, MockProvider(IS_MEDIUM, isMediumSubject), + provideMockStore(), TranslateServiceMock, ], }).compileComponents(); diff --git a/src/app/features/moderation/pages/collection-moderation/collection-moderation.component.ts b/src/app/features/moderation/pages/collection-moderation/collection-moderation.component.ts index b847526fc..a6ea58f3d 100644 --- a/src/app/features/moderation/pages/collection-moderation/collection-moderation.component.ts +++ b/src/app/features/moderation/pages/collection-moderation/collection-moderation.component.ts @@ -1,14 +1,18 @@ +import { createDispatchMap } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Tab, TabList, TabPanels, Tabs } from 'primeng/tabs'; -import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; +import { ClearCurrentProvider } from '@core/store/provider'; import { SelectComponent, SubHeaderComponent } from '@osf/shared/components'; import { IS_MEDIUM, Primitive } from '@osf/shared/helpers'; +import { GetCollectionProvider } from '@osf/shared/stores'; import { COLLECTION_MODERATION_TABS } from '../../constants'; import { CollectionModerationTab } from '../../enums'; @@ -30,7 +34,7 @@ import { CollectionModerationTab } from '../../enums'; styleUrl: './collection-moderation.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CollectionModerationComponent implements OnInit { +export class CollectionModerationComponent implements OnInit, OnDestroy { readonly route = inject(ActivatedRoute); readonly router = inject(Router); @@ -39,8 +43,25 @@ export class CollectionModerationComponent implements OnInit { selectedTab = CollectionModerationTab.AllItems; + actions = createDispatchMap({ + getCollectionProvider: GetCollectionProvider, + clearCurrentProvider: ClearCurrentProvider, + }); + ngOnInit(): void { this.selectedTab = this.route.snapshot.firstChild?.data['tab']; + const id = this.route.snapshot.params['providerId']; + + if (!id) { + this.router.navigate(['/not-found']); + return; + } + + this.actions.getCollectionProvider(id); + } + + ngOnDestroy(): void { + this.actions.clearCurrentProvider(); } onTabChange(value: Primitive): void { diff --git a/src/app/features/moderation/pages/registries-moderation/registries-moderation.component.spec.ts b/src/app/features/moderation/pages/registries-moderation/registries-moderation.component.spec.ts index bba2c1116..04aeb7c8b 100644 --- a/src/app/features/moderation/pages/registries-moderation/registries-moderation.component.spec.ts +++ b/src/app/features/moderation/pages/registries-moderation/registries-moderation.component.spec.ts @@ -10,6 +10,9 @@ import { TranslateServiceMock } from '@shared/mocks'; import { RegistriesModerationComponent } from './registries-moderation.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('RegistriesModerationComponent', () => { let component: RegistriesModerationComponent; let fixture: ComponentFixture; @@ -22,6 +25,7 @@ describe('RegistriesModerationComponent', () => { tab: null, }, }, + params: { providerId: 'osf' }, }, }; @@ -33,11 +37,12 @@ describe('RegistriesModerationComponent', () => { isMediumSubject = new BehaviorSubject(true); await TestBed.configureTestingModule({ - imports: [RegistriesModerationComponent], + imports: [RegistriesModerationComponent, OSFTestingModule], providers: [ { provide: ActivatedRoute, useValue: mockActivatedRoute }, MockProvider(Router, mockRouter), MockProvider(IS_MEDIUM, isMediumSubject), + provideMockStore(), TranslateServiceMock, ], }).compileComponents(); diff --git a/src/app/features/moderation/pages/registries-moderation/registries-moderation.component.ts b/src/app/features/moderation/pages/registries-moderation/registries-moderation.component.ts index 991ddb182..aec8cd656 100644 --- a/src/app/features/moderation/pages/registries-moderation/registries-moderation.component.ts +++ b/src/app/features/moderation/pages/registries-moderation/registries-moderation.component.ts @@ -1,15 +1,19 @@ +import { createDispatchMap } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Tab, TabList, TabPanels, Tabs } from 'primeng/tabs'; -import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; +import { ClearCurrentProvider } from '@core/store/provider'; import { SelectComponent, SubHeaderComponent } from '@osf/shared/components'; import { ResourceType } from '@osf/shared/enums'; import { IS_MEDIUM, Primitive } from '@osf/shared/helpers'; +import { GetRegistryProviderBrand } from '@osf/shared/stores/registration-provider'; import { REGISTRY_MODERATION_TABS } from '../../constants'; import { RegistryModerationTab } from '../../enums'; @@ -31,7 +35,7 @@ import { RegistryModerationTab } from '../../enums'; styleUrl: './registries-moderation.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistriesModerationComponent implements OnInit { +export class RegistriesModerationComponent implements OnInit, OnDestroy { readonly resourceType = ResourceType.Registration; readonly route = inject(ActivatedRoute); readonly router = inject(Router); @@ -39,10 +43,27 @@ export class RegistriesModerationComponent implements OnInit { readonly tabOptions = REGISTRY_MODERATION_TABS; readonly isMedium = toSignal(inject(IS_MEDIUM)); + actions = createDispatchMap({ + getProvider: GetRegistryProviderBrand, + clearCurrentProvider: ClearCurrentProvider, + }); + selectedTab = RegistryModerationTab.Submitted; ngOnInit(): void { - this.selectedTab = this.route.snapshot.firstChild?.data['tab'] as RegistryModerationTab; + this.selectedTab = this.route.snapshot.firstChild?.data['tab']; + const id = this.route.snapshot.params['providerId']; + + if (!id) { + this.router.navigate(['/not-found']); + return; + } + + this.actions.getProvider(id); + } + + ngOnDestroy(): void { + this.actions.clearCurrentProvider(); } onTabChange(value: Primitive): void { diff --git a/src/app/features/moderation/services/moderators.service.ts b/src/app/features/moderation/services/moderators.service.ts index df6558f2f..ee9a78dc8 100644 --- a/src/app/features/moderation/services/moderators.service.ts +++ b/src/app/features/moderation/services/moderators.service.ts @@ -26,7 +26,7 @@ export class ModeratorsService { ]); getModerators(resourceId: string, resourceType: ResourceType): Observable { - const baseUrl = `${this.apiUrl}/${this.urlMap.get(resourceType)}/${resourceId}/moderators`; + const baseUrl = `${this.apiUrl}/${this.urlMap.get(resourceType)}/${resourceId}/moderators/`; return this.jsonApiService .get(baseUrl) diff --git a/src/app/features/moderation/store/moderators/moderators.actions.ts b/src/app/features/moderation/store/moderators/moderators.actions.ts index fd98872bf..25f3186ae 100644 --- a/src/app/features/moderation/store/moderators/moderators.actions.ts +++ b/src/app/features/moderation/store/moderators/moderators.actions.ts @@ -43,7 +43,7 @@ export class DeleteModerator { ) {} } -export class UpdateSearchValue { +export class UpdateModeratorsSearchValue { static readonly type = `${ACTION_SCOPE} Update Search Value`; constructor(public searchValue: string | null) {} diff --git a/src/app/features/moderation/store/moderators/moderators.state.ts b/src/app/features/moderation/store/moderators/moderators.state.ts index 9db6dd46f..9733135af 100644 --- a/src/app/features/moderation/store/moderators/moderators.state.ts +++ b/src/app/features/moderation/store/moderators/moderators.state.ts @@ -16,7 +16,7 @@ import { LoadModerators, SearchUsers, UpdateModerator, - UpdateSearchValue, + UpdateModeratorsSearchValue, } from './moderators.actions'; import { MODERATORS_STATE_DEFAULTS, ModeratorsStateModel } from './moderators.model'; @@ -54,8 +54,8 @@ export class ModeratorsState { ); } - @Action(UpdateSearchValue) - updateSearchValue(ctx: StateContext, action: UpdateSearchValue) { + @Action(UpdateModeratorsSearchValue) + updateModeratorsSearchValue(ctx: StateContext, action: UpdateModeratorsSearchValue) { ctx.patchState({ moderators: { ...ctx.getState().moderators, searchValue: action.searchValue }, }); diff --git a/src/app/features/preprints/models/preprint-provider-json-api.models.ts b/src/app/features/preprints/models/preprint-provider-json-api.models.ts index 7e92396c2..c75f31f62 100644 --- a/src/app/features/preprints/models/preprint-provider-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-provider-json-api.models.ts @@ -1,6 +1,6 @@ +import { ReviewPermissions } from '@osf/shared/enums'; import { StringOrNull } from '@osf/shared/helpers'; -import { ReviewPermissions } from '@shared/enums/review-permissions.enum'; -import { BrandDataJsonApi } from '@shared/models'; +import { BrandDataJsonApi } from '@osf/shared/models'; import { ProviderReviewsWorkflow } from '../enums'; diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index c0a186b1e..9b3a8903d 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -22,6 +22,7 @@ import { import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; +import { ClearCurrentProvider } from '@core/store/provider'; import { UserSelectors } from '@core/store/user'; import { AdditionalInfoComponent, @@ -102,6 +103,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { fetchPreprintRequests: FetchPreprintRequests, fetchPreprintReviewActions: FetchPreprintReviewActions, fetchPreprintRequestActions: FetchPreprintRequestActions, + clearCurrentProvider: ClearCurrentProvider, }); currentUser = select(UserSelectors.getCurrentUser); preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); @@ -289,6 +291,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { ngOnDestroy() { this.actions.resetState(); + this.actions.clearCurrentProvider(); } fetchPreprintVersion(preprintVersionId: string) { diff --git a/src/app/features/preprints/store/preprint-providers/preprint-providers.state.ts b/src/app/features/preprints/store/preprint-providers/preprint-providers.state.ts index 3277d6af8..f7b9188e3 100644 --- a/src/app/features/preprints/store/preprint-providers/preprint-providers.state.ts +++ b/src/app/features/preprints/store/preprint-providers/preprint-providers.state.ts @@ -6,7 +6,9 @@ import { catchError } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; +import { SetCurrentProvider } from '@core/store/provider'; import { PreprintProvidersService } from '@osf/features/preprints/services'; +import { CurrentResourceType } from '@osf/shared/enums'; import { handleSectionError } from '@osf/shared/helpers'; import { @@ -33,6 +35,14 @@ export class PreprintProvidersState { const shouldRefresh = this.shouldRefresh(cachedData?.lastFetched); if (cachedData && !shouldRefresh) { + ctx.dispatch( + new SetCurrentProvider({ + id: cachedData.id, + name: cachedData.name, + type: CurrentResourceType.Preprints, + permissions: cachedData.permissions, + }) + ); return of(cachedData); } @@ -53,6 +63,15 @@ export class PreprintProvidersState { }), }) ); + + ctx.dispatch( + new SetCurrentProvider({ + id: preprintProvider.id, + name: preprintProvider.name, + type: CurrentResourceType.Preprints, + permissions: preprintProvider.permissions, + }) + ); }), catchError((error) => handleSectionError(ctx, 'preprintProvidersDetails', error)) ); diff --git a/src/app/features/project/contributors/contributors.component.spec.ts b/src/app/features/project/contributors/contributors.component.spec.ts index d5764ab3e..081356027 100644 --- a/src/app/features/project/contributors/contributors.component.spec.ts +++ b/src/app/features/project/contributors/contributors.component.spec.ts @@ -150,7 +150,7 @@ describe('ContributorsComponent', () => { expect(component.hasChanges).toBe(false); const modifiedContributors = [...mockContributors]; - modifiedContributors[0].permission = 'write'; + modifiedContributors[0].permission = ContributorPermission.Write; (component.contributors as any).set(modifiedContributors); expect((component.contributors as any)()).toEqual(modifiedContributors); @@ -158,7 +158,7 @@ describe('ContributorsComponent', () => { it('should cancel changes', () => { const modifiedContributors = [...mockContributors]; - modifiedContributors[0].permission = 'write'; + modifiedContributors[0].permission = ContributorPermission.Write; (component.contributors as any).set(modifiedContributors); component.cancel(); @@ -170,7 +170,7 @@ describe('ContributorsComponent', () => { jest.spyOn(component.toastService, 'showSuccess'); const modifiedContributors = [...mockContributors]; - modifiedContributors[0].permission = 'write'; + modifiedContributors[0].permission = ContributorPermission.Write; (component.contributors as any).set(modifiedContributors); expect(() => component.save()).not.toThrow(); @@ -180,7 +180,7 @@ describe('ContributorsComponent', () => { jest.spyOn(component.toastService, 'showError'); const modifiedContributors = [...mockContributors]; - modifiedContributors[0].permission = 'write'; + modifiedContributors[0].permission = ContributorPermission.Write; (component.contributors as any).set(modifiedContributors); expect(() => component.save()).not.toThrow(); diff --git a/src/app/features/project/contributors/contributors.component.ts b/src/app/features/project/contributors/contributors.component.ts index 2e047fad3..196caf851 100644 --- a/src/app/features/project/contributors/contributors.component.ts +++ b/src/app/features/project/contributors/contributors.component.ts @@ -53,8 +53,8 @@ import { GetResourceDetails, UpdateBibliographyFilter, UpdateContributor, + UpdateContributorsSearchValue, UpdatePermissionFilter, - UpdateSearchValue, ViewOnlyLinkSelectors, } from '@osf/shared/stores'; @@ -124,7 +124,7 @@ export class ContributorsComponent implements OnInit { getViewOnlyLinks: FetchViewOnlyLinks, getResourceDetails: GetResourceDetails, getContributors: GetAllContributors, - updateSearchValue: UpdateSearchValue, + updateSearchValue: UpdateContributorsSearchValue, updatePermissionFilter: UpdatePermissionFilter, updateBibliographyFilter: UpdateBibliographyFilter, deleteContributor: DeleteContributor, diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.ts b/src/app/features/project/overview/components/files-widget/files-widget.component.ts index 5007345db..83116ee52 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.ts +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.ts @@ -39,7 +39,7 @@ import { OsfFile, SelectOption, } from '@osf/shared/models'; -import { Project } from '@osf/shared/models/projects'; +import { ProjectModel } from '@osf/shared/models/projects'; import { environment } from 'src/environments/environment'; @@ -161,7 +161,7 @@ export class FilesWidgetComponent { } private flatComponents( - components: (Partial & { children?: Project[] })[] = [], + components: (Partial & { children?: ProjectModel[] })[] = [], parentPath = '..' ): SelectOption[] { return components.flatMap((component) => { diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html index 9569eaa74..4efab8ac6 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html @@ -5,7 +5,7 @@ } @else { - Provider Logo + Provider Logo }
    diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts index beefe4e03..a5377306a 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts @@ -10,8 +10,8 @@ import { FormControl } from '@angular/forms'; import { Router } from '@angular/router'; import { PreprintsHelpDialogComponent } from '@osf/features/preprints/components'; -import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; import { HeaderStyleHelper } from '@osf/shared/helpers'; +import { RegistryProviderDetails } from '@osf/shared/models'; import { SearchInputComponent } from '@shared/components'; import { DecodeHtmlPipe } from '@shared/pipes'; import { BrandService } from '@shared/services'; @@ -42,7 +42,7 @@ export class RegistryProviderHeroComponent implements OnDestroy { effect(() => { const provider = this.provider(); - if (provider) { + if (provider?.brand) { BrandService.applyBranding(provider.brand); HeaderStyleHelper.applyHeaderStyles( this.WHITE, diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts index ce8327310..25350b20b 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts @@ -7,7 +7,7 @@ import { Tree } from 'primeng/tree'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { Project } from '../../models'; +import { ProjectShortInfoModel } from '../../models'; @Component({ selector: 'osf-select-components-dialog', @@ -20,7 +20,7 @@ export class SelectComponentsDialogComponent { readonly dialogRef = inject(DynamicDialogRef); readonly config = inject(DynamicDialogConfig); selectedComponents: TreeNode[] = []; - parent: Project = this.config.data.parent; + parent: ProjectShortInfoModel = this.config.data.parent; components: TreeNode[] = []; constructor() { @@ -37,7 +37,7 @@ export class SelectComponentsDialogComponent { this.selectedComponents.push({ key: this.parent.id }); } - private mapProjectToTreeNode = (project: Project): TreeNode => { + private mapProjectToTreeNode = (project: ProjectShortInfoModel): TreeNode => { this.selectedComponents.push({ key: project.id, }); diff --git a/src/app/features/registries/mappers/index.ts b/src/app/features/registries/mappers/index.ts index cea020f3c..8c8a64cf3 100644 --- a/src/app/features/registries/mappers/index.ts +++ b/src/app/features/registries/mappers/index.ts @@ -1,2 +1 @@ export * from './licenses.mapper'; -export * from './providers.mapper'; diff --git a/src/app/features/registries/mappers/providers.mapper.ts b/src/app/features/registries/mappers/providers.mapper.ts deleted file mode 100644 index 593984a46..000000000 --- a/src/app/features/registries/mappers/providers.mapper.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ProvidersResponseJsonApi } from '@osf/shared/models'; - -import { ProviderSchema, RegistryProviderDetails, RegistryProviderDetailsJsonApi } from '../models'; - -export class ProvidersMapper { - static fromProvidersResponse(response: ProvidersResponseJsonApi): ProviderSchema[] { - return response.data.map((item) => ({ - id: item.id, - name: item.attributes.name, - })); - } - - static fromRegistryProvider(response: RegistryProviderDetailsJsonApi): RegistryProviderDetails { - const brandRaw = response.embeds!.brand.data; - return { - id: response.id, - name: response.attributes.name, - descriptionHtml: response.attributes.description, - permissions: response.attributes.permissions, - brand: { - id: brandRaw.id, - name: brandRaw.attributes.name, - heroLogoImageUrl: brandRaw.attributes.hero_logo_image, - heroBackgroundImageUrl: brandRaw.attributes.hero_background_image, - topNavLogoImageUrl: brandRaw.attributes.topnav_logo_image, - primaryColor: brandRaw.attributes.primary_color, - secondaryColor: brandRaw.attributes.secondary_color, - backgroundColor: brandRaw.attributes.background_color, - }, - iri: response.links.iri, - }; - } -} diff --git a/src/app/features/registries/models/index.ts b/src/app/features/registries/models/index.ts index 3b045981b..101e1aeac 100644 --- a/src/app/features/registries/models/index.ts +++ b/src/app/features/registries/models/index.ts @@ -1,4 +1 @@ -export * from './project'; -export * from './provider-schema.model'; -export * from './registry-provider.model'; -export * from './registry-provider-json-api.model'; +export * from './project-short-info.model'; diff --git a/src/app/features/registries/models/project-short-info.model.ts b/src/app/features/registries/models/project-short-info.model.ts new file mode 100644 index 000000000..86e569c39 --- /dev/null +++ b/src/app/features/registries/models/project-short-info.model.ts @@ -0,0 +1,5 @@ +export interface ProjectShortInfoModel { + id: string; + title: string; + children?: ProjectShortInfoModel[]; +} diff --git a/src/app/features/registries/models/project.ts b/src/app/features/registries/models/project.ts deleted file mode 100644 index dc1ae9d02..000000000 --- a/src/app/features/registries/models/project.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Project { - id: string; - title: string; - children?: Project[]; -} diff --git a/src/app/features/registries/models/registry-provider-json-api.model.ts b/src/app/features/registries/models/registry-provider-json-api.model.ts deleted file mode 100644 index e0e451f9d..000000000 --- a/src/app/features/registries/models/registry-provider-json-api.model.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BrandDataJsonApi, RegistrationProviderAttributesJsonApi } from '@shared/models'; - -export interface RegistryProviderDetailsJsonApi { - id: string; - type: 'registration-providers'; - attributes: RegistrationProviderAttributesJsonApi; - embeds?: { - brand: { - data: BrandDataJsonApi; - }; - }; - links: { - iri: string; - }; -} diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts index 54f0b2263..ad51dc759 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts @@ -4,10 +4,11 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; import { Router } from '@angular/router'; +import { ClearCurrentProvider } from '@core/store/provider'; import { LoadingSpinnerComponent, ResourceCardComponent, @@ -16,6 +17,7 @@ import { SubHeaderComponent, } from '@osf/shared/components'; import { ResourceType } from '@osf/shared/enums'; +import { GetRegistryProviderBrand, RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { RegistryServicesComponent } from '../../components'; import { GetRegistries, RegistriesSelectors } from '../../store'; @@ -38,18 +40,30 @@ import { environment } from 'src/environments/environment'; styleUrl: './registries-landing.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistriesLandingComponent implements OnInit { +export class RegistriesLandingComponent implements OnInit, OnDestroy { private router = inject(Router); - searchControl = new FormControl(''); - - private readonly actions = createDispatchMap({ getRegistries: GetRegistries }); + private actions = createDispatchMap({ + getRegistries: GetRegistries, + getProvider: GetRegistryProviderBrand, + clearCurrentProvider: ClearCurrentProvider, + }); + provider = select(RegistrationProviderSelectors.getBrandedProvider); + isProviderLoading = select(RegistrationProviderSelectors.isBrandedProviderLoading); registries = select(RegistriesSelectors.getRegistries); isRegistriesLoading = select(RegistriesSelectors.isRegistriesLoading); + searchControl = new FormControl(''); + defaultProvider = environment.defaultProvider; + ngOnInit(): void { this.actions.getRegistries(); + this.actions.getProvider(this.defaultProvider); + } + + ngOnDestroy(): void { + this.actions.clearCurrentProvider(); } redirectToSearchPageWithValue(): void { diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts index dc80f2be6..6719967a1 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts @@ -2,17 +2,17 @@ import { createDispatchMap, select } from '@ngxs/store'; import { DialogService } from 'primeng/dynamicdialog'; -import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; -import { SetCurrentProvider } from '@core/store/provider'; +import { ClearCurrentProvider } from '@core/store/provider'; import { GlobalSearchComponent } from '@osf/shared/components'; import { ResourceType } from '@osf/shared/enums'; import { SetDefaultFilterValue, SetResourceType } from '@osf/shared/stores/global-search'; +import { GetRegistryProviderBrand, RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { RegistryProviderHeroComponent } from '../../components/registry-provider-hero/registry-provider-hero.component'; -import { GetRegistryProviderBrand, RegistriesProviderSearchSelectors } from '../../store/registries-provider-search'; @Component({ selector: 'osf-registries-provider-search', @@ -22,18 +22,18 @@ import { GetRegistryProviderBrand, RegistriesProviderSearchSelectors } from '../ changeDetection: ChangeDetectionStrategy.OnPush, providers: [DialogService], }) -export class RegistriesProviderSearchComponent implements OnInit { +export class RegistriesProviderSearchComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private actions = createDispatchMap({ getProvider: GetRegistryProviderBrand, setDefaultFilterValue: SetDefaultFilterValue, setResourceType: SetResourceType, - setCurrentProvider: SetCurrentProvider, + clearCurrentProvider: ClearCurrentProvider, }); - provider = select(RegistriesProviderSearchSelectors.getBrandedProvider); - isProviderLoading = select(RegistriesProviderSearchSelectors.isBrandedProviderLoading); + provider = select(RegistrationProviderSelectors.getBrandedProvider); + isProviderLoading = select(RegistrationProviderSelectors.isBrandedProviderLoading); searchControl = new FormControl(''); @@ -44,9 +44,12 @@ export class RegistriesProviderSearchComponent implements OnInit { next: () => { this.actions.setDefaultFilterValue('publisher', this.provider()!.iri!); this.actions.setResourceType(ResourceType.Registration); - this.actions.setCurrentProvider(this.provider()!); }, }); } } + + ngOnDestroy(): void { + this.actions.clearCurrentProvider(); + } } diff --git a/src/app/features/registries/registries.routes.ts b/src/app/features/registries/registries.routes.ts index c2ce29a6f..2106ca2dd 100644 --- a/src/app/features/registries/registries.routes.ts +++ b/src/app/features/registries/registries.routes.ts @@ -5,8 +5,8 @@ import { Routes } from '@angular/router'; import { authGuard } from '@osf/core/guards'; import { RegistriesComponent } from '@osf/features/registries/registries.component'; import { RegistriesState } from '@osf/features/registries/store'; -import { RegistriesProviderSearchState } from '@osf/features/registries/store/registries-provider-search'; import { CitationsState, ContributorsState, SubjectsState } from '@osf/shared/stores'; +import { RegistrationProviderState } from '@osf/shared/stores/registration-provider'; import { LicensesHandlers, ProjectsHandlers, ProvidersHandlers } from './store/handlers'; import { FilesHandlers } from './store/handlers/files.handlers'; @@ -17,7 +17,7 @@ export const registriesRoutes: Routes = [ path: '', component: RegistriesComponent, providers: [ - provideStates([RegistriesState, CitationsState, ContributorsState, SubjectsState, RegistriesProviderSearchState]), + provideStates([RegistriesState, CitationsState, ContributorsState, SubjectsState, RegistrationProviderState]), ProvidersHandlers, ProjectsHandlers, LicensesHandlers, diff --git a/src/app/features/registries/services/index.ts b/src/app/features/registries/services/index.ts index 66cd5ab89..0dca62501 100644 --- a/src/app/features/registries/services/index.ts +++ b/src/app/features/registries/services/index.ts @@ -1,3 +1,3 @@ export * from './licenses.service'; -export * from './providers.service'; export * from './registries.service'; +export * from '@osf/shared/services/registration-provider.service'; diff --git a/src/app/features/registries/store/handlers/projects.handlers.ts b/src/app/features/registries/store/handlers/projects.handlers.ts index 659769e57..000f4898b 100644 --- a/src/app/features/registries/store/handlers/projects.handlers.ts +++ b/src/app/features/registries/store/handlers/projects.handlers.ts @@ -2,9 +2,10 @@ import { StateContext } from '@ngxs/store'; import { inject, Injectable } from '@angular/core'; +import { handleSectionError } from '@osf/shared/helpers'; import { ProjectsService } from '@osf/shared/services/projects.service'; -import { Project } from '../../models'; +import { ProjectShortInfoModel } from '../../models'; import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from '../registries.model'; @Injectable() @@ -28,7 +29,7 @@ export class ProjectsHandlers { }, }); return this.projectsService.fetchProjects(userId, params).subscribe({ - next: (projects: Project[]) => { + next: (projects: ProjectShortInfoModel[]) => { ctx.patchState({ projects: { data: projects, @@ -56,7 +57,7 @@ export class ProjectsHandlers { }); return this.projectsService.getComponentsTree(projectId).subscribe({ - next: (children: Project[]) => { + next: (children: ProjectShortInfoModel[]) => { ctx.patchState({ draftRegistration: { data: { @@ -68,11 +69,7 @@ export class ProjectsHandlers { }, }); }, - error: (error) => { - ctx.patchState({ - projects: { ...state.projects, isLoading: false, error }, - }); - }, + error: (error) => handleSectionError(ctx, 'draftRegistration', error), }); } } diff --git a/src/app/features/registries/store/handlers/providers.handlers.ts b/src/app/features/registries/store/handlers/providers.handlers.ts index 3e0a513e8..424d2286b 100644 --- a/src/app/features/registries/store/handlers/providers.handlers.ts +++ b/src/app/features/registries/store/handlers/providers.handlers.ts @@ -2,12 +2,12 @@ import { StateContext } from '@ngxs/store'; import { inject, Injectable } from '@angular/core'; -import { ProvidersService } from '../../services'; +import { RegistrationProviderService } from '../../services'; import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from '../registries.model'; @Injectable() export class ProvidersHandlers { - providersService = inject(ProvidersService); + providersService = inject(RegistrationProviderService); getProviderSchemas({ patchState }: StateContext, providerId: string) { patchState({ diff --git a/src/app/features/registries/store/registries-provider-search/index.ts b/src/app/features/registries/store/registries-provider-search/index.ts deleted file mode 100644 index f0cef0a5b..000000000 --- a/src/app/features/registries/store/registries-provider-search/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './registries-provider-search.actions'; -export * from './registries-provider-search.model'; -export * from './registries-provider-search.selectors'; -export * from './registries-provider-search.state'; diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts deleted file mode 100644 index 87598b29e..000000000 --- a/src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { AsyncStateModel } from '@shared/models'; - -import { RegistryProviderDetails } from '../../models'; - -export interface RegistriesProviderSearchStateModel { - currentBrandedProvider: AsyncStateModel; -} - -export const REGISTRIES_PROVIDER_SEARCH_STATE_DEFAULTS: RegistriesProviderSearchStateModel = { - currentBrandedProvider: { - data: null, - isLoading: false, - error: null, - }, -}; diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts deleted file mode 100644 index 45fa310b7..000000000 --- a/src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { RegistriesProviderSearchStateModel } from './registries-provider-search.model'; -import { RegistriesProviderSearchState } from './registries-provider-search.state'; - -export class RegistriesProviderSearchSelectors { - @Selector([RegistriesProviderSearchState]) - static getBrandedProvider(state: RegistriesProviderSearchStateModel) { - return state.currentBrandedProvider.data; - } - - @Selector([RegistriesProviderSearchState]) - static isBrandedProviderLoading(state: RegistriesProviderSearchStateModel) { - return state.currentBrandedProvider.isLoading; - } -} diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts deleted file mode 100644 index 14af12034..000000000 --- a/src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Action, State, StateContext } from '@ngxs/store'; -import { patch } from '@ngxs/store/operators'; - -import { catchError, tap } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { handleSectionError } from '@shared/helpers'; - -import { ProvidersService } from '../../services'; - -import { GetRegistryProviderBrand } from './registries-provider-search.actions'; -import { - REGISTRIES_PROVIDER_SEARCH_STATE_DEFAULTS, - RegistriesProviderSearchStateModel, -} from './registries-provider-search.model'; - -@State({ - name: 'registryProviderSearch', - defaults: REGISTRIES_PROVIDER_SEARCH_STATE_DEFAULTS, -}) -@Injectable() -export class RegistriesProviderSearchState { - private providersService = inject(ProvidersService); - - @Action(GetRegistryProviderBrand) - getProviderBrand(ctx: StateContext, action: GetRegistryProviderBrand) { - const state = ctx.getState(); - ctx.patchState({ - currentBrandedProvider: { - ...state.currentBrandedProvider, - isLoading: true, - }, - }); - - return this.providersService.getProviderBrand(action.providerName).pipe( - tap((brand) => { - ctx.setState( - patch({ - currentBrandedProvider: patch({ - data: brand, - isLoading: false, - error: null, - }), - }) - ); - }), - catchError((error) => handleSectionError(ctx, 'currentBrandedProvider', error)) - ); - } -} diff --git a/src/app/features/registries/store/registries.model.ts b/src/app/features/registries/store/registries.model.ts index 7c6f5e421..22a99c7e6 100644 --- a/src/app/features/registries/store/registries.model.ts +++ b/src/app/features/registries/store/registries.model.ts @@ -5,17 +5,18 @@ import { LicenseModel, OsfFile, PageSchema, + ProviderSchema, RegistrationCard, RegistrationModel, ResourceModel, SchemaResponse, } from '@shared/models'; -import { Project, ProviderSchema } from '../models'; +import { ProjectShortInfoModel } from '../models'; export interface RegistriesStateModel { providerSchemas: AsyncStateModel; - projects: AsyncStateModel; + projects: AsyncStateModel; draftRegistration: AsyncStateModel; registration: AsyncStateModel; registries: AsyncStateModel; diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 57772458d..60d7f2035 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -5,13 +5,14 @@ import { LicenseModel, OsfFile, PageSchema, + ProviderSchema, RegistrationCard, RegistrationModel, ResourceModel, SchemaResponse, } from '@shared/models'; -import { Project, ProviderSchema } from '../models'; +import { ProjectShortInfoModel } from '../models'; import { RegistriesStateModel } from './registries.model'; import { RegistriesState } from './registries.state'; @@ -28,7 +29,7 @@ export class RegistriesSelectors { } @Selector([RegistriesState]) - static getProjects(state: RegistriesStateModel): Project[] { + static getProjects(state: RegistriesStateModel): ProjectShortInfoModel[] { return state.projects.data; } diff --git a/src/app/features/registry/components/short-registration-info/short-registration-info.component.html b/src/app/features/registry/components/short-registration-info/short-registration-info.component.html index c67597ce2..066099deb 100644 --- a/src/app/features/registry/components/short-registration-info/short-registration-info.component.html +++ b/src/app/features/registry/components/short-registration-info/short-registration-info.component.html @@ -30,6 +30,6 @@

    {{ 'registry.archiving.createdDate' | translate }}

    {{ 'registry.overview.metadata.associatedProject' | translate }}

    - {{ associatedProjectUrl }} + {{ associatedProjectUrl }}

    diff --git a/src/app/features/registry/mappers/registry-overview.mapper.ts b/src/app/features/registry/mappers/registry-overview.mapper.ts index 636a62cc1..bf7626c93 100644 --- a/src/app/features/registry/mappers/registry-overview.mapper.ts +++ b/src/app/features/registry/mappers/registry-overview.mapper.ts @@ -1,7 +1,6 @@ import { RegistryOverview, RegistryOverviewJsonApiData } from '@osf/features/registry/models'; -import { ReviewPermissionsMapper } from '@osf/shared/mappers'; +import { MapRegistryStatus, ReviewPermissionsMapper } from '@osf/shared/mappers'; import { RegistrationMapper } from '@osf/shared/mappers/registration'; -import { MapRegistryStatus } from '@shared/mappers/registry/map-registry-status.mapper'; export function MapRegistryOverview(data: RegistryOverviewJsonApiData): RegistryOverview | null { return { diff --git a/src/app/features/registry/models/registry-overview.models.ts b/src/app/features/registry/models/registry-overview.models.ts index 59aeb0e17..2b15cc715 100644 --- a/src/app/features/registry/models/registry-overview.models.ts +++ b/src/app/features/registry/models/registry-overview.models.ts @@ -5,7 +5,7 @@ import { LicenseModel, LicensesOption, MetaAnonymousJsonApi, - ProviderModel, + ProviderShortInfoModel, SchemaResponse, SubjectModel, } from '@osf/shared/models'; @@ -24,7 +24,7 @@ export interface RegistryOverview { registrationType: string; doi: string; tags: string[]; - provider?: ProviderModel; + provider?: ProviderShortInfoModel; contributors: ProjectOverviewContributor[]; citation: string; category: string; diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index 9c67b8a16..04f5e10c8 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 @@ -5,10 +5,19 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { DialogService } from 'primeng/dynamicdialog'; import { Message } from 'primeng/message'; -import { filter, map, switchMap, tap } from 'rxjs'; +import { map, switchMap, tap } from 'rxjs'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, HostBinding, inject, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + HostBinding, + inject, + signal, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; @@ -177,21 +186,12 @@ export class RegistryOverviewComponent { } constructor() { - this.route.parent?.params.subscribe((params) => { - const id = params['id']; - if (id) { - this.actions - .getRegistryById(id) - .pipe( - filter(() => { - return !this.registry()?.withdrawn; - }), - tap(() => { - this.actions.getSubjects(id, ResourceType.Registration); - this.actions.getInstitutions(id); - }) - ) - .subscribe(); + effect(() => { + const registry = this.registry(); + + if (registry && !registry?.withdrawn) { + this.actions.getSubjects(registry?.id, ResourceType.Registration); + this.actions.getInstitutions(registry?.id); } }); @@ -293,6 +293,7 @@ export class RegistryOverviewComponent { this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { this.router.navigateByUrl(currentUrl); }); + this.actions.getRegistryById(this.registry()?.id || ''); } }); diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index cfb39b7da..56e861b53 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -1,15 +1,18 @@ -import { select } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; + +import { map, of } from 'rxjs'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, HostBinding, inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { RouterOutlet } from '@angular/router'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, HostBinding, inject, OnDestroy } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterOutlet } from '@angular/router'; +import { ClearCurrentProvider } from '@core/store/provider'; import { pathJoin } from '@osf/shared/helpers'; import { MetaTagsService } from '@osf/shared/services'; import { DataciteService } from '@shared/services/datacite/datacite.service'; -import { RegistryOverviewSelectors } from './store/registry-overview'; +import { GetRegistryById, RegistryOverviewSelectors } from './store/registry-overview'; import { environment } from 'src/environments/environment'; @@ -21,27 +24,46 @@ import { environment } from 'src/environments/environment'; changeDetection: ChangeDetectionStrategy.OnPush, providers: [DatePipe], }) -export class RegistryComponent { +export class RegistryComponent implements OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column'; private readonly metaTags = inject(MetaTagsService); private readonly datePipe = inject(DatePipe); private readonly dataciteService = inject(DataciteService); private readonly destroyRef = inject(DestroyRef); + private readonly route = inject(ActivatedRoute); + + private readonly actions = createDispatchMap({ + getRegistryById: GetRegistryById, + clearCurrentProvider: ClearCurrentProvider, + }); + + private registryId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); readonly registry = select(RegistryOverviewSelectors.getRegistry); readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading); readonly registry$ = toObservable(select(RegistryOverviewSelectors.getRegistry)); constructor() { + effect(() => { + if (this.registryId()) { + this.actions.getRegistryById(this.registryId()); + } + }); + effect(() => { if (!this.isRegistryLoading() && this.registry()) { this.setMetaTags(); } }); + this.dataciteService.logIdentifiableView(this.registry$).subscribe(); } + ngOnDestroy(): void { + this.actions.clearCurrentProvider(); + } + private setMetaTags(): void { this.metaTags.updateMetaTags( { diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index f06a96364..995d1d020 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -28,7 +28,7 @@ export class RegistryOverviewService { getRegistrationById(id: string): Observable { const params = { - related_counts: 'forks,comments,linked_nodes,linked_registrations,children,wikis', + related_counts: 'forks,linked_nodes,linked_registrations,children,wikis', 'embed[]': [ 'bibliographic_contributors', 'provider', 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 b940a59e0..0ead2773f 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 @@ -6,9 +6,8 @@ import { catchError } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { SetCurrentProvider } from '@osf/core/store/provider/provider.actions'; -import { SetUserAsModerator } from '@osf/core/store/user'; +import { CurrentResourceType } from '@osf/shared/enums'; import { handleSectionError } from '@osf/shared/helpers'; -import { SubjectsService } from '@osf/shared/services'; import { RegistryOverviewService } from '../../services'; @@ -32,7 +31,6 @@ import { REGISTRY_OVERVIEW_DEFAULTS, RegistryOverviewStateModel } from './regist }) export class RegistryOverviewState { private readonly registryOverviewService = inject(RegistryOverviewService); - private readonly subjectsService = inject(SubjectsService); @Action(GetRegistryById) getRegistryById(ctx: StateContext, action: GetRegistryById) { @@ -45,27 +43,31 @@ export class RegistryOverviewState { }); return this.registryOverviewService.getRegistrationById(action.id).pipe( - tap({ - next: (response) => { - const registryOverview = response.registry; - if (registryOverview?.currentUserIsModerator) { - ctx.dispatch(new SetUserAsModerator()); - } - if (registryOverview?.provider) { - ctx.dispatch(new SetCurrentProvider(registryOverview.provider)); - } - ctx.patchState({ - registry: { - data: registryOverview, - isLoading: false, - error: null, - }, - isAnonymous: response.meta?.anonymous ?? false, - }); - if (registryOverview?.registrationSchemaLink && registryOverview?.questions && !action.isComponentPage) { - ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); - } - }, + tap((response) => { + const registryOverview = response.registry; + + if (registryOverview?.provider) { + ctx.dispatch( + new SetCurrentProvider({ + id: registryOverview.provider.id, + name: registryOverview.provider.name, + type: CurrentResourceType.Registrations, + permissions: registryOverview.provider.permissions, + }) + ); + } + + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + isAnonymous: response.meta?.anonymous ?? false, + }); + if (registryOverview?.registrationSchemaLink && registryOverview?.questions && !action.isComponentPage) { + ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); + } }), catchError((error) => handleSectionError(ctx, 'registry', error)) ); @@ -82,16 +84,14 @@ export class RegistryOverviewState { }); return this.registryOverviewService.getInstitutions(action.registryId).pipe( - tap({ - next: (institutions) => { - ctx.patchState({ - institutions: { - data: institutions, - isLoading: false, - error: null, - }, - }); - }, + tap((institutions) => { + ctx.patchState({ + institutions: { + data: institutions, + isLoading: false, + error: null, + }, + }); }), catchError((error) => handleSectionError(ctx, 'institutions', error)) ); @@ -108,16 +108,14 @@ export class RegistryOverviewState { }); return this.registryOverviewService.getSchemaBlocks(action.schemaLink).pipe( - tap({ - next: (schemaBlocks) => { - ctx.patchState({ - schemaBlocks: { - data: schemaBlocks, - isLoading: false, - error: null, - }, - }); - }, + tap((schemaBlocks) => { + ctx.patchState({ + schemaBlocks: { + data: schemaBlocks, + isLoading: false, + error: null, + }, + }); }), catchError((error) => handleSectionError(ctx, 'schemaBlocks', error)) ); @@ -134,19 +132,18 @@ export class RegistryOverviewState { }); return this.registryOverviewService.withdrawRegistration(action.registryId, action.justification).pipe( - tap({ - next: (registryOverview) => { - ctx.patchState({ - registry: { - data: registryOverview, - isLoading: false, - error: null, - }, - }); - if (registryOverview?.registrationSchemaLink && registryOverview?.questions) { - ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); - } - }, + tap((registryOverview) => { + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + }); + + if (registryOverview?.registrationSchemaLink && registryOverview?.questions) { + ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); + } }), catchError((error) => handleSectionError(ctx, 'registry', error)) ); @@ -163,19 +160,17 @@ export class RegistryOverviewState { }); return this.registryOverviewService.makePublic(action.registryId).pipe( - tap({ - next: (registryOverview) => { - ctx.patchState({ - registry: { - data: registryOverview, - isLoading: false, - error: null, - }, - }); - if (registryOverview?.registrationSchemaLink && registryOverview?.questions) { - ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); - } - }, + tap((registryOverview) => { + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + }); + if (registryOverview?.registrationSchemaLink && registryOverview?.questions) { + ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); + } }), catchError((error) => handleSectionError(ctx, 'registry', error)) ); diff --git a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts index 0a66d2770..d20121524 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts @@ -17,7 +17,7 @@ import { ProjectFormControls } from '@osf/shared/enums'; import { CustomValidators } from '@osf/shared/helpers'; import { MOCK_STORE, MOCK_USER } from '@osf/shared/mocks'; import { ProjectForm } from '@osf/shared/models'; -import { Project } from '@osf/shared/models/projects'; +import { ProjectModel } from '@osf/shared/models/projects'; import { GetMyProjects, MyResourcesState } from '@osf/shared/stores'; import { AffiliatedInstitutionSelectComponent, ProjectSelectorComponent } from '@shared/components'; import { InstitutionsState } from '@shared/stores/institutions'; @@ -114,7 +114,7 @@ describe('AddProjectFormComponent', () => { }); it('should update template when onTemplateChange is called with a project', () => { - const mockProject: Project = { id: 'template1', title: 'Template Project' } as Project; + const mockProject: ProjectModel = { id: 'template1', title: 'Template Project' } as ProjectModel; const templateControl = component.projectForm().get(ProjectFormControls.Template); expect(templateControl?.value).toBe(''); diff --git a/src/app/shared/components/add-project-form/add-project-form.component.ts b/src/app/shared/components/add-project-form/add-project-form.component.ts index 13e361a37..0c8072372 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.ts @@ -16,7 +16,7 @@ import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { UserSelectors } from '@core/store/user'; import { ProjectFormControls } from '@osf/shared/enums'; import { Institution, ProjectForm } from '@osf/shared/models'; -import { Project } from '@osf/shared/models/projects'; +import { ProjectModel } from '@osf/shared/models/projects'; import { FetchRegions, RegionsSelectors } from '@osf/shared/stores'; import { FetchUserInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; @@ -51,7 +51,7 @@ export class AddProjectFormComponent implements OnInit { ProjectFormControls = ProjectFormControls; hasTemplateSelected = signal(false); - selectedTemplate = signal(null); + selectedTemplate = signal(null); isSubmitting = signal(false); selectedAffiliations = signal([]); currentUser = select(UserSelectors.getCurrentUser); @@ -92,7 +92,7 @@ export class AddProjectFormComponent implements OnInit { .subscribe((value) => this.hasTemplateSelected.set(!!value)); } - onTemplateChange(project: Project | null): void { + onTemplateChange(project: ProjectModel | null): void { if (!project) return; this.selectedTemplate.set(project); this.projectForm().get(ProjectFormControls.Template)?.setValue(project.id); diff --git a/src/app/shared/components/project-selector/project-selector.component.ts b/src/app/shared/components/project-selector/project-selector.component.ts index fdf5ab030..b9a4ddf78 100644 --- a/src/app/shared/components/project-selector/project-selector.component.ts +++ b/src/app/shared/components/project-selector/project-selector.component.ts @@ -23,7 +23,7 @@ import { FormsModule } from '@angular/forms'; import { UserSelectors } from '@core/store/user'; import { CustomOption } from '@shared/models'; -import { Project } from '@shared/models/projects'; +import { ProjectModel } from '@shared/models/projects'; import { GetProjects } from '@shared/stores'; import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; @@ -46,12 +46,12 @@ export class ProjectSelectorComponent { placeholder = input('common.buttons.select'); showClear = input(true); excludeProjectIds = input([]); - selectedProject = model(null); + selectedProject = model(null); - projectChange = output(); - projectsLoaded = output(); + projectChange = output(); + projectsLoaded = output(); - projectsOptions = signal[]>([]); + projectsOptions = signal[]>([]); filterMessage = computed(() => { const isLoading = this.isProjectsLoading(); diff --git a/src/app/shared/components/scheduled-banner/schedule-banner.component.spec.ts b/src/app/shared/components/scheduled-banner/schedule-banner.component.spec.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/shared/components/scheduled-banner/scheduled-banner.component.html b/src/app/shared/components/scheduled-banner/scheduled-banner.component.html index d1cac0cdd..04a6b6fc7 100644 --- a/src/app/shared/components/scheduled-banner/scheduled-banner.component.html +++ b/src/app/shared/components/scheduled-banner/scheduled-banner.component.html @@ -1,22 +1,14 @@ -@if (this.shouldShowBanner()) { +@if (shouldShowBanner()) { } diff --git a/src/app/shared/components/scheduled-banner/scheduled-banner.component.ts b/src/app/shared/components/scheduled-banner/scheduled-banner.component.ts index 996842773..171545415 100644 --- a/src/app/shared/components/scheduled-banner/scheduled-banner.component.ts +++ b/src/app/shared/components/scheduled-banner/scheduled-banner.component.ts @@ -24,9 +24,9 @@ export class ScheduledBannerComponent implements OnInit { const banner = this.currentBanner(); if (banner) { const bannerStartTime = banner.startDate; - const bannderEndTime = banner.endDate; + const bannerEndTime = banner.endDate; const currentTime = new Date(); - return bannerStartTime < currentTime && bannderEndTime > currentTime; + return bannerStartTime < currentTime && bannerEndTime > currentTime; } return false; }); diff --git a/src/app/shared/enums/resource-type.enum.ts b/src/app/shared/enums/resource-type.enum.ts index 82e39135a..5a8ff84d9 100644 --- a/src/app/shared/enums/resource-type.enum.ts +++ b/src/app/shared/enums/resource-type.enum.ts @@ -17,4 +17,5 @@ export enum CurrentResourceType { Projects = 'nodes', Registrations = 'registrations', Preprints = 'preprints', + Collections = 'collections', } diff --git a/src/app/shared/mappers/index.ts b/src/app/shared/mappers/index.ts index 1c6d170c7..5579fbd32 100644 --- a/src/app/shared/mappers/index.ts +++ b/src/app/shared/mappers/index.ts @@ -13,6 +13,7 @@ export * from './institutions'; export * from './licenses.mapper'; export * from './nodes'; export * from './notification-subscription.mapper'; +export * from './registration-provider.mapper'; export * from './registry'; export * from './resource-overview.mappers'; export * from './review-actions.mapper'; diff --git a/src/app/shared/mappers/projects/projects.mapper.ts b/src/app/shared/mappers/projects/projects.mapper.ts index a92d6e8a7..86ee52697 100644 --- a/src/app/shared/mappers/projects/projects.mapper.ts +++ b/src/app/shared/mappers/projects/projects.mapper.ts @@ -1,13 +1,13 @@ import { CollectionSubmissionMetadataPayloadJsonApi } from '@osf/features/collections/models'; import { ProjectMetadataUpdatePayload } from '@osf/shared/models'; -import { Project, ProjectJsonApi, ProjectsResponseJsonApi } from '@osf/shared/models/projects'; +import { ProjectJsonApi, ProjectModel, ProjectsResponseJsonApi } from '@osf/shared/models/projects'; export class ProjectsMapper { - static fromGetAllProjectsResponse(response: ProjectsResponseJsonApi): Project[] { + static fromGetAllProjectsResponse(response: ProjectsResponseJsonApi): ProjectModel[] { return response.data.map((project) => this.fromProjectResponse(project)); } - static fromProjectResponse(project: ProjectJsonApi): Project { + static fromProjectResponse(project: ProjectJsonApi): ProjectModel { return { id: project.id, type: project.type, diff --git a/src/app/shared/mappers/registration-provider.mapper.ts b/src/app/shared/mappers/registration-provider.mapper.ts new file mode 100644 index 000000000..78b9f8dc3 --- /dev/null +++ b/src/app/shared/mappers/registration-provider.mapper.ts @@ -0,0 +1,39 @@ +import { + ProviderSchema, + ProvidersResponseJsonApi, + RegistryProviderDetails, + RegistryProviderDetailsJsonApi, +} from '@osf/shared/models'; + +export class RegistrationProviderMapper { + static fromProvidersResponse(response: ProvidersResponseJsonApi): ProviderSchema[] { + return response.data.map((item) => ({ + id: item.id, + name: item.attributes.name, + })); + } + + static fromRegistryProvider(response: RegistryProviderDetailsJsonApi): RegistryProviderDetails { + const brandRaw = response.embeds!.brand.data; + + return { + id: response.id, + name: response.attributes.name, + descriptionHtml: response.attributes.description, + permissions: response.attributes.permissions, + brand: brandRaw + ? { + id: brandRaw.id, + name: brandRaw.attributes.name, + heroLogoImageUrl: brandRaw.attributes.hero_logo_image, + heroBackgroundImageUrl: brandRaw.attributes.hero_background_image, + topNavLogoImageUrl: brandRaw.attributes.topnav_logo_image, + primaryColor: brandRaw.attributes.primary_color, + secondaryColor: brandRaw.attributes.secondary_color, + backgroundColor: brandRaw.attributes.background_color, + } + : null, + iri: response.links.iri, + }; + } +} diff --git a/src/app/shared/models/collections/collections-json-api.models.ts b/src/app/shared/models/collections/collections-json-api.models.ts index 69e6384ad..65c1b067d 100644 --- a/src/app/shared/models/collections/collections-json-api.models.ts +++ b/src/app/shared/models/collections/collections-json-api.models.ts @@ -1,31 +1,9 @@ -import { BrandDataJsonApi, JsonApiResponse } from '@shared/models'; +import { BrandDataJsonApi, CollectionsProviderAttributesJsonApi, JsonApiResponse } from '@shared/models'; export interface CollectionProviderResponseJsonApi { id: string; type: string; - attributes: { - name: string; - description: string; - advisory_board: string; - example: string | null; - domain: string; - domain_redirect_enabled: boolean; - footer_links: string; - email_support: boolean | null; - facebook_app_id: string | null; - allow_submissions: boolean; - allow_commenting: boolean; - assets: { - style?: string; - square_color_transparent?: string; - square_color_no_transparent?: string; - favicon?: string; - }; - share_source: string; - share_publish_type: string; - permissions: string[]; - reviews_workflow: string; - }; + attributes: CollectionsProviderAttributesJsonApi; embeds: { brand: { data?: BrandDataJsonApi; @@ -38,12 +16,6 @@ export interface CollectionProviderResponseJsonApi { type: string; }; }; - brand: { - data: { - id: string; - type: string; - } | null; - }; }; } diff --git a/src/app/shared/models/collections/collections.models.ts b/src/app/shared/models/collections/collections.models.ts index ff94c7df3..ccd9429fe 100644 --- a/src/app/shared/models/collections/collections.models.ts +++ b/src/app/shared/models/collections/collections.models.ts @@ -1,30 +1,15 @@ import { CollectionSubmissionReviewAction } from '@osf/features/moderation/models'; -import { Brand } from '@shared/models'; -export interface CollectionProvider { - id: string; - type: string; - name: string; - description: string; - advisoryBoard: string; - example: string | null; - domain: string; - domainRedirectEnabled: boolean; - footerLinks: string; - emailSupport: boolean | null; - facebookAppId: string | null; - allowSubmissions: boolean; - allowCommenting: boolean; +import { Brand } from '../brand.model'; +import { BaseProviderModel } from '../provider'; + +export interface CollectionProvider extends BaseProviderModel { assets: { style?: string; squareColorTransparent?: string; squareColorNoTransparent?: string; favicon?: string; }; - shareSource: string; - sharePublishType: string; - permissions: string[]; - reviewsWorkflow: string; primaryCollection: { id: string; type: string; diff --git a/src/app/shared/models/projects/projects.models.ts b/src/app/shared/models/projects/projects.models.ts index 2e41b948e..68be33269 100644 --- a/src/app/shared/models/projects/projects.models.ts +++ b/src/app/shared/models/projects/projects.models.ts @@ -2,7 +2,7 @@ import { StringOrNull } from '@osf/shared/helpers'; import { LicenseOptions } from '../license.model'; -export interface Project { +export interface ProjectModel { id: string; type: string; title: string; diff --git a/src/app/shared/models/provider/base-provider-json-api.model.ts b/src/app/shared/models/provider/base-provider-json-api.model.ts index 74ce19065..09c066b9c 100644 --- a/src/app/shared/models/provider/base-provider-json-api.model.ts +++ b/src/app/shared/models/provider/base-provider-json-api.model.ts @@ -1,19 +1,19 @@ import { ReviewPermissions } from '@osf/shared/enums'; export interface BaseProviderAttributesJsonApi { - name: string; - description: string; advisory_board: string; - example: string | null; + allow_commenting: boolean; + allow_submissions: boolean; + description: string; domain: string; domain_redirect_enabled: boolean; - footer_links: string; email_support: string | null; + example: string | null; facebook_app_id: string | null; - allow_submissions: boolean; - allow_commenting: boolean; - share_source: string; - share_publish_type: string; + footer_links: string; + name: string; permissions: ReviewPermissions[]; reviews_workflow: string; + share_publish_type: string; + share_source: string; } diff --git a/src/app/shared/models/provider/collections-provider-json-api.model.ts b/src/app/shared/models/provider/collections-provider-json-api.model.ts new file mode 100644 index 000000000..d2b6a4de0 --- /dev/null +++ b/src/app/shared/models/provider/collections-provider-json-api.model.ts @@ -0,0 +1,12 @@ +import { BaseProviderAttributesJsonApi } from './base-provider-json-api.model'; + +export interface CollectionsProviderAttributesJsonApi extends BaseProviderAttributesJsonApi { + assets: CollectionsAssetsJsonApi; +} + +export interface CollectionsAssetsJsonApi { + favicon: string; + style: string; + square_color_no_transparent: string; + square_color_transparent: string; +} diff --git a/src/app/shared/models/provider/index.ts b/src/app/shared/models/provider/index.ts index 7750fce62..5968ef0a6 100644 --- a/src/app/shared/models/provider/index.ts +++ b/src/app/shared/models/provider/index.ts @@ -1,5 +1,7 @@ export * from './base-provider-json-api.model'; +export * from './collections-provider-json-api.model'; export * from './preprints-provider-json-api.model'; export * from './provider.model'; export * from './providers-json-api.model'; export * from './registration-provider-json-api.model'; +export * from './registry-provider.model'; diff --git a/src/app/shared/models/provider/preprints-provider-json-api.model.ts b/src/app/shared/models/provider/preprints-provider-json-api.model.ts index 4bd8d70a2..e49d23559 100644 --- a/src/app/shared/models/provider/preprints-provider-json-api.model.ts +++ b/src/app/shared/models/provider/preprints-provider-json-api.model.ts @@ -1,17 +1,17 @@ import { BaseProviderAttributesJsonApi } from './base-provider-json-api.model'; export interface PreprintProviderAttributesJsonApi extends BaseProviderAttributesJsonApi { - assets: PreprintProviderAssetsJsonApi; - preprint_word: string; additional_providers: string[]; - assertions_enabled: boolean; advertise_on_discover_page: boolean; - reviews_comments_private: boolean; + assertions_enabled: boolean; + assets: PreprintProviderAssetsJsonApi; + preprint_word: string; reviews_comments_anonymous: boolean; + reviews_comments_private: boolean; } export interface PreprintProviderAssetsJsonApi { favicon: string; - wide_white: string; square_color_no_transparent: string; + wide_white: string; } diff --git a/src/app/shared/models/provider/provider.model.ts b/src/app/shared/models/provider/provider.model.ts index 249342ac4..88dc359c1 100644 --- a/src/app/shared/models/provider/provider.model.ts +++ b/src/app/shared/models/provider/provider.model.ts @@ -1,7 +1,28 @@ -import { ReviewPermissions } from '@osf/shared/enums'; +import { CurrentResourceType, ReviewPermissions } from '@osf/shared/enums'; -export interface ProviderModel { +export interface ProviderShortInfoModel { id: string; name: string; + type: CurrentResourceType; permissions?: ReviewPermissions[]; } + +export interface BaseProviderModel { + id: string; + type: string; + advisoryBoard: string; + allowCommenting: boolean; + allowSubmissions: boolean; + description: string; + domain: string; + domainRedirectEnabled: boolean; + emailSupport: string | null; + example: string | null; + facebookAppId: string | null; + footerLinks: string; + name: string; + permissions: ReviewPermissions[]; + reviewsWorkflow: string; + sharePublishType: string; + shareSource: string; +} diff --git a/src/app/shared/models/provider/registration-provider-json-api.model.ts b/src/app/shared/models/provider/registration-provider-json-api.model.ts index 9d865b1dc..b78154747 100644 --- a/src/app/shared/models/provider/registration-provider-json-api.model.ts +++ b/src/app/shared/models/provider/registration-provider-json-api.model.ts @@ -1,12 +1,14 @@ +import { BrandDataJsonApi } from '../brand.json-api.model'; + import { BaseProviderAttributesJsonApi } from './base-provider-json-api.model'; export interface RegistrationProviderAttributesJsonApi extends BaseProviderAttributesJsonApi { + allow_bulk_uploads: boolean; + allow_updates: boolean; assets: RegistrationAssetsJsonApi; branded_discovery_page: boolean; - reviews_comments_anonymous: boolean | null; - allow_updates: boolean; - allow_bulk_uploads: boolean; registration_word: string; + reviews_comments_anonymous: boolean | null; } export interface RegistrationAssetsJsonApi { @@ -14,3 +16,17 @@ export interface RegistrationAssetsJsonApi { square_color_transparent: string; wide_color: string; } + +export interface RegistryProviderDetailsJsonApi { + id: string; + type: 'registration-providers'; + attributes: RegistrationProviderAttributesJsonApi; + embeds?: { + brand: { + data: BrandDataJsonApi; + }; + }; + links: { + iri: string; + }; +} diff --git a/src/app/features/registries/models/registry-provider.model.ts b/src/app/shared/models/provider/registry-provider.model.ts similarity index 91% rename from src/app/features/registries/models/registry-provider.model.ts rename to src/app/shared/models/provider/registry-provider.model.ts index 132be2d27..3c2f82ec6 100644 --- a/src/app/features/registries/models/registry-provider.model.ts +++ b/src/app/shared/models/provider/registry-provider.model.ts @@ -6,6 +6,6 @@ export interface RegistryProviderDetails { name: string; descriptionHtml: string; permissions: ReviewPermissions[]; - brand: Brand; + brand: Brand | null; iri: string; } diff --git a/src/app/shared/models/registration/draft-registration.model.ts b/src/app/shared/models/registration/draft-registration.model.ts index 7b40cf319..e9cbd53ef 100644 --- a/src/app/shared/models/registration/draft-registration.model.ts +++ b/src/app/shared/models/registration/draft-registration.model.ts @@ -1,5 +1,5 @@ import { LicenseOptions } from '../license.model'; -import { Project } from '../projects'; +import { ProjectModel } from '../projects'; export interface DraftRegistrationModel { id: string; @@ -13,8 +13,8 @@ export interface DraftRegistrationModel { tags: string[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any stepsData?: Record; - branchedFrom?: Partial; + branchedFrom?: Partial; providerId: string; hasProject: boolean; - components: Partial[]; + components: Partial[]; } diff --git a/src/app/shared/models/registration/index.ts b/src/app/shared/models/registration/index.ts index cdbda99e1..af928d02e 100644 --- a/src/app/shared/models/registration/index.ts +++ b/src/app/shared/models/registration/index.ts @@ -1,5 +1,6 @@ export * from './draft-registration.model'; export * from './page-schema.model'; +export * from './provider-schema.model'; export * from './registration.model'; export * from './registration-card.model'; export * from './registration-json-api.model'; diff --git a/src/app/features/registries/models/provider-schema.model.ts b/src/app/shared/models/registration/provider-schema.model.ts similarity index 100% rename from src/app/features/registries/models/provider-schema.model.ts rename to src/app/shared/models/registration/provider-schema.model.ts diff --git a/src/app/shared/services/addons/addons.service.spec.ts b/src/app/shared/services/addons/addons.service.spec.ts index 334562221..d8b27feb7 100644 --- a/src/app/shared/services/addons/addons.service.spec.ts +++ b/src/app/shared/services/addons/addons.service.spec.ts @@ -121,7 +121,7 @@ describe('Service: Addons', () => { [HttpTestingController], (httpMock: HttpTestingController) => { let results; - service.getAuthorizedStorageOauthToken('account-id').subscribe((result) => { + service.getAuthorizedStorageOauthToken('account-id', 'storage').subscribe((result) => { results = result; }); diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index 6a46dd119..b5182241d 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -18,6 +18,7 @@ export { MyResourcesService } from './my-resources.service'; export { NodeLinksService } from './node-links.service'; export { ProjectRedirectDialogService } from './project-redirect-dialog.service'; export { RegionsService } from './regions.service'; +export { RegistrationProviderService } from './registration-provider.service'; export { ResourceGuidService } from './resource.service'; export { ResourceCardService } from './resource-card.service'; export { SocialShareService } from './social-share.service'; diff --git a/src/app/shared/services/projects.service.ts b/src/app/shared/services/projects.service.ts index ae135a51a..ada1df3a6 100644 --- a/src/app/shared/services/projects.service.ts +++ b/src/app/shared/services/projects.service.ts @@ -4,7 +4,7 @@ import { inject, Injectable } from '@angular/core'; import { ProjectsMapper } from '@shared/mappers/projects'; import { ProjectMetadataUpdatePayload } from '@shared/models'; -import { Project, ProjectJsonApi, ProjectsResponseJsonApi } from '@shared/models/projects'; +import { ProjectJsonApi, ProjectModel, ProjectsResponseJsonApi } from '@shared/models/projects'; import { JsonApiService } from '@shared/services'; import { environment } from 'src/environments/environment'; @@ -16,13 +16,13 @@ export class ProjectsService { private jsonApiService = inject(JsonApiService); private apiUrl = `${environment.apiDomainUrl}/v2`; - fetchProjects(userId: string, params?: Record): Observable { + fetchProjects(userId: string, params?: Record): Observable { return this.jsonApiService .get(`${this.apiUrl}/users/${userId}/nodes/`, params) .pipe(map((response) => ProjectsMapper.fromGetAllProjectsResponse(response))); } - updateProjectMetadata(metadata: ProjectMetadataUpdatePayload): Observable { + updateProjectMetadata(metadata: ProjectMetadataUpdatePayload): Observable { const payload = ProjectsMapper.toUpdateProjectRequest(metadata); return this.jsonApiService @@ -30,13 +30,13 @@ export class ProjectsService { .pipe(map((response) => ProjectsMapper.fromProjectResponse(response))); } - getProjectChildren(id: string): Observable { + getProjectChildren(id: string): Observable { return this.jsonApiService .get(`${this.apiUrl}/nodes/${id}/children/`) .pipe(map((response) => ProjectsMapper.fromGetAllProjectsResponse(response))); } - getComponentsTree(id: string): Observable { + getComponentsTree(id: string): Observable { return this.getProjectChildren(id).pipe( switchMap((children) => { if (!children.length) { diff --git a/src/app/features/registries/services/providers.service.ts b/src/app/shared/services/registration-provider.service.ts similarity index 53% rename from src/app/features/registries/services/providers.service.ts rename to src/app/shared/services/registration-provider.service.ts index 978251eb0..53d6a7c55 100644 --- a/src/app/features/registries/services/providers.service.ts +++ b/src/app/shared/services/registration-provider.service.ts @@ -2,28 +2,30 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; -import { RegistryProviderDetailsJsonApi } from '@osf/features/registries/models/registry-provider-json-api.model'; -import { ProvidersResponseJsonApi } from '@osf/shared/models'; -import { JsonApiService } from '@osf/shared/services'; -import { JsonApiResponse } from '@shared/models'; +import { RegistrationProviderMapper } from '../mappers'; +import { + JsonApiResponse, + ProviderSchema, + ProvidersResponseJsonApi, + RegistryProviderDetails, + RegistryProviderDetailsJsonApi, +} from '../models'; -import { ProvidersMapper } from '../mappers/providers.mapper'; -import { ProviderSchema } from '../models'; +import { JsonApiService } from './'; import { environment } from 'src/environments/environment'; @Injectable({ providedIn: 'root', }) -export class ProvidersService { +export class RegistrationProviderService { private readonly jsonApiService = inject(JsonApiService); private readonly apiUrl = `${environment.apiDomainUrl}/v2`; getProviderSchemas(providerId: string): Observable { return this.jsonApiService .get(`${this.apiUrl}/providers/registrations/${providerId}/schemas/`) - .pipe(map((response) => ProvidersMapper.fromProvidersResponse(response))); + .pipe(map((response) => RegistrationProviderMapper.fromProvidersResponse(response))); } getProviderBrand(providerName: string): Observable { @@ -31,6 +33,6 @@ export class ProvidersService { .get< JsonApiResponse >(`${this.apiUrl}/providers/registrations/${providerName}/?embed=brand`) - .pipe(map((response) => ProvidersMapper.fromRegistryProvider(response.data))); + .pipe(map((response) => RegistrationProviderMapper.fromRegistryProvider(response.data))); } } diff --git a/src/app/shared/stores/addons/addons.state.spec.ts b/src/app/shared/stores/addons/addons.state.spec.ts index 1c71b922d..c9c5736b6 100644 --- a/src/app/shared/stores/addons/addons.state.spec.ts +++ b/src/app/shared/stores/addons/addons.state.spec.ts @@ -280,7 +280,7 @@ describe('State: Addons', () => { [HttpTestingController], (httpMock: HttpTestingController) => { let result: any[] = []; - store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe(() => { + store.dispatch(new GetAuthorizedStorageOauthToken('account-id', 'storage')).subscribe(() => { result = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddons); }); @@ -327,7 +327,7 @@ describe('State: Addons', () => { let result: any[] = []; store.dispatch(new GetAuthorizedStorageAddons('reference-id')).subscribe(); - store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe(() => { + store.dispatch(new GetAuthorizedStorageOauthToken('account-id', 'storage')).subscribe(() => { result = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddons); }); @@ -383,7 +383,7 @@ describe('State: Addons', () => { (httpMock: HttpTestingController) => { let result: any = null; - store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe({ + store.dispatch(new GetAuthorizedStorageOauthToken('account-id', 'storage')).subscribe({ next: () => { result = 'Expected error, but got success'; }, diff --git a/src/app/shared/stores/collections/collections.state.ts b/src/app/shared/stores/collections/collections.state.ts index 7c6289a54..2024ec572 100644 --- a/src/app/shared/stores/collections/collections.state.ts +++ b/src/app/shared/stores/collections/collections.state.ts @@ -4,6 +4,8 @@ import { catchError, forkJoin, of, switchMap, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { SetCurrentProvider } from '@core/store/provider'; +import { CurrentResourceType } from '@osf/shared/enums'; import { handleSectionError } from '@osf/shared/helpers'; import { CollectionsService } from '@osf/shared/services'; @@ -51,6 +53,21 @@ export class CollectionsState { }, }); + const provider = state.collectionProvider.data; + + if (provider?.name === action.collectionName) { + ctx.dispatch( + new SetCurrentProvider({ + id: provider.id, + name: provider.name, + type: CurrentResourceType.Collections, + permissions: provider.permissions, + }) + ); + + return of(provider); + } + return this.collectionsService.getCollectionProvider(action.collectionName).pipe( tap((res) => { ctx.patchState({ @@ -60,6 +77,15 @@ export class CollectionsState { error: null, }, }); + + ctx.dispatch( + new SetCurrentProvider({ + id: res.id, + name: res.name, + type: CurrentResourceType.Collections, + permissions: res.permissions, + }) + ); }) ); } diff --git a/src/app/shared/stores/contributors/contributors.actions.ts b/src/app/shared/stores/contributors/contributors.actions.ts index 40626b7d2..d93e9c89b 100644 --- a/src/app/shared/stores/contributors/contributors.actions.ts +++ b/src/app/shared/stores/contributors/contributors.actions.ts @@ -10,7 +10,7 @@ export class GetAllContributors { ) {} } -export class UpdateSearchValue { +export class UpdateContributorsSearchValue { static readonly type = '[Contributors] Update Search Value'; constructor(public searchValue: string | null) {} diff --git a/src/app/shared/stores/contributors/contributors.state.ts b/src/app/shared/stores/contributors/contributors.state.ts index 4009b8965..bfd7558d2 100644 --- a/src/app/shared/stores/contributors/contributors.state.ts +++ b/src/app/shared/stores/contributors/contributors.state.ts @@ -16,8 +16,8 @@ import { SearchUsers, UpdateBibliographyFilter, UpdateContributor, + UpdateContributorsSearchValue, UpdatePermissionFilter, - UpdateSearchValue, } from './contributors.actions'; import { CONTRIBUTORS_STATE_DEFAULTS, ContributorsStateModel } from './contributors.model'; @@ -141,8 +141,8 @@ export class ContributorsState { ); } - @Action(UpdateSearchValue) - updateSearchValue(ctx: StateContext, action: UpdateSearchValue) { + @Action(UpdateContributorsSearchValue) + updateContributorsSearchValue(ctx: StateContext, action: UpdateContributorsSearchValue) { ctx.patchState({ contributorsList: { ...ctx.getState().contributorsList, searchValue: action.searchValue } }); } diff --git a/src/app/shared/stores/projects/projects.actions.ts b/src/app/shared/stores/projects/projects.actions.ts index e5fa9b65a..3a7731e12 100644 --- a/src/app/shared/stores/projects/projects.actions.ts +++ b/src/app/shared/stores/projects/projects.actions.ts @@ -1,5 +1,5 @@ import { ProjectMetadataUpdatePayload } from '@shared/models'; -import { Project } from '@shared/models/projects'; +import { ProjectModel } from '@shared/models/projects'; export class GetProjects { static readonly type = '[Projects] Get Projects'; @@ -13,7 +13,7 @@ export class GetProjects { export class SetSelectedProject { static readonly type = '[Projects] Set Selected Project'; - constructor(public project: Project) {} + constructor(public project: ProjectModel) {} } export class UpdateProjectMetadata { diff --git a/src/app/shared/stores/projects/projects.model.ts b/src/app/shared/stores/projects/projects.model.ts index cca209928..cb8bb5250 100644 --- a/src/app/shared/stores/projects/projects.model.ts +++ b/src/app/shared/stores/projects/projects.model.ts @@ -1,8 +1,8 @@ -import { AsyncStateModel, Project } from '@osf/shared/models'; +import { AsyncStateModel, ProjectModel } from '@osf/shared/models'; export interface ProjectsStateModel { - projects: AsyncStateModel; - selectedProject: AsyncStateModel; + projects: AsyncStateModel; + selectedProject: AsyncStateModel; } export const PROJECTS_STATE_DEFAULTS: ProjectsStateModel = { diff --git a/src/app/shared/stores/registration-provider/index.ts b/src/app/shared/stores/registration-provider/index.ts new file mode 100644 index 000000000..47e5b0316 --- /dev/null +++ b/src/app/shared/stores/registration-provider/index.ts @@ -0,0 +1,4 @@ +export * from './registration-provider.actions'; +export * from './registration-provider.model'; +export * from './registration-provider.selectors'; +export * from './registration-provider.state'; diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.actions.ts b/src/app/shared/stores/registration-provider/registration-provider.actions.ts similarity index 100% rename from src/app/features/registries/store/registries-provider-search/registries-provider-search.actions.ts rename to src/app/shared/stores/registration-provider/registration-provider.actions.ts diff --git a/src/app/shared/stores/registration-provider/registration-provider.model.ts b/src/app/shared/stores/registration-provider/registration-provider.model.ts new file mode 100644 index 000000000..00928acbc --- /dev/null +++ b/src/app/shared/stores/registration-provider/registration-provider.model.ts @@ -0,0 +1,13 @@ +import { AsyncStateModel, RegistryProviderDetails } from '@shared/models'; + +export interface RegistrationProviderStateModel { + currentBrandedProvider: AsyncStateModel; +} + +export const REGISTRIES_PROVIDER_SEARCH_STATE_DEFAULTS: RegistrationProviderStateModel = { + currentBrandedProvider: { + data: null, + isLoading: false, + error: null, + }, +}; diff --git a/src/app/shared/stores/registration-provider/registration-provider.selectors.ts b/src/app/shared/stores/registration-provider/registration-provider.selectors.ts new file mode 100644 index 000000000..61010f5cb --- /dev/null +++ b/src/app/shared/stores/registration-provider/registration-provider.selectors.ts @@ -0,0 +1,16 @@ +import { Selector } from '@ngxs/store'; + +import { RegistrationProviderStateModel } from './registration-provider.model'; +import { RegistrationProviderState } from './registration-provider.state'; + +export class RegistrationProviderSelectors { + @Selector([RegistrationProviderState]) + static getBrandedProvider(state: RegistrationProviderStateModel) { + return state.currentBrandedProvider.data; + } + + @Selector([RegistrationProviderState]) + static isBrandedProviderLoading(state: RegistrationProviderStateModel) { + return state.currentBrandedProvider.isLoading; + } +} diff --git a/src/app/shared/stores/registration-provider/registration-provider.state.ts b/src/app/shared/stores/registration-provider/registration-provider.state.ts new file mode 100644 index 000000000..9aa723ae9 --- /dev/null +++ b/src/app/shared/stores/registration-provider/registration-provider.state.ts @@ -0,0 +1,78 @@ +import { Action, State, StateContext } from '@ngxs/store'; +import { patch } from '@ngxs/store/operators'; + +import { catchError, of, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { SetCurrentProvider } from '@core/store/provider'; +import { CurrentResourceType } from '@osf/shared/enums'; +import { handleSectionError } from '@shared/helpers'; + +import { RegistrationProviderService } from '../../services'; + +import { GetRegistryProviderBrand } from './registration-provider.actions'; +import { + RegistrationProviderStateModel as RegistrationProviderStateModel, + REGISTRIES_PROVIDER_SEARCH_STATE_DEFAULTS, +} from './registration-provider.model'; + +@State({ + name: 'registryProviderSearch', + defaults: REGISTRIES_PROVIDER_SEARCH_STATE_DEFAULTS, +}) +@Injectable() +export class RegistrationProviderState { + private registrationProvidersService = inject(RegistrationProviderService); + + @Action(GetRegistryProviderBrand) + getProviderBrand(ctx: StateContext, action: GetRegistryProviderBrand) { + const state = ctx.getState(); + + const currentProvider = state.currentBrandedProvider.data; + + if (currentProvider?.name === action.providerName) { + ctx.dispatch( + new SetCurrentProvider({ + id: currentProvider.id, + name: currentProvider.name, + type: CurrentResourceType.Registrations, + permissions: currentProvider.permissions, + }) + ); + + return of(currentProvider); + } + + ctx.patchState({ + currentBrandedProvider: { + ...state.currentBrandedProvider, + isLoading: true, + }, + }); + + return this.registrationProvidersService.getProviderBrand(action.providerName).pipe( + tap((provider) => { + ctx.setState( + patch({ + currentBrandedProvider: patch({ + data: provider, + isLoading: false, + error: null, + }), + }) + ); + + ctx.dispatch( + new SetCurrentProvider({ + id: provider.id, + name: provider.name, + type: CurrentResourceType.Registrations, + permissions: provider.permissions, + }) + ); + }), + catchError((error) => handleSectionError(ctx, 'currentBrandedProvider', error)) + ); + } +} From b56ce01794c3821b03ce9604e5d477c06dff2bdc Mon Sep 17 00:00:00 2001 From: Lord Business <113387478+bp-cos@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:17:14 -0500 Subject: [PATCH 18/21] fix(datalayer): fixed to handle datalayer existence (#401) --- .../core/services/help-scout.service.spec.ts | 128 ++++++++++++------ src/app/core/services/help-scout.service.ts | 13 +- 2 files changed, 98 insertions(+), 43 deletions(-) diff --git a/src/app/core/services/help-scout.service.spec.ts b/src/app/core/services/help-scout.service.spec.ts index 3bb9469dc..8f7603aea 100644 --- a/src/app/core/services/help-scout.service.spec.ts +++ b/src/app/core/services/help-scout.service.spec.ts @@ -9,57 +9,107 @@ import { UserSelectors } from '@core/store/user/user.selectors'; import { HelpScoutService } from './help-scout.service'; describe('HelpScoutService', () => { - let storeMock: Partial; + const storeMock: Partial = { + selectSignal: jest.fn().mockImplementation((selector) => { + if (selector === UserSelectors.isAuthenticated) { + return authSignal; + } + return signal(null); // fallback + }), + }; let service: HelpScoutService; let mockWindow: any; const authSignal = signal(false); - beforeEach(() => { - mockWindow = { - dataLayer: {}, - }; - - storeMock = { - selectSignal: jest.fn().mockImplementation((selector) => { - if (selector === UserSelectors.isAuthenticated) { - return authSignal; - } - return signal(null); // fallback - }), - }; - - TestBed.configureTestingModule({ - providers: [{ provide: WINDOW, useValue: mockWindow }, HelpScoutService, { provide: Store, useValue: storeMock }], + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initialization - no dataLayer', () => { + beforeEach(() => { + mockWindow = {}; + TestBed.configureTestingModule({ + providers: [ + { provide: WINDOW, useValue: mockWindow }, + HelpScoutService, + { provide: Store, useValue: storeMock }, + ], + }); + + service = TestBed.inject(HelpScoutService); }); - service = TestBed.inject(HelpScoutService); - }); + it('should initialize dataLayer with default values', () => { + expect(mockWindow.dataLayer).toEqual({ + loggedIn: false, + resourceType: undefined, + }); + }); - it('should initialize dataLayer with default values', () => { - expect(mockWindow.dataLayer).toEqual({ - loggedIn: false, - resourceType: undefined, + it('should set the resourceType', () => { + service.setResourceType('project'); + expect(mockWindow.dataLayer.resourceType).toBe('project'); }); - }); - it('should set the resourceType', () => { - service.setResourceType('project'); - expect(mockWindow.dataLayer.resourceType).toBe('project'); - }); + it('should unset the resourceType', () => { + service.setResourceType('node'); + service.unsetResourceType(); + expect(mockWindow.dataLayer.resourceType).toBeUndefined(); + }); - it('should unset the resourceType', () => { - service.setResourceType('node'); - service.unsetResourceType(); - expect(mockWindow.dataLayer.resourceType).toBeUndefined(); + it('should set loggedIn to true or false', () => { + authSignal.set(true); + TestBed.flushEffects(); + expect(mockWindow.dataLayer.loggedIn).toBeTruthy(); + + authSignal.set(false); + TestBed.flushEffects(); + expect(mockWindow.dataLayer.loggedIn).toBeFalsy(); + }); }); - it('should set loggedIn to true or false', () => { - authSignal.set(true); - TestBed.flushEffects(); - expect(mockWindow.dataLayer.loggedIn).toBeTruthy(); + describe('initialization - dataLayer', () => { + beforeEach(() => { + mockWindow = { + dataLayer: {}, + }; + TestBed.configureTestingModule({ + providers: [ + { provide: WINDOW, useValue: mockWindow }, + HelpScoutService, + { provide: Store, useValue: storeMock }, + ], + }); - authSignal.set(false); - TestBed.flushEffects(); - expect(mockWindow.dataLayer.loggedIn).toBeFalsy(); + service = TestBed.inject(HelpScoutService); + }); + + it('should initialize dataLayer with default values', () => { + expect(mockWindow.dataLayer).toEqual({ + loggedIn: false, + resourceType: undefined, + }); + }); + + it('should set the resourceType', () => { + service.setResourceType('project'); + expect(mockWindow.dataLayer.resourceType).toBe('project'); + }); + + it('should unset the resourceType', () => { + service.setResourceType('node'); + service.unsetResourceType(); + expect(mockWindow.dataLayer.resourceType).toBeUndefined(); + }); + + it('should set loggedIn to true or false', () => { + authSignal.set(true); + TestBed.flushEffects(); + expect(mockWindow.dataLayer.loggedIn).toBeTruthy(); + + authSignal.set(false); + TestBed.flushEffects(); + expect(mockWindow.dataLayer.loggedIn).toBeFalsy(); + }); }); }); diff --git a/src/app/core/services/help-scout.service.ts b/src/app/core/services/help-scout.service.ts index 1be92f992..7f33b002b 100644 --- a/src/app/core/services/help-scout.service.ts +++ b/src/app/core/services/help-scout.service.ts @@ -51,10 +51,15 @@ export class HelpScoutService { * - `resourceType`: undefined */ constructor() { - this.window.dataLayer = { - loggedIn: false, - resourceType: undefined, - }; + if (this.window.dataLayer) { + this.window.dataLayer.loggedIn = false; + this.window.dataLayer.resourceType = undefined; + } else { + this.window.dataLayer = { + loggedIn: false, + resourceType: undefined, + }; + } effect(() => { this.window.dataLayer.loggedIn = this.isAuthenticated(); From 68e6827c31878bdbc23f63cb0da923bf04b3d8b8 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Tue, 16 Sep 2025 12:24:15 -0400 Subject: [PATCH 19/21] fix(metadata): correct resource type values (#402) --- .../constants/resource-type-options.const.ts | 61 ++++++++++--------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/src/app/features/metadata/constants/resource-type-options.const.ts b/src/app/features/metadata/constants/resource-type-options.const.ts index a70ed5381..e9919c8a2 100644 --- a/src/app/features/metadata/constants/resource-type-options.const.ts +++ b/src/app/features/metadata/constants/resource-type-options.const.ts @@ -1,30 +1,35 @@ +// `value` must be from resourceTypeGeneral controlled vocab in https://schema.datacite.org/meta/kernel-4/ +// see https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/resourceTypeGeneral/ export const RESOURCE_TYPE_OPTIONS = [ - { label: 'Audiovisual', value: 'audiovisual' }, - { label: 'Book', value: 'book' }, - { label: 'Book Chapter', value: 'book-chapter' }, - { label: 'Collection', value: 'collection' }, - { label: 'Computational Notebook', value: 'computational-notebook' }, - { label: 'Conference Paper', value: 'conference-paper' }, - { label: 'Conference Proceeding', value: 'conference-proceeding' }, - { label: 'Data Paper', value: 'data-paper' }, - { label: 'Dataset', value: 'dataset' }, - { label: 'Dissertation', value: 'dissertation' }, - { label: 'Event', value: 'event' }, - { label: 'Image', value: 'image' }, - { label: 'Interactive Resource', value: 'interactive-resource' }, - { label: 'Journal Article', value: 'journal-article' }, - { label: 'Model', value: 'model' }, - { label: 'Output Management Plan', value: 'output-management-plan' }, - { label: 'Peer Review', value: 'peer-review' }, - { label: 'Physical Object', value: 'physical-object' }, - { label: 'Preprint', value: 'preprint' }, - { label: 'Report', value: 'report' }, - { label: 'Service', value: 'service' }, - { label: 'Software', value: 'software' }, - { label: 'Sound', value: 'sound' }, - { label: 'Standard', value: 'standard' }, - { label: 'Text', value: 'text' }, - { label: 'Thesis', value: 'thesis' }, - { label: 'Workflow', value: 'workflow' }, - { label: 'Other', value: 'other' }, + { label: 'Audiovisual', value: 'Audiovisual' }, + { label: 'Book', value: 'Book' }, + { label: 'Book Chapter', value: 'BookChapter' }, + { label: 'Collection', value: 'Collection' }, + { label: 'Computational Notebook', value: 'ComputationalNotebook' }, + { label: 'Conference Paper', value: 'ConferencePaper' }, + { label: 'Conference Proceeding', value: 'ConferenceProceeding' }, + { label: 'Data Paper', value: 'DataPaper' }, + { label: 'Dataset', value: 'Dataset' }, + { label: 'Dissertation', value: 'Dissertation' }, + { label: 'Event', value: 'Event' }, + { label: 'Image', value: 'Image' }, + { label: 'Instrument', value: 'Instrument' }, + { label: 'Interactive Resource', value: 'InteractiveResource' }, + { label: 'Journal', value: 'Journal' }, + { label: 'Journal Article', value: 'JournalArticle' }, + { label: 'Model', value: 'Model' }, + { label: 'Output Management Plan', value: 'OutputManagementPlan' }, + { label: 'Peer Review', value: 'PeerReview' }, + { label: 'Physical Object', value: 'PhysicalObject' }, + { label: 'Preprint', value: 'Preprint' }, + { label: 'Project', value: 'Project' }, + { label: 'Report', value: 'Report' }, + { label: 'Service', value: 'Service' }, + { label: 'Software', value: 'Software' }, + { label: 'Sound', value: 'Sound' }, + { label: 'Standard', value: 'Standard' }, + { label: 'StudyRegistration', value: 'StudyRegistration' }, + { label: 'Text', value: 'Text' }, + { label: 'Workflow', value: 'Workflow' }, + { label: 'Other', value: 'Other' }, ]; From d337216c5821162e91eecc0c4a892eae1f2b09cb Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Wed, 17 Sep 2025 10:10:59 +0300 Subject: [PATCH 20/21] Fix(view-only-links): View only links files access issues (#399) * fix(view-only-links): fixed view only links files access issues * fix(view-only-links): fixed file guid absence while redirecting * fix(view-only-links): fixed files spec file issue --- .../interceptors/view-only.interceptor.ts | 4 +++ .../file-keywords.component.html | 27 ++++++++++++------- .../file-keywords/file-keywords.component.ts | 7 +++-- .../file-metadata.component.html | 18 +++++++------ .../file-metadata/file-metadata.component.ts | 7 +++-- .../file-resource-metadata.component.html | 2 +- .../file-resource-metadata.component.ts | 9 +++++-- .../files/mappers/resource-metadata.mapper.ts | 25 +++++++++-------- .../file-detail/file-detail.component.html | 10 +++---- .../file-detail/file-detail.component.ts | 16 +++++++++-- .../files-container.component.scss | 5 ++++ .../files-container.component.ts | 1 + .../files/pages/files/files.component.spec.ts | 9 +++++++ .../files/pages/files/files.component.ts | 5 +++- .../files-widget/files-widget.component.ts | 5 +++- .../files-tree/files-tree.component.html | 6 ++--- .../files-tree/files-tree.component.scss | 3 +++ .../metadata-tabs.component.scss | 5 ++++ .../contributors/contributors.mapper.ts | 12 ++++----- .../activity-logs/activity-logs.service.ts | 4 +-- src/app/shared/services/files.service.ts | 23 +++++++++++++--- .../data/activity-logs/activity-logs.data.ts | 2 +- 22 files changed, 144 insertions(+), 61 deletions(-) create mode 100644 src/app/features/files/pages/files-container/files-container.component.scss diff --git a/src/app/core/interceptors/view-only.interceptor.ts b/src/app/core/interceptors/view-only.interceptor.ts index 908913796..a0a4d91e1 100644 --- a/src/app/core/interceptors/view-only.interceptor.ts +++ b/src/app/core/interceptors/view-only.interceptor.ts @@ -15,6 +15,10 @@ export const viewOnlyInterceptor: HttpInterceptorFn = ( const viewOnlyParam = getViewOnlyParam(router); if (!req.url.includes('/api.crossref.org/funders') && viewOnlyParam) { + if (req.url.includes('view_only=')) { + return next(req); + } + const separator = req.url.includes('?') ? '&' : '?'; const updatedUrl = `${req.url}${separator}view_only=${encodeURIComponent(viewOnlyParam)}`; diff --git a/src/app/features/files/components/file-keywords/file-keywords.component.html b/src/app/features/files/components/file-keywords/file-keywords.component.html index 495d7d9e5..123708174 100644 --- a/src/app/features/files/components/file-keywords/file-keywords.component.html +++ b/src/app/features/files/components/file-keywords/file-keywords.component.html @@ -1,21 +1,28 @@

    {{ 'files.detail.keywords.title' | translate }}

    -
    - + @if (!hasViewOnly()) { +
    + - - -
    + + +
    + } @if (!isTagsLoading()) {
    @for (tag of tags(); track $index) { - + }
    } @else { diff --git a/src/app/features/files/components/file-keywords/file-keywords.component.ts b/src/app/features/files/components/file-keywords/file-keywords.component.ts index 7991a5740..80e08b78d 100644 --- a/src/app/features/files/components/file-keywords/file-keywords.component.ts +++ b/src/app/features/files/components/file-keywords/file-keywords.component.ts @@ -7,11 +7,12 @@ import { Chip } from 'primeng/chip'; import { InputText } from 'primeng/inputtext'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; -import { CustomValidators } from '@osf/shared/helpers'; +import { CustomValidators, hasViewOnlyParam } from '@osf/shared/helpers'; import { InputLimits } from '@shared/constants'; import { FilesSelectors, UpdateTags } from '../../store'; @@ -26,10 +27,12 @@ import { FilesSelectors, UpdateTags } from '../../store'; export class FileKeywordsComponent { private readonly actions = createDispatchMap({ updateTags: UpdateTags }); private readonly destroyRef = inject(DestroyRef); + private readonly router = inject(Router); readonly tags = select(FilesSelectors.getFileTags); readonly isTagsLoading = select(FilesSelectors.isFileTagsLoading); readonly file = select(FilesSelectors.getOpenedFile); + readonly hasViewOnly = computed(() => hasViewOnlyParam(this.router)); keywordControl = new FormControl('', { nonNullable: true, diff --git a/src/app/features/files/components/file-metadata/file-metadata.component.html b/src/app/features/files/components/file-metadata/file-metadata.component.html index 868f4fbd1..1f24fde19 100644 --- a/src/app/features/files/components/file-metadata/file-metadata.component.html +++ b/src/app/features/files/components/file-metadata/file-metadata.component.html @@ -2,15 +2,17 @@

    {{ 'files.detail.fileMetadata.title' | translate }}

    -
    - + @if (!hasViewOnly()) { +
    + - -
    + +
    + }
    @if (isLoading()) { diff --git a/src/app/features/files/components/file-metadata/file-metadata.component.ts b/src/app/features/files/components/file-metadata/file-metadata.component.ts index cd7ec7d92..5c24dfa75 100644 --- a/src/app/features/files/components/file-metadata/file-metadata.component.ts +++ b/src/app/features/files/components/file-metadata/file-metadata.component.ts @@ -8,11 +8,12 @@ import { Skeleton } from 'primeng/skeleton'; import { filter, map, of } from 'rxjs'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { languageCodes } from '@osf/shared/constants'; +import { hasViewOnlyParam } from '@osf/shared/helpers'; import { LanguageCodeModel } from '@osf/shared/models'; import { FileMetadataFields } from '../../constants'; @@ -33,11 +34,13 @@ import { environment } from 'src/environments/environment'; export class FileMetadataComponent { private readonly actions = createDispatchMap({ setFileMetadata: SetFileMetadata }); private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private readonly dialogService = inject(DialogService); private readonly translateService = inject(TranslateService); fileMetadata = select(FilesSelectors.getFileCustomMetadata); isLoading = select(FilesSelectors.isFileMetadataLoading); + hasViewOnly = computed(() => hasViewOnlyParam(this.router)); readonly languageCodes = languageCodes; diff --git a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html index 73a66aca3..06b9f50c5 100644 --- a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html +++ b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html @@ -98,7 +98,7 @@

    {{ 'files.detail.resourceMetadata.fields.dateModified' | translate }}

    @if (isResourceContributorsLoading()) { } @else { - @if (contributors()?.length) { + @if (contributors()?.length && !hasViewOnly()) {

    {{ 'files.detail.resourceMetadata.fields.contributors' | translate }}

    diff --git a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts index 3e962d0c2..adcaaab74 100644 --- a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts +++ b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts @@ -5,8 +5,10 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; -import { RouterLink } from '@angular/router'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; + +import { hasViewOnlyParam } from '@osf/shared/helpers'; import { FilesSelectors } from '../../store'; @@ -18,9 +20,12 @@ import { FilesSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileResourceMetadataComponent { + private readonly router = inject(Router); + resourceType = input('nodes'); resourceMetadata = select(FilesSelectors.getResourceMetadata); contributors = select(FilesSelectors.getContributors); isResourceMetadataLoading = select(FilesSelectors.isResourceMetadataLoading); isResourceContributorsLoading = select(FilesSelectors.isResourceContributorsLoading); + hasViewOnly = computed(() => hasViewOnlyParam(this.router)); } diff --git a/src/app/features/files/mappers/resource-metadata.mapper.ts b/src/app/features/files/mappers/resource-metadata.mapper.ts index b5a993af2..0f3890722 100644 --- a/src/app/features/files/mappers/resource-metadata.mapper.ts +++ b/src/app/features/files/mappers/resource-metadata.mapper.ts @@ -13,16 +13,19 @@ export function MapResourceMetadata( description: shortInfo.data.attributes.description, dateCreated: new Date(shortInfo.data.attributes.date_created), dateModified: new Date(shortInfo.data.attributes.date_modified), - funders: customMetadata.data.embeds.custom_metadata.data.attributes.funders.map((funder) => ({ - funderName: funder.funder_name, - funderIdentifier: funder.funder_identifier, - funderIdentifierType: funder.funder_identifier_type, - awardNumber: funder.award_number, - awardUri: funder.award_uri, - awardTitle: funder.award_title, - })), - identifiers: IdentifiersMapper.fromJsonApi(shortInfo.data.embeds.identifiers), - language: customMetadata.data.embeds.custom_metadata.data.attributes.language, - resourceTypeGeneral: customMetadata.data.embeds.custom_metadata.data.attributes.resource_type_general, + funders: + customMetadata.data.embeds?.custom_metadata?.data?.attributes?.funders?.map((funder) => ({ + funderName: funder.funder_name, + funderIdentifier: funder.funder_identifier, + funderIdentifierType: funder.funder_identifier_type, + awardNumber: funder.award_number, + awardUri: funder.award_uri, + awardTitle: funder.award_title, + })) || [], + identifiers: shortInfo.data.embeds?.identifiers?.data.length + ? IdentifiersMapper.fromJsonApi(shortInfo.data.embeds?.identifiers) + : [], + language: customMetadata.data.embeds?.custom_metadata?.data?.attributes?.language || '', + resourceTypeGeneral: customMetadata.data.embeds?.custom_metadata?.data?.attributes?.resource_type_general || '', }; } diff --git a/src/app/features/files/pages/file-detail/file-detail.component.html b/src/app/features/files/pages/file-detail/file-detail.component.html index 05c85e754..1e7d75fd7 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.html +++ b/src/app/features/files/pages/file-detail/file-detail.component.html @@ -1,6 +1,6 @@ - + {{ 'files.detail.tabs.details' | translate }} {{ 'files.detail.tabs.revisions' | translate }} @@ -12,13 +12,13 @@
    - @if (!isAnonymous()) { + @if (!isAnonymous() && !hasViewOnly()) {
    } - @if (file() && !isAnonymous()) { + @if (file() && !isAnonymous() && !hasViewOnly()) {