diff --git a/jest.config.js b/jest.config.js index 2ac9f4723..79cf42f14 100644 --- a/jest.config.js +++ b/jest.config.js @@ -30,6 +30,7 @@ module.exports = { 'src/app/**/*.{ts,js}', '!src/app/app.config.ts', '!src/app/**/*.routes.{ts.js}', + '!src/app/**/*.actions.{ts.js}', '!src/app/**/*.models.{ts.js}', '!src/app/**/*.model.{ts.js}', '!src/app/**/*.route.{ts,js}', @@ -45,10 +46,10 @@ module.exports = { extensionsToTreatAsEsm: ['.ts'], coverageThreshold: { global: { - branches: 14.15, - functions: 14.83, - lines: 41.15, - statements: 41.63, + branches: 14.27, + functions: 15.55, + lines: 42.6, + statements: 43.2, }, }, watchPathIgnorePatterns: [ diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 2d95fc017..80e6b0d3e 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -51,7 +51,7 @@ import { SearchInputComponent, SubHeaderComponent, } from '@shared/components'; -import { ConfiguredStorageAddon, FilesTreeActions, OsfFile } from '@shared/models'; +import { ConfiguredStorageAddonModel, FilesTreeActions, OsfFile } from '@shared/models'; import { FilesService } from '@shared/services'; import { CreateFolderDialogComponent } from '../../components'; @@ -346,7 +346,7 @@ export class FilesComponent { this.router.navigate([file.guid], { relativeTo: this.activeRoute }); } - getAddonName(addons: ConfiguredStorageAddon[], provider: string): string { + getAddonName(addons: ConfiguredStorageAddonModel[], provider: string): string { if (provider === 'osfstorage') { return 'Osf Storage'; } else { diff --git a/src/app/features/files/store/files.model.ts b/src/app/features/files/store/files.model.ts index 79d621f16..8b0cf19af 100644 --- a/src/app/features/files/store/files.model.ts +++ b/src/app/features/files/store/files.model.ts @@ -1,5 +1,5 @@ import { ContributorModel, OsfFile, ResourceMetadata } from '@shared/models'; -import { ConfiguredStorageAddon } from '@shared/models/addons'; +import { ConfiguredStorageAddonModel } from '@shared/models/addons'; import { AsyncStateModel } from '@shared/models/store'; import { FileProvider } from '../constants'; @@ -20,7 +20,7 @@ export interface FilesStateModel { fileRevisions: AsyncStateModel; tags: AsyncStateModel; rootFolders: AsyncStateModel; - configuredStorageAddons: AsyncStateModel; + configuredStorageAddons: AsyncStateModel; } export const filesStateDefaults: FilesStateModel = { diff --git a/src/app/features/files/store/files.selectors.ts b/src/app/features/files/store/files.selectors.ts index a7033f803..4d1c103cf 100644 --- a/src/app/features/files/store/files.selectors.ts +++ b/src/app/features/files/store/files.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { ConfiguredStorageAddon, ContributorModel, OsfFile, ResourceMetadata } from '@shared/models'; +import { ConfiguredStorageAddonModel, ContributorModel, OsfFile, ResourceMetadata } from '@shared/models'; import { OsfFileCustomMetadata, OsfFileRevision } from '../models'; @@ -114,7 +114,7 @@ export class FilesSelectors { } @Selector([FilesState]) - static getConfiguredStorageAddons(state: FilesStateModel): ConfiguredStorageAddon[] | null { + static getConfiguredStorageAddons(state: FilesStateModel): ConfiguredStorageAddonModel[] | null { return state.configuredStorageAddons.data; } diff --git a/src/app/features/project/addons/components/configure-addon/configure-addon.component.html b/src/app/features/project/addons/components/configure-addon/configure-addon.component.html index bf12fc308..32b811407 100644 --- a/src/app/features/project/addons/components/configure-addon/configure-addon.component.html +++ b/src/app/features/project/addons/components/configure-addon/configure-addon.component.html @@ -63,6 +63,11 @@

(keydown.enter)="toggleEditMode()" > + (save)="handleUpdateAddonConfiguration()" (cancelSelection)="toggleEditMode()" /> + } diff --git a/src/app/features/project/addons/components/configure-addon/configure-addon.component.ts b/src/app/features/project/addons/components/configure-addon/configure-addon.component.ts index 6a02d79ea..35cf69c2c 100644 --- a/src/app/features/project/addons/components/configure-addon/configure-addon.component.ts +++ b/src/app/features/project/addons/components/configure-addon/configure-addon.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'; @@ -25,7 +25,7 @@ import { OperationNames } from '@osf/features/project/addons/enums'; import { getAddonTypeString } from '@osf/shared/helpers'; import { SubHeaderComponent } from '@shared/components'; import { FolderSelectorComponent } from '@shared/components/addons/folder-selector/folder-selector.component'; -import { ConfiguredAddon } from '@shared/models'; +import { AddonModel, ConfiguredStorageAddonModel } from '@shared/models'; import { AddonDialogService, AddonFormService, AddonOperationInvocationService, ToastService } from '@shared/services'; import { AddonsSelectors, @@ -63,9 +63,26 @@ export class ConfigureAddonComponent implements OnInit { private addonDialogService = inject(AddonDialogService); private addonFormService = inject(AddonFormService); private operationInvocationService = inject(AddonOperationInvocationService); - + /** + * Injected NGXS store used to access and dispatch state actions and selectors. + */ + private store = inject(Store); + + /** + * Form control for capturing or displaying the user’s selected account name. + */ protected accountNameControl = new FormControl(''); - protected addon = signal(null); + /** + * Signal representing the currently selected `Addon` from the list of available storage addons. + * This value updates reactively as the selection changes. + */ + protected storageAddon = signal(undefined); + /** + * Signal representing the currently selected and configured storage addon model. + * This may be `null` if no addon has been configured. + */ + protected addon = signal(null); + protected isEditMode = signal(false); protected selectedRootFolderId = signal(''); protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); @@ -101,9 +118,15 @@ export class ConfigureAddonComponent implements OnInit { } private initializeAddon(): void { - const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as ConfiguredAddon; + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as ConfiguredStorageAddonModel; if (addon) { + this.storageAddon.set( + this.store.selectSnapshot((state) => + AddonsSelectors.getStorageAddon(state.addons, addon.externalStorageServiceId || '') + ) + ); + this.addon.set(addon); this.selectedRootFolderId.set(addon.selectedFolderId); this.accountNameControl.setValue(addon.displayName); @@ -132,7 +155,7 @@ export class ConfigureAddonComponent implements OnInit { this.openDisconnectDialog(currentAddon); } - private openDisconnectDialog(addon: ConfiguredAddon): void { + private openDisconnectDialog(addon: ConfiguredStorageAddonModel): void { const dialogRef = this.addonDialogService.openDisconnectDialog(addon); dialogRef.subscribe((result) => { diff --git a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.spec.ts b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.spec.ts index 88240f190..f3d4f48da 100644 --- a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.spec.ts +++ b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.spec.ts @@ -11,7 +11,7 @@ import { ActivatedRoute, Navigation, Router, UrlTree } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components'; import { CredentialsFormat } from '@shared/enums'; -import { Addon } from '@shared/models'; +import { AddonModel } from '@shared/models'; import { AddonsSelectors } from '@shared/stores/addons'; import { ConnectConfiguredAddonComponent } from './connect-configured-addon.component'; @@ -20,7 +20,7 @@ describe('ConnectAddonComponent', () => { let component: ConnectConfiguredAddonComponent; let fixture: ComponentFixture; - const mockAddon: Addon = { + const mockAddon: AddonModel = { id: 'test-addon-id', type: 'external-storage-services', displayName: 'Test Addon', 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 eb64fb5f6..d2660985c 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 @@ -23,7 +23,7 @@ import { AddonTermsComponent, FolderSelectorComponent, } from '@shared/components/addons'; -import { Addon, AddonTerm, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; +import { AddonModel, AddonTerm, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; import { AddonDialogService, AddonFormService, AddonOperationInvocationService, ToastService } from '@shared/services'; import { AddonsSelectors, @@ -74,7 +74,7 @@ export class ConnectConfiguredAddonComponent { protected readonly stepper = viewChild(Stepper); protected accountNameControl = new FormControl(''); protected terms = signal([]); - protected addon = signal(null); + protected addon = signal(null); protected addonAuthUrl = signal('/settings/addons'); protected currentAuthorizedAddonAccounts = signal([]); protected chosenAccountId = signal(''); @@ -128,7 +128,7 @@ export class ConnectConfiguredAddonComponent { }); constructor() { - const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as Addon | AuthorizedAddon; + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAddon; if (!addon) { this.router.navigate([`${this.baseUrl()}/addons`]); } @@ -243,7 +243,7 @@ export class ConnectConfiguredAddonComponent { private processAuthorizedAddons( addonConfig: AddonConfigMap[keyof AddonConfigMap], - currentAddon: Addon | AuthorizedAddon + currentAddon: AddonModel | AuthorizedAddon ) { const authorizedAddons = addonConfig.getAuthorizedAddons(); const matchingAddons = this.findMatchingAddons(authorizedAddons, currentAddon); @@ -262,7 +262,7 @@ export class ConnectConfiguredAddonComponent { private findMatchingAddons( authorizedAddons: AuthorizedAddon[], - currentAddon: Addon | AuthorizedAddon + currentAddon: AddonModel | AuthorizedAddon ): AuthorizedAddon[] { return authorizedAddons.filter((addon) => addon.externalServiceName === currentAddon.externalServiceName); } 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 27f2a57c7..b44467305 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 @@ -14,7 +14,7 @@ import { SubHeaderComponent } from '@osf/shared/components'; import { ProjectAddonsStepperValue } from '@osf/shared/enums'; import { getAddonTypeString, isAuthorizedAddon } from '@osf/shared/helpers'; import { AddonSetupAccountFormComponent, AddonTermsComponent } from '@shared/components/addons'; -import { Addon, AddonTerm, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; +import { AddonModel, AddonTerm, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@shared/stores/addons'; @Component({ @@ -43,7 +43,7 @@ export class ConnectAddonComponent { protected readonly ProjectAddonsStepperValue = ProjectAddonsStepperValue; protected terms = signal([]); - protected addon = signal(null); + protected addon = signal(null); protected addonAuthUrl = signal('/settings/addons'); protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); @@ -70,7 +70,7 @@ export class ConnectAddonComponent { }); constructor() { - const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as Addon | AuthorizedAddon; + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAddon; if (!addon) { this.router.navigate([`${this.baseUrl()}/addons`]); } diff --git a/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts index 60357eb65..b2510a3de 100644 --- a/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts +++ b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts @@ -3,7 +3,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Component, input } from '@angular/core'; import { AddonCardComponent } from '@shared/components/addons'; -import { Addon, AuthorizedAddon, ConfiguredAddon } from '@shared/models'; +import { AddonModel, AuthorizedAddon, ConfiguredStorageAddonModel } from '@shared/models'; @Component({ selector: 'osf-addon-card-list', @@ -12,7 +12,7 @@ import { Addon, AuthorizedAddon, ConfiguredAddon } from '@shared/models'; styleUrl: './addon-card-list.component.scss', }) export class AddonCardListComponent { - cards = input<(Addon | AuthorizedAddon | ConfiguredAddon)[]>([]); + cards = input<(AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel)[]>([]); cardButtonLabel = input(''); showDangerButton = input(false); } diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts b/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts index 90d143023..56a48cb86 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts @@ -10,7 +10,7 @@ import { Router } from '@angular/router'; import { CredentialsFormat } from '@shared/enums'; import { MockCustomConfirmationServiceProvider } from '@shared/mocks'; -import { Addon } from '@shared/models'; +import { AddonModel } from '@shared/models'; import { CustomConfirmationService } from '@shared/services'; import { AddonCardComponent } from './addon-card.component'; @@ -22,7 +22,7 @@ describe('AddonCardComponent', () => { let customConfirmationService: CustomConfirmationService; let store: Store; - const mockAddon: Addon = { + const mockAddon: AddonModel = { id: 'test-addon-id', type: 'external-storage-services', displayName: 'Test Addon', diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.ts b/src/app/shared/components/addons/addon-card/addon-card.component.ts index 17a5bbbb3..0ad70f6b9 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.ts @@ -9,7 +9,7 @@ import { Router } from '@angular/router'; import { getAddonTypeString, isConfiguredAddon } from '@osf/shared/helpers'; import { CustomConfirmationService, LoaderService } from '@osf/shared/services'; -import { Addon, AuthorizedAddon, ConfiguredAddon } from '@shared/models'; +import { AddonModel, AuthorizedAddon, ConfiguredStorageAddonModel } from '@shared/models'; import { DeleteAuthorizedAddon } from '@shared/stores/addons'; @Component({ @@ -24,7 +24,7 @@ export class AddonCardComponent { private readonly loaderService = inject(LoaderService); private readonly actions = createDispatchMap({ deleteAuthorizedAddon: DeleteAuthorizedAddon }); - readonly card = input(null); + readonly card = input(null); readonly cardButtonLabel = input(''); readonly showDangerButton = input(false); diff --git a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.ts b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.ts index 8bf5f5e3a..491e3d342 100644 --- a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.ts +++ b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.ts @@ -10,7 +10,7 @@ import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { AddonFormControls, CredentialsFormat } from '@shared/enums'; -import { Addon, AddonForm, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; +import { AddonForm, AddonModel, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; import { AddonFormService } from '@shared/services/addons/addon-form.service'; @Component({ @@ -22,7 +22,7 @@ import { AddonFormService } from '@shared/services/addons/addon-form.service'; export class AddonSetupAccountFormComponent { private addonFormService = inject(AddonFormService); - addon = input.required(); + addon = input.required(); userReferenceId = input.required(); addonTypeString = input.required(); isSubmitting = input(false); diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts index 8b993cc7c..1cbd07a4f 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts @@ -7,7 +7,7 @@ import { isCitationAddon } from '@osf/shared/helpers'; import { AddonTermsComponent } from '@shared/components/addons'; import { ADDON_TERMS } from '@shared/constants'; import { MOCK_ADDON } from '@shared/mocks'; -import { Addon, AddonTerm } from '@shared/models'; +import { AddonModel, AddonTerm } from '@shared/models'; jest.mock('@shared/helpers', () => ({ isCitationAddon: jest.fn(), @@ -17,7 +17,7 @@ describe('AddonTermsComponent', () => { let component: AddonTermsComponent; let fixture: ComponentFixture; const mockIsCitationAddon = isCitationAddon as jest.MockedFunction; - const mockAddon: Addon = MOCK_ADDON; + const mockAddon: AddonModel = MOCK_ADDON; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -42,7 +42,7 @@ describe('AddonTermsComponent', () => { }); it('should return terms for regular addon with unsupported features', () => { - const addonWithoutFeatures: Addon = { + const addonWithoutFeatures: AddonModel = { ...mockAddon, supportedFeatures: [], }; @@ -60,7 +60,7 @@ describe('AddonTermsComponent', () => { }); it('should return terms for regular addon with partial features', () => { - const addonWithPartialFeatures: Addon = { + const addonWithPartialFeatures: AddonModel = { ...mockAddon, supportedFeatures: ['FORKING_PARTIAL'], }; @@ -76,7 +76,7 @@ describe('AddonTermsComponent', () => { }); it('should replace {provider} placeholder with actual provider name', () => { - const customProviderAddon: Addon = { + const customProviderAddon: AddonModel = { ...mockAddon, providerName: 'CustomProvider', }; @@ -93,7 +93,7 @@ describe('AddonTermsComponent', () => { }); it('should show all terms when isCitationService is false', () => { - const regularAddon: Addon = { + const regularAddon: AddonModel = { ...mockAddon, supportedFeatures: ['STORAGE', 'FORKING'], }; @@ -110,7 +110,7 @@ describe('AddonTermsComponent', () => { }); it('should handle citation service without required features', () => { - const citationAddonWithoutFeatures: Addon = { + const citationAddonWithoutFeatures: AddonModel = { ...mockAddon, supportedFeatures: [], }; @@ -127,7 +127,7 @@ describe('AddonTermsComponent', () => { }); it('should handle citation service with full features', () => { - const citationAddonWithFullFeatures: Addon = { + const citationAddonWithFullFeatures: AddonModel = { ...mockAddon, supportedFeatures: ['STORAGE', 'FORKING'], }; @@ -160,7 +160,7 @@ describe('AddonTermsComponent', () => { }); it('should handle addon with empty supportedFeatures', () => { - const addonWithEmptyFeatures: Addon = { + const addonWithEmptyFeatures: AddonModel = { ...mockAddon, supportedFeatures: [], }; @@ -178,7 +178,7 @@ describe('AddonTermsComponent', () => { }); it('should handle addon with partial features only', () => { - const addonWithPartialOnly: Addon = { + const addonWithPartialOnly: AddonModel = { ...mockAddon, supportedFeatures: ['STORAGE_PARTIAL', 'FORKING_PARTIAL'], }; @@ -195,7 +195,7 @@ describe('AddonTermsComponent', () => { }); it('should handle addon with mixed features (full, partial, none)', () => { - const addonWithMixedFeatures: Addon = { + const addonWithMixedFeatures: AddonModel = { ...mockAddon, supportedFeatures: ['STORAGE', 'FORKING_PARTIAL'], }; diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts index 642988e68..7d3239894 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts @@ -7,7 +7,7 @@ import { Component, computed, input } from '@angular/core'; import { isCitationAddon } from '@osf/shared/helpers'; import { ADDON_TERMS as addonTerms } from '@shared/constants'; -import { Addon, AddonTerm, AuthorizedAddon } from '@shared/models'; +import { AddonModel, AddonTerm, AuthorizedAddon } from '@shared/models'; @Component({ selector: 'osf-addon-terms', @@ -16,7 +16,7 @@ import { Addon, AddonTerm, AuthorizedAddon } from '@shared/models'; styleUrls: ['./addon-terms.component.scss'], }) export class AddonTermsComponent { - addon = input(null); + addon = input(null); protected terms = computed(() => { const addon = this.addon(); if (!addon) { @@ -25,7 +25,7 @@ export class AddonTermsComponent { return this.getAddonTerms(addon); }); - private getAddonTerms(addon: Addon | AuthorizedAddon): AddonTerm[] { + private getAddonTerms(addon: AddonModel | AuthorizedAddon): AddonTerm[] { const supportedFeatures = addon.supportedFeatures; const provider = addon.providerName; const isCitationService = isCitationAddon(addon); diff --git a/src/app/shared/helpers/addon-type.helper.ts b/src/app/shared/helpers/addon-type.helper.ts index cdc7f3dff..9a1ec71dc 100644 --- a/src/app/shared/helpers/addon-type.helper.ts +++ b/src/app/shared/helpers/addon-type.helper.ts @@ -1,6 +1,6 @@ -import { Addon, AuthorizedAddon, ConfiguredAddon } from '@shared/models'; +import { AddonModel, AuthorizedAddon, ConfiguredStorageAddonModel } from '@shared/models'; -export function isStorageAddon(addon: Addon | AuthorizedAddon | ConfiguredAddon | null): boolean { +export function isStorageAddon(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): boolean { if (!addon) return false; return ( @@ -10,7 +10,7 @@ export function isStorageAddon(addon: Addon | AuthorizedAddon | ConfiguredAddon ); } -export function isCitationAddon(addon: Addon | AuthorizedAddon | ConfiguredAddon | null): boolean { +export function isCitationAddon(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): boolean { if (!addon) return false; return ( @@ -20,19 +20,19 @@ export function isCitationAddon(addon: Addon | AuthorizedAddon | ConfiguredAddon ); } -export function getAddonTypeString(addon: Addon | AuthorizedAddon | ConfiguredAddon | null): string { +export function getAddonTypeString(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): string { if (!addon) return ''; return isStorageAddon(addon) ? 'storage' : 'citation'; } -export function isAuthorizedAddon(addon: Addon | AuthorizedAddon | ConfiguredAddon | null): boolean { +export function isAuthorizedAddon(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): boolean { if (!addon) return false; return addon.type === 'authorized-storage-accounts' || addon.type === 'authorized-citation-accounts'; } -export function isConfiguredAddon(addon: Addon | AuthorizedAddon | ConfiguredAddon | null): boolean { +export function isConfiguredAddon(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): boolean { if (!addon) return false; return addon.type === 'configured-storage-addons' || addon.type === 'configured-citation-addons'; diff --git a/src/app/shared/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts index 7589ba85e..4758dbaac 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -1,10 +1,10 @@ import { - Addon, AddonGetResponseJsonApi, + AddonModel, AuthorizedAddon, AuthorizedAddonGetResponseJsonApi, - ConfiguredAddon, ConfiguredAddonGetResponseJsonApi, + ConfiguredStorageAddonModel, IncludedAddonData, OperationInvocation, OperationInvocationResponseJsonApi, @@ -12,10 +12,11 @@ import { } from '../models'; export class AddonMapper { - static fromResponse(response: AddonGetResponseJsonApi): Addon { + static fromResponse(response: AddonGetResponseJsonApi): AddonModel { return { type: response.type, id: response.id, + wbKey: response.attributes.wb_key, authUrl: response.attributes.auth_uri, displayName: response.attributes.display_name, externalServiceName: response.attributes.external_service_name, @@ -64,7 +65,20 @@ export class AddonMapper { }; } - static fromConfiguredAddonResponse(response: ConfiguredAddonGetResponseJsonApi): ConfiguredAddon { + /** + * Maps a JSON:API-formatted response object into a `ConfiguredAddon` domain model. + * + * @param response - The raw API response object representing a configured addon. + * This must conform to the `ConfiguredAddonGetResponseJsonApi` structure. + * + * @returns A `ConfiguredAddon` object with normalized and flattened properties + * for application use. + * + * @example + * const addon = AddonMapper.fromConfiguredAddonResponse(apiResponse); + * console.log(addon.displayName); // "Google Drive" + */ + static fromConfiguredAddonResponse(response: ConfiguredAddonGetResponseJsonApi): ConfiguredStorageAddonModel { return { type: response.type, id: response.id, @@ -76,6 +90,7 @@ export class AddonMapper { currentUserIsOwner: response.attributes.current_user_is_owner, baseAccountId: response.relationships.base_account.data.id, baseAccountType: response.relationships.base_account.data.type, + externalStorageServiceId: response.relationships?.external_storage_service?.data?.id, }; } diff --git a/src/app/shared/mocks/addon.mock.ts b/src/app/shared/mocks/addon.mock.ts index 7a32d88a4..068725a85 100644 --- a/src/app/shared/mocks/addon.mock.ts +++ b/src/app/shared/mocks/addon.mock.ts @@ -1,7 +1,7 @@ import { CredentialsFormat } from '@shared/enums'; -import { Addon } from '@shared/models'; +import { AddonModel } from '@shared/models'; -export const MOCK_ADDON: Addon = { +export const MOCK_ADDON: AddonModel = { type: 'addon', id: 'id1', authUrl: 'https://test.com/auth', diff --git a/src/app/shared/models/addons/addons.models.ts b/src/app/shared/models/addons/addons.models.ts index a3e1d3288..acb5099d2 100644 --- a/src/app/shared/models/addons/addons.models.ts +++ b/src/app/shared/models/addons/addons.models.ts @@ -1,18 +1,67 @@ +/** + * JSON:API response structure for a single external addon. + */ export interface AddonGetResponseJsonApi { + /** + * Resource type (e.g., 'external-storage-services'). + */ type: string; + /** + * Unique identifier for the addon. + */ id: string; + /** + * Addon metadata fields returned from the API. + */ attributes: { + /** + * OAuth authorization URI for the external provider. + */ auth_uri: string; + /** + * Human-readable name of the addon (e.g., "Google Drive"). + */ display_name: string; + /** + * List of supported capabilities for this addon + * (e.g., 'DOWNLOAD_AS_ZIP', 'PERMISSIONS'). + */ supported_features: string[]; + /** + * Internal identifier for the external provider + * (e.g., 'googledrive', 'figshare'). + */ external_service_name: string; + /** + * Type of credentials used to authenticate + * (e.g., 'OAUTH2', 'S3'). + */ credentials_format: string; + /** + * Internal WaterButler key used for routing and integration. + */ + wb_key: string; + /** + * Additional provider-specific fields (if present). + */ [key: string]: unknown; }; + /** + * Object relationships to other API resources. + */ relationships: { + /** + * Reference to the associated addon implementation. + */ addon_imp: { data: { + /** + * Resource type of the related addon implementation. + */ type: string; + /** + * Resource ID of the related addon implementation. + */ id: string; }; }; @@ -53,36 +102,129 @@ export interface AuthorizedAddonGetResponseJsonApi { }; } +/** + * Interface representing the JSON:API response shape for a configured addon. + * + * This structure is returned from the backend when querying for configured addons + * related to a specific resource or user. It conforms to the JSON:API specification. + */ export interface ConfiguredAddonGetResponseJsonApi { + /** + * The resource type (e.g., "configured-storage-addons"). + */ type: string; + /** + * Unique identifier of the configured addon. + */ id: string; + /** + * Attributes associated with the configured addon. + */ attributes: { + /** + * Display name shown to users (e.g., "Google Drive"). + */ display_name: string; + /** + * Internal identifier of the external storage service (e.g., "googledrive"). + */ external_service_name: string; + /** + * ID of the root folder selected during configuration. + */ root_folder: string; + /** + * List of capabilities enabled for this addon (e.g., "DOWNLOAD", "UPLOAD"). + */ connected_capabilities: string[]; + /** + * List of operation names tied to the addon configuration. + */ connected_operation_names: string[]; + /** + * Indicates whether the current user is the owner of this addon configuration. + */ current_user_is_owner: boolean; }; + + /** + * Relationships to other entities, such as accounts or external services. + */ relationships: { + /** + * Reference to the base account used for this addon. + */ base_account: { data: { + /** + * Resource type of the account (e.g., "accounts"). + */ + type: string; + /** + * Unique identifier of the account. + */ + id: string; + }; + }; + + /** + * Reference to the underlying external storage service (e.g., Dropbox, Drive). + */ + external_storage_service: { + data: { + /** + * Resource type of the storage service. + */ type: string; + /** + * Unique identifier of the storage service. + */ id: string; }; }; }; } -export interface Addon { +/** + * Normalized model representing an external addon provider. + */ +export interface AddonModel { + /** + * Resource type, typically 'external-storage-services'. + */ type: string; + /** + * Unique identifier of the addon instance. + */ id: string; + /** + * OAuth authorization URI for initiating credential flow. + */ authUrl: string; + /** + * Human-friendly name of the addon (e.g., 'Google Drive'). + */ displayName: string; + /** + * Machine-friendly name of the addon (e.g., 'googledrive'). + */ externalServiceName: string; + /** + * List of supported features or capabilities the addon provides. + */ supportedFeatures: string[]; + /** + * Credential mechanism used by the addon (e.g., 'OAUTH2', 'S3'). + */ credentialsFormat: string; + /** + * Provider key used internally (e.g., for icon or routing). + */ providerName: string; + /** + * Internal WaterButler key used for addon routing. + */ + wbKey: string; } export interface AuthorizedAddon { @@ -103,19 +245,6 @@ export interface AuthorizedAddon { credentialsFormat: string; } -export interface ConfiguredAddon { - type: string; - id: string; - displayName: string; - externalServiceName: string; - selectedFolderId: string; - connectedCapabilities: string[]; - connectedOperationNames: string[]; - currentUserIsOwner: boolean; - baseAccountId: string; - baseAccountType: string; -} - export interface IncludedAddonData { type: string; id: string; diff --git a/src/app/shared/models/addons/configured-storage-addon.model.ts b/src/app/shared/models/addons/configured-storage-addon.model.ts index 6f3ba6e09..4ac031aee 100644 --- a/src/app/shared/models/addons/configured-storage-addon.model.ts +++ b/src/app/shared/models/addons/configured-storage-addon.model.ts @@ -1,4 +1,52 @@ -export interface ConfiguredStorageAddon { - externalServiceName: string; +/** + * Represents a configured external addon instance for a resource (e.g., a linked storage service). + * + * This model is used after an addon has been fully authorized and configured for a specific project + * or user in the OSF system. It includes metadata about the integration, ownership, and operational capabilities. + */ +export interface ConfiguredStorageAddonModel { + /** + * The JSON:API resource type (typically "configured-storage-addons"). + */ + type: string; + /** + * The unique identifier for the configured addon instance. + */ + id: string; + /** + * The human-readable name of the external service (e.g., "Google Drive"). + */ displayName: string; + /** + * The internal key used to identify the service provider (e.g., "googledrive"). + */ + externalServiceName: string; + /** + * The ID of the folder selected as the root for the addon connection. + */ + selectedFolderId: string; + /** + * A list of capabilities supported by this addon (e.g., ADD_UPDATE_FILES, PERMISSIONS). + */ + connectedCapabilities: string[]; + /** + * A list of connected operation names available through this addon (e.g., UPLOAD_FILE). + */ + connectedOperationNames: string[]; + /** + * Indicates whether the current user is the owner of the configured addon. + */ + currentUserIsOwner: boolean; + /** + * The ID of the base account associated with this addon configuration. + */ + baseAccountId: string; + /** + * The resource type of the base account (e.g., "external-storage-accounts"). + */ + baseAccountType: string; + /** + * Optional: If linked to a parent storage service, provides its ID and name. + */ + externalStorageServiceId?: string; } diff --git a/src/app/shared/services/addons/addon-dialog.service.ts b/src/app/shared/services/addons/addon-dialog.service.ts index 6acebdd28..9368e475d 100644 --- a/src/app/shared/services/addons/addon-dialog.service.ts +++ b/src/app/shared/services/addons/addon-dialog.service.ts @@ -8,7 +8,7 @@ import { inject, Injectable } from '@angular/core'; import { ConfirmAccountConnectionModalComponent } from '@osf/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component'; import { DisconnectAddonModalComponent } from '@osf/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component'; -import { AuthorizedAddon, ConfiguredAddon } from '@shared/models'; +import { AuthorizedAddon, ConfiguredStorageAddonModel } from '@shared/models'; @Injectable({ providedIn: 'root', @@ -17,7 +17,7 @@ export class AddonDialogService { private dialogService = inject(DialogService); private translateService = inject(TranslateService); - openDisconnectDialog(addon: ConfiguredAddon): Observable<{ success: boolean }> { + openDisconnectDialog(addon: ConfiguredStorageAddonModel): Observable<{ success: boolean }> { const dialogRef = this.dialogService.open(DisconnectAddonModalComponent, { focusOnShow: false, header: this.translateService.instant('settings.addons.configureAddon.disconnect', { diff --git a/src/app/shared/services/addons/addon-form.service.ts b/src/app/shared/services/addons/addon-form.service.ts index 7201ede4a..275a033a4 100644 --- a/src/app/shared/services/addons/addon-form.service.ts +++ b/src/app/shared/services/addons/addon-form.service.ts @@ -4,12 +4,12 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { isAuthorizedAddon } from '@osf/shared/helpers'; import { AddonFormControls, CredentialsFormat } from '@shared/enums'; import { - Addon, AddonForm, + AddonModel, AuthorizedAddon, AuthorizedAddonRequestJsonApi, - ConfiguredAddon, ConfiguredAddonRequestJsonApi, + ConfiguredStorageAddonModel, } from '@shared/models'; @Injectable({ @@ -18,7 +18,7 @@ import { export class AddonFormService { protected formBuilder: FormBuilder = inject(FormBuilder); - initializeForm(addon: Addon | AuthorizedAddon): FormGroup { + initializeForm(addon: AddonModel | AuthorizedAddon): FormGroup { if (!addon) { return new FormGroup({} as AddonForm); } @@ -51,7 +51,7 @@ export class AddonFormService { generateAuthorizedAddonPayload( formValue: Record, - addon: Addon | AuthorizedAddon, + addon: AddonModel | AuthorizedAddon, userReferenceId: string, addonTypeString: string ): AuthorizedAddonRequestJsonApi { @@ -107,12 +107,12 @@ export class AddonFormService { }; } - private getAddonServiceId(addon: Addon | AuthorizedAddon): string { - return isAuthorizedAddon(addon) ? (addon as AuthorizedAddon).externalStorageServiceId : (addon as Addon).id; + private getAddonServiceId(addon: AddonModel | AuthorizedAddon): string { + return isAuthorizedAddon(addon) ? (addon as AuthorizedAddon).externalStorageServiceId : (addon as AddonModel).id; } generateConfiguredAddonCreatePayload( - addon: Addon | AuthorizedAddon, + addon: AddonModel | AuthorizedAddon, selectedAccount: AuthorizedAddon, userReferenceId: string, resourceUri: string, @@ -156,7 +156,7 @@ export class AddonFormService { } generateConfiguredAddonUpdatePayload( - addon: ConfiguredAddon, + addon: ConfiguredStorageAddonModel, userReferenceId: string, resourceUri: string, displayName: string, diff --git a/src/app/shared/services/addons/addon-operation-invocation.service.ts b/src/app/shared/services/addons/addon-operation-invocation.service.ts index e295ac956..163e0449a 100644 --- a/src/app/shared/services/addons/addon-operation-invocation.service.ts +++ b/src/app/shared/services/addons/addon-operation-invocation.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { OperationNames } from '@osf/features/project/addons/enums'; -import { AuthorizedAddon, ConfiguredAddon, OperationInvocationRequestJsonApi } from '@shared/models'; +import { AuthorizedAddon, ConfiguredStorageAddonModel, OperationInvocationRequestJsonApi } from '@shared/models'; @Injectable({ providedIn: 'root', @@ -38,7 +38,7 @@ export class AddonOperationInvocationService { } createOperationInvocationPayload( - addon: ConfiguredAddon, + addon: ConfiguredStorageAddonModel, operationName: OperationNames, itemId: string ): OperationInvocationRequestJsonApi { diff --git a/src/app/shared/services/addons/addons.service.spec.ts b/src/app/shared/services/addons/addons.service.spec.ts index 08ab2518e..634631352 100644 --- a/src/app/shared/services/addons/addons.service.spec.ts +++ b/src/app/shared/services/addons/addons.service.spec.ts @@ -3,6 +3,7 @@ import { inject, TestBed } from '@angular/core/testing'; import { AddonsService } from './addons.service'; +import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; import { getAddonsExternalStorageData } from '@testing/data/addons/addons.external-storage.data'; import { OSFTestingModule } from '@testing/osf.testing.module'; @@ -40,5 +41,37 @@ describe('Service: Addons', () => { type: 'external-storage-services', }) ); + + expect(httpMock.verify).toBeTruthy(); + })); + + it('should test getConfigureAddons', inject([HttpTestingController], (httpMock: HttpTestingController) => { + let results: any[] = []; + service.getConfiguredAddons('storage', 'reference-id').subscribe((result) => { + results = result; + }); + + const request = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/resource-references/reference-id/configured_storage_addons/' + ); + expect(request.request.method).toBe('GET'); + request.flush(getConfiguredAddonsData()); + + expect(results[0]).toEqual( + Object({ + baseAccountId: '62ed6dd7-f7b7-4003-b7b4-855789c1f991', + baseAccountType: 'authorized-storage-accounts', + connectedCapabilities: ['ACCESS', 'UPDATE'], + connectedOperationNames: ['list_child_items', 'list_root_items', 'get_item_info'], + currentUserIsOwner: true, + displayName: 'Google Drive', + externalServiceName: 'googledrive', + id: '756579dc-3a24-4849-8866-698a60846ac3', + selectedFolderId: '0AIl0aR4C9JAFUk9PVA', + type: 'configured-storage-addons', + }) + ); + + expect(httpMock.verify).toBeTruthy(); })); }); diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index d7dfa5e59..e31b845d2 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -7,16 +7,16 @@ import { inject, Injectable } from '@angular/core'; import { UserSelectors } from '@core/store/user'; import { AddonMapper } from '@shared/mappers'; import { - Addon, AddonGetResponseJsonApi, + AddonModel, AuthorizedAddon, AuthorizedAddonGetResponseJsonApi, AuthorizedAddonRequestJsonApi, AuthorizedAddonResponseJsonApi, - ConfiguredAddon, ConfiguredAddonGetResponseJsonApi, ConfiguredAddonRequestJsonApi, ConfiguredAddonResponseJsonApi, + ConfiguredStorageAddonModel, IncludedAddonData, JsonApiResponse, OperationInvocation, @@ -63,7 +63,7 @@ export class AddonsService { * @returns Observable emitting an array of mapped Addon objects. * */ - getAddons(addonType: string): Observable { + getAddons(addonType: string): Observable { return this.jsonApiService .get< JsonApiResponse @@ -117,7 +117,15 @@ export class AddonsService { ); } - getConfiguredAddons(addonType: string, referenceId: string): Observable { + /** + * Retrieves the list of configured addons for a given resource reference. + * + * @param addonType - The addon category to retrieve. Valid values: `'citation'` or `'storage'`. + * @param referenceId - The unique identifier of the resource (e.g., node, registration) that the addons are configured for. + * @returns An observable that emits an array of {@link ConfiguredStorageAddonModel} objects. + * + */ + getConfiguredAddons(addonType: string, referenceId: string): Observable { return this.jsonApiService .get< JsonApiResponse diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index fdf8a3553..80654475e 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -21,7 +21,7 @@ import { import { AddFileResponse, ApiData, - ConfiguredStorageAddon, + ConfiguredStorageAddonModel, ContributorModel, ContributorResponse, FileLinks, @@ -293,7 +293,7 @@ export class FilesService { .pipe(map((response) => response.data?.[0]?.links?.self ?? '')); } - getConfiguredStorageAddons(resourceUri: string): Observable { + getConfiguredStorageAddons(resourceUri: string): Observable { return this.getResourceReferences(resourceUri).pipe( switchMap((referenceUrl: string) => { if (!referenceUrl) return of([]); @@ -301,11 +301,15 @@ export class FilesService { return this.jsonApiService .get(`${referenceUrl}/configured_storage_addons`) .pipe( - map((response) => - response.data.map((addon) => ({ - externalServiceName: addon.attributes.external_service_name, - displayName: addon.attributes.display_name, - })) + map( + (response) => + response.data.map( + (addon) => + ({ + externalServiceName: addon.attributes.external_service_name, + displayName: addon.attributes.display_name, + }) as ConfiguredStorageAddonModel + ) as ConfiguredStorageAddonModel[] ) ); }) diff --git a/src/app/shared/stores/addons/addons.actions.ts b/src/app/shared/stores/addons/addons.actions.ts index 7b3915f2c..4cca15336 100644 --- a/src/app/shared/stores/addons/addons.actions.ts +++ b/src/app/shared/stores/addons/addons.actions.ts @@ -4,6 +4,17 @@ import { OperationInvocationRequestJsonApi, } from '@shared/models'; +/** + * NGXS Action to initiate loading of all available storage addon types. + * + * This action is handled by the `AddonsState` and triggers an HTTP + * request to retrieve external storage addon definitions (e.g., Google Drive, Dropbox). + * + * @example + * store.dispatch(new GetStorageAddons()); + * + * @see AddonsState.getStorageAddons + */ export class GetStorageAddons { static readonly type = '[Addons] Get Storage Addons'; } @@ -24,6 +35,18 @@ export class GetAuthorizedCitationAddons { constructor(public referenceId: string) {} } +/** + * NGXS Action to initiate loading of configured storage addons + * for a specific resource reference (e.g., node, registration). + * + * This action is handled by the `AddonsState` and triggers an HTTP + * request to fetch addons configured for the given `referenceId`. + * + * @example + * store.dispatch(new GetConfiguredStorageAddons('abc123')); + * + * @see AddonsState.getConfiguredStorageAddons + */ export class GetConfiguredStorageAddons { static readonly type = '[Addons] Get Configured Storage Addons'; diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index ca13c78e5..48d1bdcf5 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -1,26 +1,83 @@ import { - Addon, + AddonModel, AsyncStateModel, AuthorizedAddon, AuthorizedAddonResponseJsonApi, - ConfiguredAddon, ConfiguredAddonResponseJsonApi, + ConfiguredStorageAddonModel, OperationInvocation, ResourceReferenceJsonApi, UserReferenceJsonApi, } from '@osf/shared/models'; +/** + * Represents the full NGXS state model for addon-related data within the application. + * + * This state structure manages async lifecycle and data for all addon types, + * including storage and citation addons, both authorized and configured. + * It also includes references for addon-user and addon-resource relationships + * as well as operation invocation results. + * + * Each field is wrapped in `AsyncStateModel` to track loading, success, and error states. + */ export interface AddonsStateModel { - storageAddons: AsyncStateModel; - citationAddons: AsyncStateModel; + /** + * Async state for available external storage addons (e.g., Google Drive, Dropbox). + */ + storageAddons: AsyncStateModel; + + /** + * Async state for available external citation addons (e.g., Zotero, Mendeley). + */ + citationAddons: AsyncStateModel; + + /** + * Async state for authorized external storage addons linked to the current user. + */ authorizedStorageAddons: AsyncStateModel; + + /** + * Async state for authorized external citation addons linked to the current user. + */ authorizedCitationAddons: AsyncStateModel; - configuredStorageAddons: AsyncStateModel; - configuredCitationAddons: AsyncStateModel; + + /** + * Async state for storage addons that have been configured on a resource (e.g., project, node). + */ + configuredStorageAddons: AsyncStateModel; + + /** + * Async state for citation addons that have been configured on a resource (e.g., project, node). + */ + configuredCitationAddons: AsyncStateModel; + + /** + * Async state holding user-level references for addon configuration. + */ addonsUserReference: AsyncStateModel; + + /** + * Async state holding resource-level references for addon configuration. + */ addonsResourceReference: AsyncStateModel; + + /** + * Async state for the result of a create or update action on an authorized addon. + */ createdUpdatedAuthorizedAddon: AsyncStateModel; + + /** + * Async state for the result of a create or update action on a configured addon. + */ createdUpdatedConfiguredAddon: AsyncStateModel; + + /** + * Async state for the result of a generic operation invocation (e.g., folder list, disconnect). + */ operationInvocation: AsyncStateModel; + + /** + * Async state for a folder selection operation invocation (e.g., choosing a root folder). + */ selectedFolderOperationInvocation: AsyncStateModel; } diff --git a/src/app/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index 98e85144b..a50c42381 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -1,11 +1,11 @@ import { Selector } from '@ngxs/store'; import { - Addon, + AddonModel, AuthorizedAddon, AuthorizedAddonResponseJsonApi, - ConfiguredAddon, ConfiguredAddonResponseJsonApi, + ConfiguredStorageAddonModel, OperationInvocation, ResourceReferenceJsonApi, StorageItem, @@ -15,19 +15,55 @@ import { import { AddonsStateModel } from './addons.models'; import { AddonsState } from './addons.state'; +/** + * A static utility class containing NGXS selectors for accessing various slices + * of the `AddonsStateModel` in the NGXS store. + * + * This class provides typed, reusable selectors to extract addon-related state such as + * storage, citation, authorized, and configured addon collections. It supports structured + * access to application state and encourages consistency across components. + * + * All selectors in this class operate on the `AddonsStateModel`. + */ export class AddonsSelectors { - @Selector([AddonsState]) - static getStorageAddons(state: AddonsStateModel): Addon[] { + /** + * Selector to retrieve the list of available external storage addons from the NGXS state. + * + * These are public addon services (e.g., Google Drive, Dropbox) available for configuration. + * The data is retrieved from the `storageAddons` portion of the `AddonsStateModel`. + * + * @param state - The current `AddonsStateModel` from the NGXS store. + * @returns An array of available `Addon` objects representing storage providers. + */ + @Selector([AddonsState]) + static getStorageAddons(state: AddonsStateModel): AddonModel[] { return state.storageAddons.data; } + /** + * Selector to retrieve a specific storage addon by its ID from the NGXS state. + * + * @param state The current state of the Addons NGXS store. + * @param id The unique identifier of the storage addon to retrieve. + * @returns The matched Addon object if found, otherwise undefined. + */ + @Selector([AddonsState]) + static getStorageAddon(state: AddonsStateModel, id: string): AddonModel | undefined { + return state.storageAddons.data.find((addon: AddonModel) => addon.id === id); + } + /** + * Selector to retrieve the loading status of storage addons from the AddonsState. + * + * @param state The current state of the Addons feature. + * @returns A boolean indicating whether storage addons are currently being loaded. + */ @Selector([AddonsState]) static getStorageAddonsLoading(state: AddonsStateModel): boolean { return state.storageAddons.isLoading; } @Selector([AddonsState]) - static getCitationAddons(state: AddonsStateModel): Addon[] { + static getCitationAddons(state: AddonsStateModel): AddonModel[] { return state.citationAddons.data; } @@ -56,18 +92,36 @@ export class AddonsSelectors { return state.authorizedCitationAddons.isLoading; } + /** + * Selector to retrieve the list of configured storage addons from the NGXS state. + * + * @param state - The current state of the AddonsStateModel. + * @returns An array of configured storage addons. + * + * @example + * const addons = this.store.selectSnapshot(AddonsSelectors.getConfiguredStorageAddons); + */ @Selector([AddonsState]) - static getConfiguredStorageAddons(state: AddonsStateModel): ConfiguredAddon[] { + static getConfiguredStorageAddons(state: AddonsStateModel): ConfiguredStorageAddonModel[] { return state.configuredStorageAddons.data; } + /** + * Selector to determine whether the configured storage addons are currently being loaded. + * + * @param state - The current state of the AddonsStateModel. + * @returns A boolean indicating if the addons are loading. + * + * @example + * const isLoading = this.store.selectSnapshot(AddonsSelectors.getConfiguredStorageAddonsLoading); + */ @Selector([AddonsState]) static getConfiguredStorageAddonsLoading(state: AddonsStateModel): boolean { return state.configuredStorageAddons.isLoading; } @Selector([AddonsState]) - static getConfiguredCitationAddons(state: AddonsStateModel): ConfiguredAddon[] { + static getConfiguredCitationAddons(state: AddonsStateModel): ConfiguredStorageAddonModel[] { return state.configuredCitationAddons.data; } diff --git a/src/app/shared/stores/addons/addons.state.spec.ts b/src/app/shared/stores/addons/addons.state.spec.ts new file mode 100644 index 000000000..f491f52f8 --- /dev/null +++ b/src/app/shared/stores/addons/addons.state.spec.ts @@ -0,0 +1,183 @@ +import { NgxsModule, Store } from '@ngxs/store'; + +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { AddonsService } from '@osf/shared/services/addons/addons.service'; + +import { GetConfiguredStorageAddons, GetStorageAddons } from './addons.actions'; +import { AddonsSelectors } from './addons.selectors'; +import { AddonsState } from './addons.state'; + +import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; +import { getAddonsExternalStorageData } from '@testing/data/addons/addons.external-storage.data'; + +describe('State: Addons', () => { + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([AddonsState])], + providers: [provideHttpClient(), provideHttpClientTesting(), AddonsService], + }); + + store = TestBed.inject(Store); + }); + + describe('getStorageAddons', () => { + it('should fetch storage addons and update state and selector output', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let result: any[] = []; + store.dispatch(new GetStorageAddons()).subscribe(() => { + result = store.selectSnapshot(AddonsSelectors.getStorageAddons); + }); + + const loading = store.selectSignal(AddonsSelectors.getStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + const request = httpMock.expectOne('https://addons.staging4.osf.io/v1/external-storage-services'); + expect(request.request.method).toBe('GET'); + request.flush(getAddonsExternalStorageData()); + + expect(result[0]).toEqual( + Object({ + authUrl: 'https://figshare.com/account/applications/authorize', + credentialsFormat: 'OAUTH2', + displayName: 'figshare', + externalServiceName: 'figshare', + id: '1d8d9be2-522e-4969-b8fa-bfb45ae13c0d', + providerName: 'figshare', + supportedFeatures: ['DOWNLOAD_AS_ZIP', 'FORKING', 'LOGS', 'PERMISSIONS', 'REGISTERING'], + type: 'external-storage-services', + wbKey: 'figshare', + }) + ); + + const addon = store.selectSnapshot((state) => AddonsSelectors.getStorageAddon(state.addons, result[0].id)); + + expect(addon).toEqual( + Object({ + authUrl: 'https://figshare.com/account/applications/authorize', + credentialsFormat: 'OAUTH2', + displayName: 'figshare', + externalServiceName: 'figshare', + id: '1d8d9be2-522e-4969-b8fa-bfb45ae13c0d', + providerName: 'figshare', + supportedFeatures: ['DOWNLOAD_AS_ZIP', 'FORKING', 'LOGS', 'PERMISSIONS', 'REGISTERING'], + type: 'external-storage-services', + wbKey: 'figshare', + }) + ); + expect(loading()).toBeFalsy(); + } + )); + + it('should handle error if getAddons fails', inject([HttpTestingController], (httpMock: HttpTestingController) => { + let result: any = null; + + store.dispatch(new GetStorageAddons()).subscribe({ + next: () => { + result = 'Expected error, but got success'; + }, + error: () => { + result = store.snapshot().addons.storageAddons; + }, + }); + + const loading = store.selectSignal(AddonsSelectors.getStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + const req = httpMock.expectOne('https://addons.staging4.osf.io/v1/external-storage-services'); + expect(req.request.method).toBe('GET'); + + req.flush({ message: 'Internal Server Error' }, { status: 500, statusText: 'Server Error' }); + + expect(result).toEqual({ + data: [], + error: + 'Http failure response for https://addons.staging4.osf.io/v1/external-storage-services: 500 Server Error', + isLoading: false, + isSubmitting: false, + }); + + expect(loading()).toBeFalsy(); + })); + }); + + describe('getConfiguredStorageAddons', () => { + it('should fetch configured storage addons and update state and selector output', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let result: any[] = []; + store.dispatch(new GetConfiguredStorageAddons('reference-id')).subscribe(() => { + result = store.selectSnapshot(AddonsSelectors.getConfiguredStorageAddons); + }); + + const loading = store.selectSignal(AddonsSelectors.getConfiguredStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + const request = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/resource-references/reference-id/configured_storage_addons/' + ); + expect(request.request.method).toBe('GET'); + request.flush(getConfiguredAddonsData()); + + expect(result[0]).toEqual( + Object({ + baseAccountId: '62ed6dd7-f7b7-4003-b7b4-855789c1f991', + baseAccountType: 'authorized-storage-accounts', + connectedCapabilities: ['ACCESS', 'UPDATE'], + connectedOperationNames: ['list_child_items', 'list_root_items', 'get_item_info'], + currentUserIsOwner: true, + displayName: 'Google Drive', + externalServiceName: 'googledrive', + id: '756579dc-3a24-4849-8866-698a60846ac3', + selectedFolderId: '0AIl0aR4C9JAFUk9PVA', + type: 'configured-storage-addons', + externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + }) + ); + + expect(loading()).toBeFalsy(); + } + )); + + it('should handle error if getConfiguredStorageAddons fails', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let result: any = null; + + store.dispatch(new GetConfiguredStorageAddons('reference-id')).subscribe({ + next: () => { + result = 'Expected error, but got success'; + }, + error: () => { + result = store.snapshot().addons.configuredStorageAddons; + }, + }); + + const loading = store.selectSignal(AddonsSelectors.getConfiguredStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + const req = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/resource-references/reference-id/configured_storage_addons/' + ); + expect(req.request.method).toBe('GET'); + + req.flush({ message: 'Internal Server Error' }, { status: 500, statusText: 'Server Error' }); + + expect(result).toEqual({ + data: [], + error: + 'Http failure response for https://addons.staging4.osf.io/v1/resource-references/reference-id/configured_storage_addons/: 500 Server Error', + isLoading: false, + isSubmitting: false, + }); + + expect(loading()).toBeFalsy(); + } + )); + }); +}); diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index d6d614660..ed63340a4 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -96,14 +96,55 @@ const ADDONS_DEFAULTS: AddonsStateModel = { }, }; +/** + * NGXS state class for managing addon-related data and actions. + * + * Handles loading and storing both storage and citation addons as well as their configurations. + * This state includes logic for retrieving addons, patching loading states, handling errors, + * and providing selectors for access within the application. + * + * @see AddonsStateModel + * @see ADDONS_DEFAULTS + * @see addons.actions.ts + * @see addons.selectors.ts + */ @State({ name: 'addons', defaults: ADDONS_DEFAULTS, }) @Injectable() export class AddonsState { + /** + * Injected instance of {@link AddonsService}, used to interact with the addons API. + * + * Provides methods for retrieving and mapping addon configurations, including + * storage and citation addon types. + * + * @see AddonsService + */ addonsService = inject(AddonsService); + /** + * NGXS action handler for retrieving the list of storage addons. + * + * Dispatching this action sets the `storageAddons` slice of state into a loading state, + * then asynchronously fetches storage addon configurations from the `AddonsService`. + * + * On success: + * - The retrieved addon list is stored in `storageAddons.data`. + * - `isLoading` is set to `false` and `error` is cleared. + * + * On failure: + * - Invokes `handleError` to populate the `error` state and stop the loading flag. + * + * @param ctx - NGXS `StateContext` instance for `AddonsStateModel`. + * Used to read and mutate the application state. + * + * @returns An observable that completes once the addon data has been loaded or the error is handled. + * + * @example + * this.store.dispatch(new GetStorageAddons()); + */ @Action(GetStorageAddons) getStorageAddons(ctx: StateContext) { const state = ctx.getState(); @@ -200,6 +241,22 @@ export class AddonsState { ); } + /** + * Handles the NGXS action `GetConfiguredStorageAddons`. + * + * This method is responsible for retrieving a list of configured storage addons + * associated with a specific `referenceId` (e.g., a node or registration). + * + * It sets the loading state before initiating the request and patches the store + * with the resulting data or error upon completion. + * + * @param ctx - The NGXS `StateContext` used to read and mutate the `AddonsStateModel`. + * @param action - The dispatched `GetConfiguredStorageAddons` action, which contains the `referenceId` used to fetch data. + * @returns An `Observable` that emits when the addons are successfully fetched or an error is handled. + * + * @example + * store.dispatch(new GetConfiguredStorageAddons('abc123')); + */ @Action(GetConfiguredStorageAddons) getConfiguredStorageAddons(ctx: StateContext, action: GetConfiguredStorageAddons) { const state = ctx.getState(); @@ -555,6 +612,23 @@ export class AddonsState { }); } + /** + * Handles errors by patching the specified section of the state with error information + * and marking loading/submitting flags as false. + * + * This method is used in catchError operators within NGXS actions to ensure consistent + * error handling across all async state models. + * + * @param ctx - The NGXS StateContext instance for the AddonsStateModel. + * @param section - The specific section of the AddonsStateModel to update (e.g., 'storageAddons'). + * @param error - The error object caught during an observable operation. + * @returns An observable that rethrows the provided error. + * + * @example + * return this.addonsService.getAddons('storage').pipe( + * catchError(error => this.handleError(ctx, 'storageAddons', error)) + * ); + */ private handleError(ctx: StateContext, section: keyof AddonsStateModel, error: Error) { ctx.patchState({ [section]: { diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 0d4a2f14c..137aa67ec 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -1,19 +1,77 @@ +/** + * Production environment configuration for the OSF Angular application. + * + * These values are used at runtime to define base URLs, API endpoints, + * and third-party integrations. This configuration is typically replaced + * during the Angular build process depending on the target environment. + */ export const environment = { + /** + * Flag indicating whether the app is running in production mode. + */ production: false, + /** + * Base URL of the OSF web application. + */ webUrl: 'https://staging4.osf.io', + /** + * URL used for file downloads from OSF. + */ downloadUrl: 'https://staging4.osf.io/download', + /** + * Base URL for the OSF JSON:API v2 endpoints. + */ apiUrl: 'https://api.staging4.osf.io/v2', + /** + * Legacy v1 API endpoint used by some older services. + */ apiUrlV1: 'https://staging4.osf.io/api/v1', + /** + * Domain URL used for JSON:API v2 services. + */ apiDomainUrl: 'https://api.staging4.osf.io', + /** + * Base URL for SHARE discovery search (Trove). + */ shareDomainUrl: 'https://staging-share.osf.io/trove', + /** + * URL for the OSF Addons API (v1). + */ addonsApiUrl: 'https://addons.staging4.osf.io/v1', + /** + * URL for file-related operations on the US storage region. + */ fileApiUrl: 'https://files.us.staging4.osf.io/v1', + /** + * API endpoint for funder metadata resolution via Crossref. + */ funderApiUrl: 'https://api.crossref.org/', + /** + * Duplicate of `addonsApiUrl`, retained for backwards compatibility. + */ addonsV1Url: 'https://addons.staging4.osf.io/v1', + /** + * URL for OSF Central Authentication Service (CAS). + */ casUrl: 'https://accounts.staging4.osf.io', + /** + * Site key used for reCAPTCHA v2 validation in staging. + */ recaptchaSiteKey: '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI', + /** + * Twitter handle for OSF. + */ twitterHandle: 'OSFramework', + /** + * Facebook App ID used for social authentication or sharing. + */ facebookAppId: '1022273774556662', + /** + * Support contact email for users. + */ supportEmail: 'support@osf.io', + /** + * Default provider for OSF content and routing. + */ defaultProvider: 'osf', }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 1ebfd627a..0863de4a0 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,19 +1,77 @@ +/** + * Production environment configuration for the OSF Angular application. + * + * These values are used at runtime to define base URLs, API endpoints, + * and third-party integrations. This configuration is typically replaced + * during the Angular build process depending on the target environment. + */ export const environment = { + /** + * Flag indicating whether the app is running in production mode. + */ production: true, + /** + * Base URL of the OSF web application. + */ webUrl: 'https://staging4.osf.io', + /** + * URL used for file downloads from OSF. + */ downloadUrl: 'https://staging4.osf.io/download', + /** + * Base URL for the OSF JSON:API v2 endpoints. + */ apiUrl: 'https://api.staging4.osf.io/v2', + /** + * Legacy v1 API endpoint used by some older services. + */ apiUrlV1: 'https://staging4.osf.io/api/v1', + /** + * Domain URL used for JSON:API v2 services. + */ apiDomainUrl: 'https://api.staging4.osf.io', + /** + * Base URL for SHARE discovery search (Trove). + */ shareDomainUrl: 'https://staging-share.osf.io/trove', + /** + * URL for the OSF Addons API (v1). + */ addonsApiUrl: 'https://addons.staging4.osf.io/v1', + /** + * URL for file-related operations on the US storage region. + */ fileApiUrl: 'https://files.us.staging4.osf.io/v1', + /** + * API endpoint for funder metadata resolution via Crossref. + */ funderApiUrl: 'https://api.crossref.org/', + /** + * Duplicate of `addonsApiUrl`, retained for backwards compatibility. + */ addonsV1Url: 'https://addons.staging4.osf.io/v1', + /** + * URL for OSF Central Authentication Service (CAS). + */ casUrl: 'https://accounts.staging4.osf.io', + /** + * Site key used for reCAPTCHA v2 validation in staging. + */ recaptchaSiteKey: '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI', + /** + * Twitter handle for OSF. + */ twitterHandle: 'OSFramework', + /** + * Facebook App ID used for social authentication or sharing. + */ facebookAppId: '1022273774556662', + /** + * Support contact email for users. + */ supportEmail: 'support@osf.io', + /** + * Default provider for OSF content and routing. + */ defaultProvider: 'osf', }; diff --git a/src/testing/data/addons/addons.configured.data.ts b/src/testing/data/addons/addons.configured.data.ts new file mode 100644 index 000000000..2660351d4 --- /dev/null +++ b/src/testing/data/addons/addons.configured.data.ts @@ -0,0 +1,71 @@ +import structuredClone from 'structured-clone'; + +const ConfiguredAddons = { + data: [ + { + type: 'configured-storage-addons', + id: '756579dc-3a24-4849-8866-698a60846ac3', + attributes: { + display_name: 'Google Drive', + root_folder: '0AIl0aR4C9JAFUk9PVA', + connected_capabilities: ['ACCESS', 'UPDATE'], + connected_operation_names: ['list_child_items', 'list_root_items', 'get_item_info'], + current_user_is_owner: true, + external_service_name: 'googledrive', + }, + relationships: { + base_account: { + links: { + related: + 'https://addons.staging4.osf.io/v1/configured-storage-addons/756579dc-3a24-4849-8866-698a60846ac3/base_account', + }, + data: { + type: 'authorized-storage-accounts', + id: '62ed6dd7-f7b7-4003-b7b4-855789c1f991', + }, + }, + authorized_resource: { + links: { + related: + 'https://addons.staging4.osf.io/v1/configured-storage-addons/756579dc-3a24-4849-8866-698a60846ac3/authorized_resource', + }, + data: { + type: 'resource-references', + id: '3193f97c-e6d8-41a4-8312-b73483442086', + }, + }, + connected_operations: { + links: { + related: + 'https://addons.staging4.osf.io/v1/configured-storage-addons/756579dc-3a24-4849-8866-698a60846ac3/connected_operations', + }, + }, + external_storage_service: { + links: { + related: + 'https://addons.staging4.osf.io/v1/configured-storage-addons/756579dc-3a24-4849-8866-698a60846ac3/external_storage_service', + }, + data: { + type: 'external-storage-services', + id: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + }, + }, + }, + links: { + self: 'https://addons.staging4.osf.io/v1/configured-storage-addons/756579dc-3a24-4849-8866-698a60846ac3', + }, + }, + ], +}; + +export function getConfiguredAddonsData(index?: number, asArray?: boolean) { + if (index || index === 0) { + if (asArray) { + return structuredClone(ConfiguredAddons.data[index]); + } else { + return structuredClone(ConfiguredAddons.data[index]); + } + } else { + return structuredClone(ConfiguredAddons); + } +} diff --git a/tsconfig.docs.json b/tsconfig.docs.json index c000a08ca..0919464bb 100644 --- a/tsconfig.docs.json +++ b/tsconfig.docs.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["src/app/**/*.spec.ts", "*.md", "src/testing/**"] + "exclude": ["src/app/**/*.spec.ts", "*.md", "src/testing/**", "src/assets/**"] }