diff --git a/eslint.config.js b/eslint.config.js index 96045f6fa..03e6b13dc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -90,6 +90,7 @@ module.exports = tseslint.config( files: ['**/*.spec.ts'], rules: { '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-function': 'off', }, } ); diff --git a/package-lock.json b/package-lock.json index 30110ef3f..57eb6c3f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,8 @@ "@commitlint/cli": "^19.7.1", "@commitlint/config-conventional": "^19.7.1", "@compodoc/compodoc": "^1.1.26", + "@types/gapi": "^0.0.47", + "@types/gapi.auth2": "^0.0.61", "@types/jest": "^29.5.14", "@types/markdown-it": "^14.1.2", "angular-eslint": "19.1.0", @@ -7754,6 +7756,23 @@ "@types/send": "*" } }, + "node_modules/@types/gapi": { + "version": "0.0.47", + "resolved": "https://registry.npmjs.org/@types/gapi/-/gapi-0.0.47.tgz", + "integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gapi.auth2": { + "version": "0.0.61", + "resolved": "https://registry.npmjs.org/@types/gapi.auth2/-/gapi.auth2-0.0.61.tgz", + "integrity": "sha512-cn+omiRoE/LTxZncnVl1QhcLggOT0sJ8Yz9RXIsw5R2zLyRf+0o6kaZzJ/Gr3Sxz6i7J/+PbXAF8yeZipCaiWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/gapi": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", diff --git a/package.json b/package.json index 7458f30ba..0a09ca7a5 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,8 @@ "@commitlint/cli": "^19.7.1", "@commitlint/config-conventional": "^19.7.1", "@compodoc/compodoc": "^1.1.26", + "@types/gapi": "^0.0.47", + "@types/gapi.auth2": "^0.0.61", "@types/jest": "^29.5.14", "@types/markdown-it": "^14.1.2", "angular-eslint": "19.1.0", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts new file mode 100644 index 000000000..582a45a49 --- /dev/null +++ b/src/@types/global.d.ts @@ -0,0 +1,10 @@ +import type gapi from 'gapi-script'; // or just `import gapi from 'gapi-script';` + +declare global { + interface Window { + gapi: typeof gapi; + google: { + picker: typeof google.picker; + }; + } +} diff --git a/src/app/core/constants/environment.token.ts b/src/app/core/constants/environment.token.ts new file mode 100644 index 000000000..50b782688 --- /dev/null +++ b/src/app/core/constants/environment.token.ts @@ -0,0 +1,8 @@ +import { InjectionToken } from '@angular/core'; + +import { environment } from 'src/environments/environment'; + +export const ENVIRONMENT = new InjectionToken('App Environment', { + providedIn: 'root', + factory: () => environment, +}); diff --git a/src/app/features/project/addons/components/configure-addon/configure-addon.component.spec.ts b/src/app/features/project/addons/components/configure-addon/configure-addon.component.spec.ts index 9a092dff3..1b041160f 100644 --- a/src/app/features/project/addons/components/configure-addon/configure-addon.component.spec.ts +++ b/src/app/features/project/addons/components/configure-addon/configure-addon.component.spec.ts @@ -73,7 +73,7 @@ describe('Component: Configure Addon', () => { }); it('should validate the constuctor values', () => { - expect(component.storageAddon()).toBeUndefined(); + expect(component.storageAddon()).toBeNull(); expect(component.addon()).toEqual( Object({ attributes: { 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 0b378a0b6..20d622702 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 @@ -76,7 +76,7 @@ export class ConfigureAddonComponent implements OnInit { * Signal representing the currently selected `Addon` from the list of available storage addons. * This value updates reactively as the selection changes. */ - public storageAddon = signal(undefined); + public storageAddon = signal(null); /** * Signal representing the currently selected and configured storage addon model. * This may be `null` if no addon has been configured. @@ -128,9 +128,7 @@ export class ConfigureAddonComponent implements OnInit { if (addon) { this.storageAddon.set( - this.store.selectSnapshot((state) => - AddonsSelectors.getStorageAddon(state.addons, addon.externalStorageServiceId || '') - ) + this.store.selectSnapshot(AddonsSelectors.getStorageAddon(addon.externalStorageServiceId || '')) ); this.addon.set(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 d2660985c..3996f97e8 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 @@ -18,12 +18,13 @@ import { AddonConfigMap } from '@osf/features/project/addons/utils'; import { SubHeaderComponent } from '@osf/shared/components'; import { ProjectAddonsStepperValue } from '@osf/shared/enums'; import { getAddonTypeString } from '@osf/shared/helpers'; +import { AuthorizedAccountModel } from '@osf/shared/models/addons/authorized-account.model'; import { AddonSetupAccountFormComponent, AddonTermsComponent, FolderSelectorComponent, } from '@shared/components/addons'; -import { AddonModel, AddonTerm, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; +import { AddonModel, AddonTerm, AuthorizedAddonRequestJsonApi } from '@shared/models'; import { AddonDialogService, AddonFormService, AddonOperationInvocationService, ToastService } from '@shared/services'; import { AddonsSelectors, @@ -74,9 +75,9 @@ 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 currentAuthorizedAddonAccounts = signal([]); protected chosenAccountId = signal(''); protected chosenAccountName = signal(''); protected selectedRootFolderId = signal(''); @@ -114,7 +115,6 @@ export class ConnectConfiguredAddonComponent { protected resourceUri = computed(() => { const id = this.route.parent?.parent?.snapshot.params['id']; - return `${environment.webUrl}/${id}`; }); @@ -128,7 +128,7 @@ export class ConnectConfiguredAddonComponent { }); constructor() { - const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAddon; + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAccountModel; if (!addon) { this.router.navigate([`${this.baseUrl()}/addons`]); } @@ -243,7 +243,7 @@ export class ConnectConfiguredAddonComponent { private processAuthorizedAddons( addonConfig: AddonConfigMap[keyof AddonConfigMap], - currentAddon: AddonModel | AuthorizedAddon + currentAddon: AddonModel | AuthorizedAccountModel ) { const authorizedAddons = addonConfig.getAuthorizedAddons(); const matchingAddons = this.findMatchingAddons(authorizedAddons, currentAddon); @@ -261,9 +261,9 @@ export class ConnectConfiguredAddonComponent { } private findMatchingAddons( - authorizedAddons: AuthorizedAddon[], - currentAddon: AddonModel | AuthorizedAddon - ): AuthorizedAddon[] { + authorizedAddons: AuthorizedAccountModel[], + currentAddon: AddonModel | AuthorizedAccountModel + ): AuthorizedAccountModel[] { return authorizedAddons.filter((addon) => addon.externalServiceName === currentAddon.externalServiceName); } diff --git a/src/app/features/project/addons/models/addon-config-actions.model.ts b/src/app/features/project/addons/models/addon-config-actions.model.ts index 418532f89..59a4313e4 100644 --- a/src/app/features/project/addons/models/addon-config-actions.model.ts +++ b/src/app/features/project/addons/models/addon-config-actions.model.ts @@ -1,8 +1,8 @@ import { Observable } from 'rxjs'; -import { AuthorizedAddon } from '@shared/models'; +import { AuthorizedAccountModel } from '@shared/models'; export interface AddonConfigActions { getAddons: () => Observable; - getAuthorizedAddons: () => AuthorizedAddon[]; + getAuthorizedAddons: () => AuthorizedAccountModel[]; } diff --git a/src/app/features/settings/addons/addons.component.ts b/src/app/features/settings/addons/addons.component.ts index 4200fcc09..2b47a827b 100644 --- a/src/app/features/settings/addons/addons.component.ts +++ b/src/app/features/settings/addons/addons.component.ts @@ -140,12 +140,14 @@ export class AddonsComponent { } constructor() { + // TODO There should not be three effects effect(() => { if (this.currentUser()) { this.actions.getAddonsUserReference(); } }); + // TODO There should not be three effects effect(() => { if (this.currentUser() && this.userReferenceId()) { const action = this.currentAction(); @@ -157,6 +159,7 @@ export class AddonsComponent { } }); + // TODO There should not be three effects effect(() => { if (this.currentUser() && this.userReferenceId()) { this.fetchAllAuthorizedAddons(this.userReferenceId()); 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 b44467305..8d60665c1 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 { AddonModel, AddonTerm, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; +import { AddonModel, AddonTerm, AuthorizedAccountModel, 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 AddonModel | AuthorizedAddon; + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAccountModel; 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 b2510a3de..aaebeeaba 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 { AddonModel, AuthorizedAddon, ConfiguredStorageAddonModel } from '@shared/models'; +import { AddonModel, AuthorizedAccountModel, ConfiguredStorageAddonModel } from '@shared/models'; @Component({ selector: 'osf-addon-card-list', @@ -12,7 +12,7 @@ import { AddonModel, AuthorizedAddon, ConfiguredStorageAddonModel } from '@share styleUrl: './addon-card-list.component.scss', }) export class AddonCardListComponent { - cards = input<(AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel)[]>([]); + cards = input<(AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel)[]>([]); cardButtonLabel = input(''); showDangerButton = input(false); } 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 0ad70f6b9..eddb6a78f 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 { AddonModel, AuthorizedAddon, ConfiguredStorageAddonModel } from '@shared/models'; +import { AddonModel, AuthorizedAccountModel, 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 491e3d342..c0b2ea0a8 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 { AddonForm, AddonModel, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; +import { AddonForm, AddonModel, AuthorizedAccountModel, 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.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts index 7d3239894..3dca0178d 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 { AddonModel, AddonTerm, AuthorizedAddon } from '@shared/models'; +import { AddonModel, AddonTerm, AuthorizedAccountModel } from '@shared/models'; @Component({ selector: 'osf-addon-terms', @@ -16,7 +16,7 @@ import { AddonModel, 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: AddonModel | AuthorizedAddon): AddonTerm[] { + private getAddonTerms(addon: AddonModel | AuthorizedAccountModel): AddonTerm[] { const supportedFeatures = addon.supportedFeatures; const provider = addon.providerName; const isCitationService = isCitationAddon(addon); diff --git a/src/app/shared/components/addons/folder-selector/folder-selector.component.html b/src/app/shared/components/addons/folder-selector/folder-selector.component.html index 4aef513d5..86350ac4a 100644 --- a/src/app/shared/components/addons/folder-selector/folder-selector.component.html +++ b/src/app/shared/components/addons/folder-selector/folder-selector.component.html @@ -34,7 +34,7 @@

@if (isOperationInvocationSubmitting()) { } @else if (isGoogleFilePicker()) { - + } @else {
diff --git a/src/app/shared/components/addons/folder-selector/folder-selector.component.ts b/src/app/shared/components/addons/folder-selector/folder-selector.component.ts index ae4b61947..92d2f6896 100644 --- a/src/app/shared/components/addons/folder-selector/folder-selector.component.ts +++ b/src/app/shared/components/addons/folder-selector/folder-selector.component.ts @@ -53,11 +53,13 @@ import { GoogleFilePickerComponent } from './google-file-picker/google-file-pick export class FolderSelectorComponent implements OnInit { private destroyRef = inject(DestroyRef); private translateService = inject(TranslateService); + isGoogleFilePicker = input.required(); accountName = input.required(); operationInvocationResult = input.required(); accountNameControl = input(new FormControl()); isCreateMode = input(false); + selectedRootFolderId = model('/'); operationInvoke = output(); save = output(); diff --git a/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.html b/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.html index 725aa2a44..83de74b87 100644 --- a/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.html +++ b/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.html @@ -1,17 +1,13 @@ -
Hello World again
- - +
+ @if (this.isFolderPicker()) { +
+ +
+ } +
diff --git a/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.spec.ts b/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.spec.ts index de34fea92..6a2f01652 100644 --- a/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.spec.ts +++ b/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.spec.ts @@ -1,25 +1,201 @@ +import { Store } from '@ngxs/store'; + +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { GoogleFilePickerDownloadService } from './service/google-file-picker.download.service'; import { GoogleFilePickerComponent } from './google-file-picker.component'; -import { OSFTestingStoreModule } from '@testing/osf.testing.module'; +import { OSFTestingModule, OSFTestingStoreModule } from '@testing/osf.testing.module'; -describe('Component: Configure Addon', () => { +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)), + }; + + const setDeveloperKey = jest.fn().mockReturnThis(); + const setAppId = jest.fn().mockReturnThis(); + const addView = jest.fn().mockReturnThis(); + const setTitle = jest.fn().mockReturnThis(); + const setOAuthToken = jest.fn().mockReturnThis(); + const setCallback = jest.fn().mockReturnThis(); + const enableFeature = jest.fn().mockReturnThis(); + const setVisible = jest.fn(); + const build = jest.fn().mockReturnValue({ + setVisible, + }); + + const setSelectFolderEnabled = jest.fn(); + const setMimeTypes = jest.fn(); + const setIncludeFolders = jest.fn(); + const setParent = jest.fn(); + + const storeMock = { + dispatch: jest.fn().mockReturnValue(of({})), + selectSnapshot: jest.fn().mockReturnValue('mock-token'), + }; + + describe('isFolderPicker - true', () => { + beforeEach(async () => { + jest.clearAllMocks(); + + (window as any).google = { + picker: { + ViewId: { + DOCS: 'docs', + }, + DocsView: jest.fn().mockImplementation(() => ({ + setSelectFolderEnabled, + setMimeTypes, + setIncludeFolders, + setParent, + })), + PickerBuilder: jest.fn().mockImplementation(() => ({ + setDeveloperKey, + setAppId, + addView, + setTitle, + setOAuthToken, + setCallback, + enableFeature, + build, + })), + Feature: { + MULTISELECT_ENABLED: 'multiselect', + }, + }, + }; + + await TestBed.configureTestingModule({ + imports: [OSFTestingModule, GoogleFilePickerComponent], + providers: [ + { provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy }, + { + provide: Store, + useValue: storeMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(GoogleFilePickerComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('isFolderPicker', true); + fixture.componentRef.setInput('rootFolderId', 'root-folder-id'); + fixture.componentRef.setInput('selectedFolderName', 'selected-folder-name'); + fixture.componentRef.setInput('accountId', 'account-id'); + fixture.detectChanges(); + }); - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [OSFTestingStoreModule, GoogleFilePickerComponent], - providers: [], - }).compileComponents(); + it('should load script and then GAPI modules and initialize picker', () => { + expect(googlePickerServiceSpy.loadScript).toHaveBeenCalled(); + expect(googlePickerServiceSpy.loadGapiModules).toHaveBeenCalled(); - fixture = TestBed.createComponent(GoogleFilePickerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + expect(component.visible()).toBeTruthy(); + expect(component.isGFPDisabled()).toBeFalsy(); + }); + + it('should build the picker with correct configuration', () => { + component.createPicker(); + + expect(window.google.picker.DocsView).toHaveBeenCalledWith('docs'); + expect(setSelectFolderEnabled).toHaveBeenCalledWith(true); + expect(setMimeTypes).toHaveBeenCalledWith('application/vnd.google-apps.folder'); + expect(setIncludeFolders).toHaveBeenCalledWith(true); + expect(setParent).toHaveBeenCalledWith(''); + + expect(window.google.picker.PickerBuilder).toHaveBeenCalledWith(); + expect(setDeveloperKey).toHaveBeenCalledWith('test-api-key'); + expect(setAppId).toHaveBeenCalledWith('test-app-id'); + expect(addView).toHaveBeenCalled(); + expect(setTitle).toHaveBeenCalledWith('settings.addons.configureAddon.google-file-picker.root-folder-title'); + expect(setOAuthToken).toHaveBeenCalledWith('mock-token'); + expect(setCallback).toHaveBeenCalled(); + expect(enableFeature).not.toHaveBeenCalled(); + expect(build).toHaveBeenCalledWith(); + expect(setVisible).toHaveBeenCalledWith(true); + }); }); - it('should validate the component', () => { - expect(component).toBeTruthy(); + describe('isFolderPicker - false', () => { + beforeEach(async () => { + jest.clearAllMocks(); + (window as any).google = { + picker: { + ViewId: { + DOCS: 'docs', + }, + DocsView: jest.fn().mockImplementation(() => ({ + setSelectFolderEnabled, + setMimeTypes, + setIncludeFolders, + setParent, + })), + PickerBuilder: jest.fn().mockImplementation(() => ({ + setDeveloperKey, + setAppId, + addView, + setTitle, + setOAuthToken, + setCallback, + enableFeature, + build, + })), + Feature: { + MULTISELECT_ENABLED: 'multiselect', + }, + }, + }; + + await TestBed.configureTestingModule({ + imports: [OSFTestingStoreModule, GoogleFilePickerComponent], + providers: [ + { provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy }, + { + provide: Store, + useValue: storeMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(GoogleFilePickerComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('isFolderPicker', false); + fixture.componentRef.setInput('rootFolderId', 'root-folder-id'); + fixture.componentRef.setInput('selectedFolderName', 'selected-folder-name'); + fixture.detectChanges(); + }); + + it('should load script and then GAPI modules and initialize picker', () => { + expect(googlePickerServiceSpy.loadScript).toHaveBeenCalled(); + expect(googlePickerServiceSpy.loadGapiModules).toHaveBeenCalled(); + + expect(component.visible()).toBeFalsy(); + expect(component.isGFPDisabled()).toBeTruthy(); + }); + + it('should build the picker with correct configuration', () => { + component.createPicker(); + + expect(window.google.picker.DocsView).toHaveBeenCalledWith('docs'); + expect(setSelectFolderEnabled).toHaveBeenCalledWith(true); + expect(setMimeTypes).not.toHaveBeenCalled(); + expect(setIncludeFolders).toHaveBeenCalledWith(true); + expect(setParent).toHaveBeenCalledWith('root-folder-id'); + + expect(window.google.picker.PickerBuilder).toHaveBeenCalledWith(); + expect(setDeveloperKey).toHaveBeenCalledWith('test-api-key'); + expect(setAppId).toHaveBeenCalledWith('test-app-id'); + expect(addView).toHaveBeenCalled(); + expect(setTitle).toHaveBeenCalledWith('settings.addons.configureAddon.google-file-picker.file-folder-title'); + expect(setOAuthToken).toHaveBeenCalledWith(null); + expect(setCallback).toHaveBeenCalled(); + expect(enableFeature).toHaveBeenCalledWith('multiselect'); + expect(build).toHaveBeenCalledWith(); + expect(setVisible).toHaveBeenCalledWith(true); + }); }); }); diff --git a/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.ts b/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.ts index d95f59c4e..7f27a2184 100644 --- a/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.ts +++ b/src/app/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component.ts @@ -1,140 +1,137 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Store } from '@ngxs/store'; + +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; + +import { ChangeDetectionStrategy, Component, inject, input, OnInit, signal } from '@angular/core'; + +import { ENVIRONMENT } from '@core/constants/environment.token'; +import { AddonsSelectors, GetAuthorizedStorageOauthToken } from '@osf/shared/stores'; + +import { GoogleFilePickerDownloadService } from './service/google-file-picker.download.service'; @Component({ selector: 'osf-google-file-picker', - imports: [], + imports: [TranslateModule, Button], templateUrl: './google-file-picker.component.html', styleUrl: './google-file-picker.component.scss', providers: [], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class GoogleFilePickerComponent {} - -// import Store from '@ember-data/store'; -// import { action } from '@ember/object'; -// import { waitFor } from '@ember/test-waiters'; -// import Component from '@glimmer/component'; -// import { tracked } from '@glimmer/tracking'; -// import { task } from 'ember-concurrency'; -// import { taskFor } from 'ember-concurrency-ts'; -// import config from 'ember-osf-web/config/environment'; -// import { Item } from 'ember-osf-web/models/addon-operation-invocation'; -// import StorageManager from 'osf-components/components/storage-provider-manager/storage-manager/component'; -// import { inject as service } from '@ember/service'; -// import Intl from 'ember-intl/services/intl'; - -// const { -// GOOGLE_FILE_PICKER_SCOPES, -// GOOGLE_FILE_PICKER_API_KEY, -// GOOGLE_FILE_PICKER_APP_ID, -// } = config.OSF.googleFilePicker; - -// // -// // πŸ“š Interface for Expected Arguments -// // -// interface Args { -// /** -// * selectFolder -// * -// * @description -// * A callback function passed into the component -// * that accepts a partial Item object and handles it (e.g., selects a file). -// */ -// selectFolder?: (a: Partial) => void; -// onRegisterChild?: (a: GoogleFilePickerWidget) => void; -// selectedFolderName?: string; -// isFolderPicker: boolean; -// rootFolderId: string; -// manager: StorageManager; -// accountId: string; -// } - -// // -// // πŸ“š Extend Global Window Type -// // -// // Declares that `window` can optionally have a GoogleFilePickerWidget instance. -// // This allows safe typing when accessing it elsewhere. -// // -// declare global { -// interface Window { -// GoogleFilePickerWidget?: GoogleFilePickerWidget; -// gapi?: any; -// google?: any; -// } -// } - -// // -// // GoogleFilePickerWidget Component -// // -// // @description -// // An Ember Glimmer component that exposes itself to the global `window` -// // so that external JavaScript (like Google Picker API callbacks) -// // can interact with it directly. -// // -// export default class GoogleFilePickerWidget extends Component { -// @service intl!: Intl; -// @service store!: Store; -// @tracked folderName!: string | undefined; -// @tracked isFolderPicker = false; -// @tracked openGoogleFilePicker = false; -// @tracked visible = false; -// @tracked isGFPDisabled = true; -// pickerInited = false; -// selectFolder: any = undefined; -// accessToken!: string; -// scopes = GOOGLE_FILE_PICKER_SCOPES; -// apiKey = GOOGLE_FILE_PICKER_API_KEY; -// appId = GOOGLE_FILE_PICKER_APP_ID; -// mimeTypes = ''; -// parentId = ''; -// isMultipleSelect: boolean; -// title!: string; - -// /** -// * Constructor -// * -// * @description -// * Initializes the GoogleFilePickerWidget component and exposes its key methods to the global `window` object -// * for integration with external JavaScript (e.g., Google Picker API). -// * -// * - Sets `window.GoogleFilePickerWidget` to the current component instance (`this`), -// * allowing external scripts to call methods like `filePickerCallback()`. -// * - Captures the closure action `selectFolder` from `this.args` and assigns it directly to `window.selectFolder`, -// * preserving the correct closure reference even outside of Ember's internal context. -// * -// * @param owner - The owner/context passed by Ember at component instantiation. -// * @param args - The arguments passed to the component, including closure actions like `selectFolder`. -// */ -// constructor(owner: unknown, args: Args) { -// super(owner, args); - -// window.GoogleFilePickerWidget = this; -// this.selectFolder = this.args.selectFolder; -// this.mimeTypes = this.args.isFolderPicker ? 'application/vnd.google-apps.folder' : ''; -// this.parentId = this.args.isFolderPicker ? '': this.args.rootFolderId; -// this.title = this.args.isFolderPicker ? -// this.intl.t('addons.configure.google-file-picker.root-folder-title') : -// this.intl.t('addons.configure.google-file-picker.file-folder-title'); -// this.isMultipleSelect = !this.args.isFolderPicker; -// this.isFolderPicker = this.args.isFolderPicker; - -// this.folderName = this.args.selectedFolderName; - -// taskFor(this.loadOauthToken).perform(); -// } - -// @task -// @waitFor -// private async loadOauthToken(): Promise{ -// if (this.args.accountId) { -// const authorizedStorageAccount = await this.store. -// findRecord('authorized-storage-account', this.args.accountId); -// authorizedStorageAccount.serializeOauthToken = true; -// const token = await authorizedStorageAccount.save(); -// this.accessToken = token.oauthToken; -// this.isGFPDisabled = this.accessToken ? false : true; -// } -// } +export class GoogleFilePickerComponent implements OnInit { + readonly #translateService = inject(TranslateService); + readonly #googlePicker = inject(GoogleFilePickerDownloadService); + readonly #environment = inject(ENVIRONMENT); + + public isFolderPicker = input.required(); + public selectedFolderName = input(''); + public rootFolderId = input(''); + public accountId = input(''); + + // selectFolder?: (a: Partial) => void; + // onRegisterChild?: (a: GoogleFilePickerWidget) => void; + // manager: StorageManager; + // @tracked openGoogleFilePicker = false; + private folderName = signal(''); + selectFolder = undefined; + 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; + private readonly store = inject(Store); + private parentId = ''; + private isMultipleSelect!: boolean; + private title!: string; + + ngOnInit(): void { + // window.GoogleFilePickerWidget = this; + // this.selectFolder = this.selectFolder(); + this.parentId = this.isFolderPicker() ? '' : this.rootFolderId(); + this.title = this.isFolderPicker() + ? this.#translateService.instant('settings.addons.configureAddon.google-file-picker.root-folder-title') + : this.#translateService.instant('settings.addons.configureAddon.google-file-picker.file-folder-title'); + this.isMultipleSelect = !this.isFolderPicker(); + this.folderName.set(this.selectedFolderName()); + + this.#googlePicker.loadScript().subscribe({ + next: () => { + this.#googlePicker.loadGapiModules().subscribe({ + next: () => { + this.initializePicker(); + this.#loadOauthToken(); + }, + // TODO add this error when the Sentry service is working + //error: (err) => console.error('GAPI modules failed:', err), + }); + }, + // TODO add this error when the Sentry service is working + // error: (err) => console.error('Script load failed:', err), + }); + } + + public initializePicker() { + if (this.isFolderPicker()) { + this.visible.set(true); + } + } + + createPicker(): void { + const google = window.google; + + const googlePickerView = new google.picker.DocsView(google.picker.ViewId.DOCS); + googlePickerView.setSelectFolderEnabled(true); + if (this.isFolderPicker()) { + googlePickerView.setMimeTypes('application/vnd.google-apps.folder'); + } + googlePickerView.setIncludeFolders(true); + googlePickerView.setParent(this.parentId); + + const pickerBuilder = new google.picker.PickerBuilder() + .setDeveloperKey(this.apiKey) + .setAppId(this.appId) + .addView(googlePickerView) + .setTitle(this.title) + .setOAuthToken(this.accessToken()) + .setCallback(this.pickerCallback.bind(this)); + + if (this.isMultipleSelect) { + pickerBuilder.enableFeature(google.picker.Feature.MULTISELECT_ENABLED); + } + + const picker = pickerBuilder.build(); + picker.setVisible(true); + } + + #loadOauthToken(): void { + if (this.accountId()) { + this.store.dispatch(new GetAuthorizedStorageOauthToken(this.accountId())).subscribe({ + next: () => { + this.accessToken.set( + this.store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(this.accountId())) + ); + this.isGFPDisabled.set(this.accessToken() ? false : true); + }, + }); + } + } + + // /** + // * Displays the file details of the user's selection. + // * @param {object} data - Containers the user selection from the picker + // */ + // eslint-disable-next-line + async pickerCallback(data: any) { + // async pickerCallback(data: any) { + // if (data.action === window.google.picker.Action.PICKED) { + // this.filePickerCallback(data.docs[0]); + // } + // } + console.log('data'); + } +} // /** // * filePickerCallback @@ -170,47 +167,6 @@ export class GoogleFilePickerComponent {} // this.pickerInited = false; // } -// /** -// * Callback after api.js is loaded. -// */ -// gapiLoaded() { -// window.gapi.load('client:picker', this.initializePicker.bind(this)); -// } - -// /** -// * Callback after the API client is loaded. Loads the -// * discovery doc to initialize the API. -// */ -// async initializePicker() { -// this.pickerInited = true; -// if (this.isFolderPicker) { -// this.visible = true; -// } -// } - -// /** -// * Create and render a Picker object for searching images. -// */ -// @action -// createPicker() { -// const googlePickerView = new window.google.picker.DocsView(window.google.picker.ViewId.DOCS); -// googlePickerView.setSelectFolderEnabled(true); -// googlePickerView.setMimeTypes(this.mimeTypes); -// googlePickerView.setIncludeFolders(true); -// googlePickerView.setParent(this.parentId); - -// const picker = new window.google.picker.PickerBuilder() -// .enableFeature(this.isMultipleSelect ? window.google.picker.Feature.MULTISELECT_ENABLED : '') -// .setDeveloperKey(this.apiKey) -// .setAppId(this.appId) -// .addView(googlePickerView) -// .setTitle(this.title) -// .setOAuthToken(this.accessToken) -// .setCallback(this.pickerCallback.bind(this)) -// .build(); -// picker.setVisible(true); -// } - // /** // * Displays the file details of the user's selection. // * @param {object} data - Containers the user selection from the picker diff --git a/src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.spec.ts b/src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.spec.ts new file mode 100644 index 000000000..9ba6fc0d1 --- /dev/null +++ b/src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.spec.ts @@ -0,0 +1,150 @@ +import { DOCUMENT } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; + +import { GoogleFilePickerDownloadService } from './google-file-picker.download.service'; + +describe('Service: Google File Picker Download', () => { + let service: GoogleFilePickerDownloadService; + let mockDocument: Document; + let mockScriptElement: any; + + beforeEach(() => { + mockScriptElement = { + set src(url) { + this._src = url; + }, + get src() { + return this._src; + }, + onload: jest.fn(), + onerror: jest.fn(), + }; + + mockDocument = { + createElement: jest.fn(() => mockScriptElement), + body: { + appendChild: jest.fn((node: Node) => node), + } as any, + querySelector: jest.fn(), + } as any; + + TestBed.configureTestingModule({ + providers: [GoogleFilePickerDownloadService, { provide: DOCUMENT, useValue: mockDocument }], + }); + + service = TestBed.inject(GoogleFilePickerDownloadService); + }); + + it('should load the script and complete the observable', (done) => { + const observable = service.loadScript(); + + observable.subscribe({ + next: () => { + expect(mockDocument.createElement).toHaveBeenCalledWith('script'); + expect(mockScriptElement.src).toBe('https://apis.google.com/js/api.js'); + expect(mockScriptElement.async).toBeTruthy(); + expect(mockScriptElement.defer).toBeTruthy(); + expect(mockDocument.body.appendChild).toHaveBeenCalledWith(mockScriptElement); + }, + complete: () => { + expect(true).toBe(true); + done(); + }, + error: () => { + fail('Should not call error on script load success'); + }, + }); + + mockScriptElement.onload(); + }); + + it('should emit error when script fails to load', (done) => { + const mockScriptElement: Partial = {}; + + // Mock document + const mockDocument = { + createElement: jest.fn(() => mockScriptElement), + body: { + appendChild: jest.fn(() => { + // Simulate async error after appendChild + setTimeout(() => { + mockScriptElement.onerror?.(new Event('error')); + }, 0); + }), + }, + querySelector: jest.fn(() => null), + }; + + // Re-instantiate service with mocked document + const service = new GoogleFilePickerDownloadService(mockDocument as unknown as Document); + + service.loadScript().subscribe({ + next: () => fail('Should not emit next on error'), + error: (err) => { + expect(err).toBe('Failed to load Google Picker script'); + done(); + }, + }); + }); + + describe('loadGapiModules', () => { + beforeEach(() => { + // Mock window.gapi + (globalThis as any).gapi = { + load: jest.fn(), + }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should complete when GAPI loads successfully', (done) => { + service.loadGapiModules().subscribe({ + next: () => {}, + complete: () => { + expect(globalThis.gapi.load).toHaveBeenCalledWith( + 'client:picker', + expect.objectContaining({ + callback: expect.any(Function), + onerror: expect.any(Function), + timeout: 5000, + ontimeout: expect.any(Function), + }) + ); + done(); + }, + error: () => fail('Should not error'), + }); + + const config = (globalThis.gapi.load as jest.Mock).mock.calls[0][1]; + config.callback(); // simulate success + }); + + it('should emit error when GAPI fails to load', (done) => { + service.loadGapiModules().subscribe({ + next: () => fail('Should not emit next'), + error: (err) => { + expect(err).toBe('Failed to load GAPI modules'); + done(); + }, + }); + + const config = (globalThis.gapi.load as jest.Mock).mock.calls[0][1]; + config.onerror(); // simulate failure + }); + + it('should emit error on GAPI timeout', (done) => { + service.loadGapiModules().subscribe({ + next: () => fail('Should not emit next'), + error: (err) => { + expect(err).toBe('GAPI load timeout'); + done(); + }, + }); + + const config = (globalThis.gapi.load as jest.Mock).mock.calls[0][1]; + config.ontimeout(); // simulate timeout + }); + }); +}); diff --git a/src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.ts b/src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.ts new file mode 100644 index 000000000..76be19951 --- /dev/null +++ b/src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.ts @@ -0,0 +1,73 @@ +import { Observable, Subscriber } from 'rxjs'; + +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable } from '@angular/core'; + +/** + * Injectable service to load the Google Picker API script dynamically. + * + * This service ensures the script is only loaded once and provides an observable + * to notify subscribers when loading is complete or fails. + */ +@Injectable({ providedIn: 'root' }) +export class GoogleFilePickerDownloadService { + /** Tracks whether the script has already been loaded to prevent duplicates. */ + private scriptLoaded = false; + /** The Google Picker API script URL. */ + private scriptUrl = 'https://apis.google.com/js/api.js'; + + /** + * Constructor injecting the global `Document` object. + * + * @param document - The Angular-injected reference to the global `document`. + */ + constructor(@Inject(DOCUMENT) private document: Document) {} + + /** + * Dynamically loads the Google Picker script if it hasn't already been loaded. + * + * Returns an Observable that completes when the script is successfully loaded. + * Emits an error if the script fails to load. + * + * @returns Observable that emits once the script is loaded, or errors if loading fails. + */ + public loadScript(): Observable { + return new Observable((observer: Subscriber) => { + const existingScript = this.document.querySelector(`script[src="${this.scriptUrl}"]`); + if (existingScript || this.scriptLoaded) { + observer.next(); + observer.complete(); + return; + } + + const script = this.document.createElement('script'); + script.src = this.scriptUrl; + script.async = true; + script.defer = true; + script.onload = () => { + this.scriptLoaded = true; + observer.next(); + observer.complete(); + }; + script.onerror = () => observer.error('Failed to load Google Picker script'); + this.document.body.appendChild(script); + }); + } + + /** + * Loads GAPI modules (client:picker). + */ + public loadGapiModules(): Observable { + return new Observable((observer: Subscriber) => { + window.gapi.load('client:picker', { + callback: () => { + observer.next(); + observer.complete(); + }, + onerror: () => observer.error('Failed to load GAPI modules'), + timeout: 5000, + ontimeout: () => observer.error('GAPI load timeout'), + }); + }); + } +} diff --git a/src/app/shared/helpers/addon-type.helper.ts b/src/app/shared/helpers/addon-type.helper.ts index 9a1ec71dc..9374eb6bf 100644 --- a/src/app/shared/helpers/addon-type.helper.ts +++ b/src/app/shared/helpers/addon-type.helper.ts @@ -1,6 +1,8 @@ -import { AddonModel, AuthorizedAddon, ConfiguredStorageAddonModel } from '@shared/models'; +import { AddonModel, AuthorizedAccountModel, ConfiguredStorageAddonModel } from '@shared/models'; -export function isStorageAddon(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): boolean { +export function isStorageAddon( + addon: AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel | null +): boolean { if (!addon) return false; return ( @@ -10,7 +12,9 @@ export function isStorageAddon(addon: AddonModel | AuthorizedAddon | ConfiguredS ); } -export function isCitationAddon(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): boolean { +export function isCitationAddon( + addon: AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel | null +): boolean { if (!addon) return false; return ( @@ -20,19 +24,25 @@ export function isCitationAddon(addon: AddonModel | AuthorizedAddon | Configured ); } -export function getAddonTypeString(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): string { +export function getAddonTypeString( + addon: AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel | null +): string { if (!addon) return ''; return isStorageAddon(addon) ? 'storage' : 'citation'; } -export function isAuthorizedAddon(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): boolean { +export function isAuthorizedAddon( + addon: AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel | null +): boolean { if (!addon) return false; return addon.type === 'authorized-storage-accounts' || addon.type === 'authorized-citation-accounts'; } -export function isConfiguredAddon(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): boolean { +export function isConfiguredAddon( + addon: AddonModel | AuthorizedAccountModel | 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 4758dbaac..2c3f5f96e 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -1,7 +1,7 @@ import { AddonGetResponseJsonApi, AddonModel, - AuthorizedAddon, + AuthorizedAccountModel, AuthorizedAddonGetResponseJsonApi, ConfiguredAddonGetResponseJsonApi, ConfiguredStorageAddonModel, @@ -29,7 +29,7 @@ export class AddonMapper { static fromAuthorizedAddonResponse( response: AuthorizedAddonGetResponseJsonApi, included?: IncludedAddonData[] - ): AuthorizedAddon { + ): AuthorizedAccountModel { const externalServiceData = response.relationships?.external_storage_service?.data || response.relationships?.external_citation_service?.data; @@ -56,6 +56,7 @@ export class AddonMapper { authorizedOperationNames: response.attributes.authorized_operation_names, defaultRootFolder: response.attributes.default_root_folder, credentialsAvailable: response.attributes.credentials_available, + oauthToken: response.attributes.oauth_token, accountOwnerId: response.relationships.account_owner.data.id, externalStorageServiceId: externalServiceId || '', externalServiceName, @@ -76,7 +77,6 @@ export class AddonMapper { * * @example * const addon = AddonMapper.fromConfiguredAddonResponse(apiResponse); - * console.log(addon.displayName); // "Google Drive" */ static fromConfiguredAddonResponse(response: ConfiguredAddonGetResponseJsonApi): ConfiguredStorageAddonModel { return { diff --git a/src/app/shared/models/addons/addons.models.ts b/src/app/shared/models/addons/addons.models.ts index acb5099d2..acd5d0a57 100644 --- a/src/app/shared/models/addons/addons.models.ts +++ b/src/app/shared/models/addons/addons.models.ts @@ -79,6 +79,7 @@ export interface AuthorizedAddonGetResponseJsonApi { authorized_operation_names: string[]; default_root_folder: string; credentials_available: boolean; + oauth_token: string; }; relationships: { account_owner: { @@ -227,24 +228,6 @@ export interface AddonModel { wbKey: string; } -export interface AuthorizedAddon { - type: string; - id: string; - displayName: string; - apiBaseUrl: string; - authUrl: string | null; - authorizedCapabilities: string[]; - authorizedOperationNames: string[]; - defaultRootFolder: string; - credentialsAvailable: boolean; - accountOwnerId: string; - externalStorageServiceId: string; - externalServiceName: string; - supportedFeatures: string[]; - providerName: string; - credentialsFormat: string; -} - export interface IncludedAddonData { type: string; id: string; diff --git a/src/app/shared/models/addons/authorized-account.model.ts b/src/app/shared/models/addons/authorized-account.model.ts new file mode 100644 index 000000000..f517c3f81 --- /dev/null +++ b/src/app/shared/models/addons/authorized-account.model.ts @@ -0,0 +1,18 @@ +export interface AuthorizedAccountModel { + type: string; + id: string; + displayName: string; + apiBaseUrl: string; + authUrl: string | null; + authorizedCapabilities: string[]; + authorizedOperationNames: string[]; + defaultRootFolder: string; + credentialsAvailable: boolean; + oauthToken: string; + accountOwnerId: string; + externalStorageServiceId: string; + externalServiceName: string; + supportedFeatures: string[]; + providerName: string; + credentialsFormat: string; +} diff --git a/src/app/shared/models/addons/index.ts b/src/app/shared/models/addons/index.ts index e2edd3d77..9c22ecdad 100644 --- a/src/app/shared/models/addons/index.ts +++ b/src/app/shared/models/addons/index.ts @@ -1,6 +1,7 @@ export * from './addon-form.model'; export * from './addon-terms.model'; export * from './addons.models'; +export * from './authorized-account.model'; export * from './configured-storage-addon.model'; export * from './operation-invocation.models'; export * from './operation-invoke-data.model'; diff --git a/src/app/shared/services/addons/addon-dialog.service.ts b/src/app/shared/services/addons/addon-dialog.service.ts index 9368e475d..c49fa3831 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, ConfiguredStorageAddonModel } from '@shared/models'; +import { AuthorizedAccountModel, ConfiguredStorageAddonModel } from '@shared/models'; @Injectable({ providedIn: 'root', @@ -35,7 +35,7 @@ export class AddonDialogService { return dialogRef.onClose; } - openConfirmAccountConnectionDialog(selectedAccount: AuthorizedAddon): Observable<{ success: boolean }> { + openConfirmAccountConnectionDialog(selectedAccount: AuthorizedAccountModel): Observable<{ success: boolean }> { const dialogRef = this.dialogService.open(ConfirmAccountConnectionModalComponent, { focusOnShow: false, header: this.translateService.instant('settings.addons.connectAddon.confirmAccount'), diff --git a/src/app/shared/services/addons/addon-form.service.ts b/src/app/shared/services/addons/addon-form.service.ts index 275a033a4..365aba3db 100644 --- a/src/app/shared/services/addons/addon-form.service.ts +++ b/src/app/shared/services/addons/addon-form.service.ts @@ -6,7 +6,7 @@ import { AddonFormControls, CredentialsFormat } from '@shared/enums'; import { AddonForm, AddonModel, - AuthorizedAddon, + AuthorizedAccountModel, AuthorizedAddonRequestJsonApi, ConfiguredAddonRequestJsonApi, ConfiguredStorageAddonModel, @@ -18,7 +18,7 @@ import { export class AddonFormService { protected formBuilder: FormBuilder = inject(FormBuilder); - initializeForm(addon: AddonModel | AuthorizedAddon): FormGroup { + initializeForm(addon: AddonModel | AuthorizedAccountModel): FormGroup { if (!addon) { return new FormGroup({} as AddonForm); } @@ -51,7 +51,7 @@ export class AddonFormService { generateAuthorizedAddonPayload( formValue: Record, - addon: AddonModel | AuthorizedAddon, + addon: AddonModel | AuthorizedAccountModel, userReferenceId: string, addonTypeString: string ): AuthorizedAddonRequestJsonApi { @@ -107,13 +107,15 @@ export class AddonFormService { }; } - private getAddonServiceId(addon: AddonModel | AuthorizedAddon): string { - return isAuthorizedAddon(addon) ? (addon as AuthorizedAddon).externalStorageServiceId : (addon as AddonModel).id; + private getAddonServiceId(addon: AddonModel | AuthorizedAccountModel): string { + return isAuthorizedAddon(addon) + ? (addon as AuthorizedAccountModel).externalStorageServiceId + : (addon as AddonModel).id; } generateConfiguredAddonCreatePayload( - addon: AddonModel | AuthorizedAddon, - selectedAccount: AuthorizedAddon, + addon: AddonModel | AuthorizedAccountModel, + selectedAccount: AuthorizedAccountModel, 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 163e0449a..f468b07dc 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, ConfiguredStorageAddonModel, OperationInvocationRequestJsonApi } from '@shared/models'; +import { AuthorizedAccountModel, ConfiguredStorageAddonModel, OperationInvocationRequestJsonApi } from '@shared/models'; @Injectable({ providedIn: 'root', @@ -9,7 +9,7 @@ import { AuthorizedAddon, ConfiguredStorageAddonModel, OperationInvocationReques export class AddonOperationInvocationService { createInitialOperationInvocationPayload( operationName: OperationNames, - selectedAccount: AuthorizedAddon, + selectedAccount: AuthorizedAccountModel, itemId?: string ): OperationInvocationRequestJsonApi { const operationKwargs = this.getOperationKwargs(operationName, itemId); diff --git a/src/app/shared/services/addons/addons.service.spec.ts b/src/app/shared/services/addons/addons.service.spec.ts index a1c773f20..d5804fcd8 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 { getAddonsAuthorizedStorageData } from '@testing/data/addons/addons.authorized-storage.data'; import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; import { getAddonsExternalStorageData } from '@testing/data/addons/addons.external-storage.data'; import { OSFTestingStoreModule } from '@testing/osf.testing.module'; @@ -76,4 +77,82 @@ describe('Service: Addons', () => { expect(httpMock.verify).toBeTruthy(); })); + + it('should test getAuthorizedStorageAddons', inject([HttpTestingController], (httpMock: HttpTestingController) => { + let results: any[] = []; + service.getAuthorizedStorageAddons('storage', 'reference-id').subscribe((result) => { + results = result; + }); + + const request = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/user-references/reference-id/authorized_storage_accounts/?include=external-storage-service&fields%5Bexternal-storage-services%5D=external_service_name' + ); + expect(request.request.method).toBe('GET'); + request.flush(getAddonsAuthorizedStorageData()); + + expect(results[0]).toEqual( + Object({ + accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', + apiBaseUrl: 'https://www.googleapis.com', + authUrl: null, + authorizedCapabilities: ['ACCESS', 'UPDATE'], + authorizedOperationNames: ['list_root_items', 'get_item_info', 'list_child_items'], + credentialsAvailable: true, + credentialsFormat: '', + defaultRootFolder: '', + displayName: 'Google Drive', + externalServiceName: 'googledrive', + oauthToken: 'ya29.A0AS3H6NzDCKgrUx', + externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + id: '331b7333-a13a-4d3b-add0-5af0fd1d4ac4', + providerName: '', + supportedFeatures: [], + type: 'authorized-storage-accounts', + }) + ); + + expect(httpMock.verify).toBeTruthy(); + })); + + it('should test getAuthorizedStorageOauthToken', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let results; + service.getAuthorizedStorageOauthToken('account-id').subscribe((result) => { + results = result; + }); + + const request = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body).toEqual( + Object({ + serializeOauthToken: true, + }) + ); + request.flush(getAddonsAuthorizedStorageData(0)); + + expect(results).toEqual( + Object({ + accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', + apiBaseUrl: 'https://www.googleapis.com', + authUrl: null, + authorizedCapabilities: ['ACCESS', 'UPDATE'], + authorizedOperationNames: ['list_root_items', 'get_item_info', 'list_child_items'], + credentialsAvailable: true, + credentialsFormat: '', + defaultRootFolder: '', + displayName: 'Google Drive', + externalServiceName: '', + oauthToken: 'ya29.A0AS3H6NzDCKgrUx', + externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + id: '331b7333-a13a-4d3b-add0-5af0fd1d4ac4', + providerName: '', + supportedFeatures: [], + type: 'authorized-storage-accounts', + }) + ); + + 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 e31b845d2..3c5634766 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -9,7 +9,7 @@ import { AddonMapper } from '@shared/mappers'; import { AddonGetResponseJsonApi, AddonModel, - AuthorizedAddon, + AuthorizedAccountModel, AuthorizedAddonGetResponseJsonApi, AuthorizedAddonRequestJsonApi, AuthorizedAddonResponseJsonApi, @@ -102,7 +102,7 @@ export class AddonsService { .pipe(map((response) => response.data)); } - getAuthorizedAddons(addonType: string, referenceId: string): Observable { + getAuthorizedStorageAddons(addonType: string, referenceId: string): Observable { const params = { [`fields[external-${addonType}-services]`]: 'external_service_name', }; @@ -117,6 +117,21 @@ export class AddonsService { ); } + getAuthorizedStorageOauthToken(accountId: string): Observable { + return this.jsonApiService + .patch( + `${environment.addonsApiUrl}/authorized-storage-accounts/${accountId}`, + { + serializeOauthToken: true, + } + ) + .pipe( + map((response) => { + return AddonMapper.fromAuthorizedAddonResponse(response as AuthorizedAddonGetResponseJsonApi); + }) + ); + } + /** * Retrieves the list of configured addons for a given resource reference. * diff --git a/src/app/shared/stores/addons/addons.actions.ts b/src/app/shared/stores/addons/addons.actions.ts index 4cca15336..9861a9baf 100644 --- a/src/app/shared/stores/addons/addons.actions.ts +++ b/src/app/shared/stores/addons/addons.actions.ts @@ -29,6 +29,12 @@ export class GetAuthorizedStorageAddons { constructor(public referenceId: string) {} } +export class GetAuthorizedStorageOauthToken { + static readonly type = '[Addons] Get Authorized Storage Oauth Token'; + + constructor(public accountId: string) {} +} + export class GetAuthorizedCitationAddons { static readonly type = '[Addons] Get Authorized Citation Addons'; diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index 48d1bdcf5..2834fd1b3 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -1,7 +1,7 @@ import { AddonModel, AsyncStateModel, - AuthorizedAddon, + AuthorizedAccountModel, AuthorizedAddonResponseJsonApi, ConfiguredAddonResponseJsonApi, ConfiguredStorageAddonModel, @@ -34,12 +34,12 @@ export interface AddonsStateModel { /** * Async state for authorized external storage addons linked to the current user. */ - authorizedStorageAddons: AsyncStateModel; + authorizedStorageAddons: AsyncStateModel; /** * Async state for authorized external citation addons linked to the current user. */ - authorizedCitationAddons: AsyncStateModel; + authorizedCitationAddons: AsyncStateModel; /** * Async state for storage addons that have been configured on a resource (e.g., project, node). diff --git a/src/app/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index a50c42381..b200e0b2d 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -1,8 +1,8 @@ -import { Selector } from '@ngxs/store'; +import { createSelector, Selector } from '@ngxs/store'; import { AddonModel, - AuthorizedAddon, + AuthorizedAccountModel, AuthorizedAddonResponseJsonApi, ConfiguredAddonResponseJsonApi, ConfiguredStorageAddonModel, @@ -47,9 +47,10 @@ export class AddonsSelectors { * @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); + static getStorageAddon(id: string): (state: AddonsStateModel) => AddonModel | null { + return createSelector([AddonsState], (state: AddonsStateModel): AddonModel | null => { + return state.storageAddons.data.find((addon: AddonModel) => addon.id === id) || null; + }); } /** * Selector to retrieve the loading status of storage addons from the AddonsState. @@ -73,17 +74,25 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getAuthorizedStorageAddons(state: AddonsStateModel): AuthorizedAddon[] { + static getAuthorizedStorageAddons(state: AddonsStateModel): AuthorizedAccountModel[] { return state.authorizedStorageAddons.data; } + static getAuthorizedStorageAddonOauthToken(id: string): (state: AddonsStateModel) => string | null { + return createSelector([AddonsState], (state: AddonsStateModel): string | null => { + return ( + state.authorizedStorageAddons.data.find((addon: AuthorizedAccountModel) => addon.id === id)?.oauthToken || null + ); + }); + } + @Selector([AddonsState]) static getAuthorizedStorageAddonsLoading(state: AddonsStateModel): boolean { return state.authorizedStorageAddons.isLoading; } @Selector([AddonsState]) - static getAuthorizedCitationAddons(state: AddonsStateModel): AuthorizedAddon[] { + static getAuthorizedCitationAddons(state: AddonsStateModel): AuthorizedAccountModel[] { return state.authorizedCitationAddons.data; } diff --git a/src/app/shared/stores/addons/addons.state.spec.ts b/src/app/shared/stores/addons/addons.state.spec.ts index 079c44857..fa013b10a 100644 --- a/src/app/shared/stores/addons/addons.state.spec.ts +++ b/src/app/shared/stores/addons/addons.state.spec.ts @@ -6,10 +6,16 @@ import { inject, TestBed } from '@angular/core/testing'; import { AddonsService } from '@osf/shared/services/addons/addons.service'; -import { GetConfiguredStorageAddons, GetStorageAddons } from './addons.actions'; +import { + GetAuthorizedStorageAddons, + GetAuthorizedStorageOauthToken, + GetConfiguredStorageAddons, + GetStorageAddons, +} from './addons.actions'; import { AddonsSelectors } from './addons.selectors'; import { AddonsState } from './addons.state'; +import { getAddonsAuthorizedStorageData } from '@testing/data/addons/addons.authorized-storage.data'; import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; import { getAddonsExternalStorageData } from '@testing/data/addons/addons.external-storage.data'; @@ -54,7 +60,7 @@ describe('State: Addons', () => { }) ); - const addon = store.selectSnapshot((state) => AddonsSelectors.getStorageAddon(state.addons, result[0].id)); + const addon = store.selectSnapshot(AddonsSelectors.getStorageAddon(result[0].id)); expect(addon).toEqual( Object({ @@ -70,6 +76,7 @@ describe('State: Addons', () => { }) ); expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); } )); @@ -102,6 +109,7 @@ describe('State: Addons', () => { }); expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); })); }); @@ -140,6 +148,7 @@ describe('State: Addons', () => { ); expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); } )); @@ -176,6 +185,228 @@ describe('State: Addons', () => { }); expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); + } + )); + }); + + describe('getAuthorizedStorageAddons', () => { + it('should fetch authorized storage oauth token and add state and selector output', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let result: any[] = []; + store.dispatch(new GetAuthorizedStorageAddons('reference-id')).subscribe(() => { + result = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddons); + }); + + const loading = store.selectSignal(AddonsSelectors.getAuthorizedStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + const request = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/user-references/reference-id/authorized_storage_accounts/?include=external-storage-service&fields%5Bexternal-storage-services%5D=external_service_name' + ); + expect(request.request.method).toBe('GET'); + request.flush(getAddonsAuthorizedStorageData()); + + expect(result[0]).toEqual( + Object({ + accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', + apiBaseUrl: 'https://www.googleapis.com', + authUrl: null, + authorizedCapabilities: ['ACCESS', 'UPDATE'], + authorizedOperationNames: ['list_root_items', 'get_item_info', 'list_child_items'], + credentialsAvailable: true, + credentialsFormat: '', + defaultRootFolder: '', + displayName: 'Google Drive', + externalServiceName: 'googledrive', + oauthToken: 'ya29.A0AS3H6NzDCKgrUx', + externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + id: '331b7333-a13a-4d3b-add0-5af0fd1d4ac4', + providerName: '', + supportedFeatures: [], + type: 'authorized-storage-accounts', + }) + ); + + expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); + } + )); + + it('should handle error if getAuthorizedStorageOauthToken fails', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let result: any = null; + + store.dispatch(new GetAuthorizedStorageAddons('reference-id')).subscribe({ + next: () => { + result = 'Expected error, but got success'; + }, + error: () => { + result = store.snapshot().addons.authorizedStorageAddons; + }, + }); + + const loading = store.selectSignal(AddonsSelectors.getAuthorizedStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + const request = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/user-references/reference-id/authorized_storage_accounts/?include=external-storage-service&fields%5Bexternal-storage-services%5D=external_service_name' + ); + expect(request.request.method).toBe('GET'); + + request.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/user-references/reference-id/authorized_storage_accounts/?include=external-storage-service&fields%5Bexternal-storage-services%5D=external_service_name: 500 Server Error', + isLoading: false, + isSubmitting: false, + }); + + expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); + } + )); + }); + + describe('getAuthorizedStorageOauthToken', () => { + it('should fetch authorized storage oauth token and add state and selector output', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let result: any[] = []; + store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe(() => { + result = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddons); + }); + + const loading = store.selectSignal(AddonsSelectors.getAuthorizedStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + const request = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); + expect(request.request.method).toBe('PATCH'); + request.flush(getAddonsAuthorizedStorageData(0)); + + expect(result[0]).toEqual( + Object({ + accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', + apiBaseUrl: 'https://www.googleapis.com', + authUrl: null, + authorizedCapabilities: ['ACCESS', 'UPDATE'], + authorizedOperationNames: ['list_root_items', 'get_item_info', 'list_child_items'], + credentialsAvailable: true, + credentialsFormat: '', + defaultRootFolder: '', + displayName: 'Google Drive', + externalServiceName: '', + oauthToken: 'ya29.A0AS3H6NzDCKgrUx', + externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + id: '331b7333-a13a-4d3b-add0-5af0fd1d4ac4', + providerName: '', + supportedFeatures: [], + type: 'authorized-storage-accounts', + }) + ); + + const oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[0].id)); + + expect(oauthToken).toBe('ya29.A0AS3H6NzDCKgrUx'); + + expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); + } + )); + + it('should fetch authorized storage oauth token and update state and selector output', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let result: any[] = []; + store.dispatch(new GetAuthorizedStorageAddons('reference-id')).subscribe(); + + store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe(() => { + result = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddons); + }); + + const loading = store.selectSignal(AddonsSelectors.getAuthorizedStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + let request = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/user-references/reference-id/authorized_storage_accounts/?include=external-storage-service&fields%5Bexternal-storage-services%5D=external_service_name' + ); + expect(request.request.method).toBe('GET'); + request.flush(getAddonsAuthorizedStorageData()); + + request = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); + expect(request.request.method).toBe('PATCH'); + const addonWithToken = getAddonsAuthorizedStorageData(1); + addonWithToken.data.attributes.oauth_token = 'ya2.34234324534'; + request.flush(addonWithToken); + + expect(result[1]).toEqual( + Object({ + accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', + apiBaseUrl: 'https://www.googleapis.com', + authUrl: null, + authorizedCapabilities: ['ACCESS', 'UPDATE'], + authorizedOperationNames: ['list_root_items', 'get_item_info', 'list_child_items'], + credentialsAvailable: true, + credentialsFormat: '', + defaultRootFolder: '', + displayName: 'Google Drive', + externalServiceName: '', + externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + id: '62ed6dd7-f7b7-4003-b7b4-855789c1f991', + oauthToken: 'ya2.34234324534', + providerName: '', + supportedFeatures: [], + type: 'authorized-storage-accounts', + }) + ); + + let oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[0].id)); + expect(oauthToken).toBe('ya29.A0AS3H6NzDCKgrUx'); + + oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[1].id)); + expect(oauthToken).toBe(result[1].oauthToken); + + expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); + } + )); + + it('should handle error if getAuthorizedStorageOauthToken fails', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let result: any = null; + + store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe({ + next: () => { + result = 'Expected error, but got success'; + }, + error: () => { + result = store.snapshot().addons.authorizedStorageAddons; + }, + }); + + const loading = store.selectSignal(AddonsSelectors.getAuthorizedStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + const req = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); + expect(req.request.method).toBe('PATCH'); + + 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/authorized-storage-accounts/account-id: 500 Server Error', + isLoading: false, + isSubmitting: false, + }); + + expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); } )); }); diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index ed63340a4..e7d1fe182 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -1,9 +1,11 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { catchError, switchMap, tap, throwError } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { handleSectionError } from '@osf/shared/helpers'; +import { AuthorizedAccountModel } from '@osf/shared/models'; import { AddonsService } from '@shared/services'; import { @@ -18,6 +20,7 @@ import { GetAddonsUserReference, GetAuthorizedCitationAddons, GetAuthorizedStorageAddons, + GetAuthorizedStorageOauthToken, GetCitationAddons, GetConfiguredCitationAddons, GetConfiguredStorageAddons, @@ -165,7 +168,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'storageAddons', error)) + catchError((error) => handleSectionError(ctx, 'storageAddons', error)) ); } @@ -189,7 +192,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'citationAddons', error)) + catchError((error) => handleSectionError(ctx, 'citationAddons', error)) ); } @@ -203,7 +206,7 @@ export class AddonsState { }, }); - return this.addonsService.getAuthorizedAddons('storage', action.referenceId).pipe( + return this.addonsService.getAuthorizedStorageAddons('storage', action.referenceId).pipe( tap((addons) => { ctx.patchState({ authorizedStorageAddons: { @@ -213,7 +216,44 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'authorizedStorageAddons', error)) + catchError((error) => handleSectionError(ctx, 'authorizedStorageAddons', error)) + ); + } + + @Action(GetAuthorizedStorageOauthToken) + getAuthorizedStorageOauthToken(ctx: StateContext, action: GetAuthorizedStorageOauthToken) { + const state = ctx.getState(); + ctx.patchState({ + authorizedStorageAddons: { + ...state.authorizedStorageAddons, + isLoading: true, + }, + }); + + return this.addonsService.getAuthorizedStorageOauthToken(action.accountId).pipe( + tap((addon) => { + ctx.setState((state) => { + const existing = state.authorizedStorageAddons.data.find( + (existingAddon: AuthorizedAccountModel) => existingAddon.id === addon.id + ); + const updatedData = existing + ? state.authorizedStorageAddons.data.map((existingAddon: AuthorizedAccountModel) => + existingAddon.id === addon.id ? { ...existingAddon, ...addon } : existingAddon + ) + : [...state.authorizedStorageAddons.data, addon]; + + return { + ...state, + authorizedStorageAddons: { + ...state.authorizedStorageAddons, + data: updatedData, + isLoading: false, + error: null, + }, + }; + }); + }), + catchError((error) => handleSectionError(ctx, 'authorizedStorageAddons', error)) ); } @@ -227,7 +267,7 @@ export class AddonsState { }, }); - return this.addonsService.getAuthorizedAddons('citation', action.referenceId).pipe( + return this.addonsService.getAuthorizedStorageAddons('citation', action.referenceId).pipe( tap((addons) => { ctx.patchState({ authorizedCitationAddons: { @@ -237,7 +277,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'authorizedCitationAddons', error)) + catchError((error) => handleSectionError(ctx, 'authorizedCitationAddons', error)) ); } @@ -277,7 +317,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'configuredStorageAddons', error)) + catchError((error) => handleSectionError(ctx, 'configuredStorageAddons', error)) ); } @@ -301,7 +341,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'configuredCitationAddons', error)) + catchError((error) => handleSectionError(ctx, 'configuredCitationAddons', error)) ); } @@ -334,7 +374,7 @@ export class AddonsState { ); } }), - catchError((error) => this.handleError(ctx, 'createdUpdatedAuthorizedAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedAuthorizedAddon', error)) ); } @@ -367,7 +407,7 @@ export class AddonsState { ); } }), - catchError((error) => this.handleError(ctx, 'createdUpdatedAuthorizedAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedAuthorizedAddon', error)) ); } @@ -392,7 +432,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'createdUpdatedConfiguredAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedConfiguredAddon', error)) ); } @@ -416,7 +456,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'addonsUserReference', error)) + catchError((error) => handleSectionError(ctx, 'addonsUserReference', error)) ); } @@ -455,7 +495,7 @@ export class AddonsState { ); } }), - catchError((error) => this.handleError(ctx, 'createdUpdatedAuthorizedAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedAuthorizedAddon', error)) ); } @@ -479,7 +519,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'addonsResourceReference', error)) + catchError((error) => handleSectionError(ctx, 'addonsResourceReference', error)) ); } @@ -504,7 +544,7 @@ export class AddonsState { } return []; }), - catchError((error) => this.handleError(ctx, stateKey, error)) + catchError((error) => handleSectionError(ctx, stateKey, error)) ); } @@ -535,7 +575,7 @@ export class AddonsState { } return []; }), - catchError((error) => this.handleError(ctx, 'createdUpdatedConfiguredAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedConfiguredAddon', error)) ); } @@ -571,7 +611,7 @@ export class AddonsState { }); } }), - catchError((error) => this.handleError(ctx, 'operationInvocation', error)) + catchError((error) => handleSectionError(ctx, 'operationInvocation', error)) ); } @@ -611,33 +651,4 @@ 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]: { - ...ctx.getState()[section], - isLoading: false, - isSubmitting: false, - error: error.message, - }, - }); - return throwError(() => error); - } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index cdf5e4004..b12361943 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1441,6 +1441,12 @@ "disconnectSuccess": "Successfully disconnected from the {{addonName}} account" }, "configureAddon": { + "google-file-picker": { + "select-root-folder": "Select Root Folder", + "selected-folder": "Selected Folder", + "root-folder-title": "Select a root folder", + "file-folder-title": "Select a file or folder to add" + }, "title": "Configure Add-on", "noFolders": "No folders", "noFolderSelected": "No selected folder", diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 137aa67ec..a2e0b13c0 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -74,4 +74,29 @@ export const environment = { * Default provider for OSF content and routing. */ defaultProvider: 'osf', + /** + * 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.ts b/src/environments/environment.ts index 0863de4a0..1b60b1d76 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -74,4 +74,13 @@ export const environment = { * Default provider for OSF content and routing. */ defaultProvider: 'osf', + + /** + * 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/data/addons/addons.authorized-storage.data.ts b/src/testing/data/addons/addons.authorized-storage.data.ts new file mode 100644 index 000000000..4365c4574 --- /dev/null +++ b/src/testing/data/addons/addons.authorized-storage.data.ts @@ -0,0 +1,135 @@ +import structuredClone from 'structured-clone'; + +const AuthorizedStorage = { + data: [ + { + type: 'authorized-storage-accounts', + id: '331b7333-a13a-4d3b-add0-5af0fd1d4ac4', + attributes: { + display_name: 'Google Drive', + api_base_url: 'https://www.googleapis.com', + auth_url: null, + authorized_capabilities: ['ACCESS', 'UPDATE'], + authorized_operation_names: ['list_root_items', 'get_item_info', 'list_child_items'], + default_root_folder: '', + credentials_available: true, + oauth_token: 'ya29.A0AS3H6NzDCKgrUx', + }, + relationships: { + account_owner: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/331b7333-a13a-4d3b-add0-5af0fd1d4ac4/account_owner', + }, + data: { + type: 'user-references', + id: '0b441148-83e5-4f7f-b302-b07b528b160b', + }, + }, + authorized_operations: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/331b7333-a13a-4d3b-add0-5af0fd1d4ac4/authorized_operations', + }, + }, + configured_storage_addons: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/331b7333-a13a-4d3b-add0-5af0fd1d4ac4/configured_storage_addons', + }, + }, + external_storage_service: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/331b7333-a13a-4d3b-add0-5af0fd1d4ac4/external_storage_service', + }, + data: { + type: 'external-storage-services', + id: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + }, + }, + }, + links: { + self: 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/331b7333-a13a-4d3b-add0-5af0fd1d4ac4', + }, + }, + { + type: 'authorized-storage-accounts', + id: '62ed6dd7-f7b7-4003-b7b4-855789c1f991', + attributes: { + display_name: 'Google Drive', + api_base_url: 'https://www.googleapis.com', + auth_url: null, + authorized_capabilities: ['ACCESS', 'UPDATE'], + authorized_operation_names: ['list_root_items', 'get_item_info', 'list_child_items'], + default_root_folder: '', + credentials_available: true, + }, + relationships: { + account_owner: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/62ed6dd7-f7b7-4003-b7b4-855789c1f991/account_owner', + }, + data: { + type: 'user-references', + id: '0b441148-83e5-4f7f-b302-b07b528b160b', + }, + }, + authorized_operations: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/62ed6dd7-f7b7-4003-b7b4-855789c1f991/authorized_operations', + }, + }, + configured_storage_addons: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/62ed6dd7-f7b7-4003-b7b4-855789c1f991/configured_storage_addons', + }, + }, + external_storage_service: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/62ed6dd7-f7b7-4003-b7b4-855789c1f991/external_storage_service', + }, + data: { + type: 'external-storage-services', + id: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + }, + }, + }, + links: { + self: 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/62ed6dd7-f7b7-4003-b7b4-855789c1f991', + }, + }, + ], + included: [ + { + type: 'external-storage-services', + id: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + attributes: { + external_service_name: 'googledrive', + }, + links: { + self: 'https://addons.staging4.osf.io/v1/external-storage-services/8aeb85e9-3a73-426f-a89b-5624b4b9d418', + }, + }, + ], +}; + +export function getAddonsAuthorizedStorageData(index?: number, asArray?: boolean) { + if (index || index === 0) { + if (asArray) { + return Object({ + data: [structuredClone(AuthorizedStorage.data[index])], + }); + } else { + return structuredClone({ + data: AuthorizedStorage.data[index], + }); + } + } else { + return structuredClone(AuthorizedStorage); + } +} diff --git a/src/testing/mocks/environment.token.mock.ts b/src/testing/mocks/environment.token.mock.ts new file mode 100644 index 000000000..c89b89def --- /dev/null +++ b/src/testing/mocks/environment.token.mock.ts @@ -0,0 +1,12 @@ +import { ENVIRONMENT } from '@core/constants/environment.token'; + +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', + }, + }, +}; diff --git a/src/testing/mocks/translation.service.mock.ts b/src/testing/mocks/translation.service.mock.ts index 010ae3167..d31c323e1 100644 --- a/src/testing/mocks/translation.service.mock.ts +++ b/src/testing/mocks/translation.service.mock.ts @@ -2,7 +2,7 @@ import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; -export const translationServiceMock = { +export const TranslationServiceMock = { provide: TranslateService, useValue: { get: jest.fn().mockImplementation((key) => of(key || '')), diff --git a/src/testing/osf.testing.module.ts b/src/testing/osf.testing.module.ts index b5c7cc404..fd30cfa44 100644 --- a/src/testing/osf.testing.module.ts +++ b/src/testing/osf.testing.module.ts @@ -5,20 +5,23 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClientTesting } from '@angular/common/http/testing'; import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { NoopAnimationsModule, provideNoopAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; +import { EnvironmentTokenMock } from './mocks/environment.token.mock'; import { StoreMock } from './mocks/store.mock'; import { ToastServiceMock } from './mocks/toast.service.mock'; -import { translationServiceMock } from './mocks/translation.service.mock'; +import { TranslationServiceMock } from './mocks/translation.service.mock'; @NgModule({ imports: [NoopAnimationsModule, BrowserModule, CommonModule, TranslateModule.forRoot()], providers: [ + provideNoopAnimations(), provideRouter([]), provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - translationServiceMock, + TranslationServiceMock, + EnvironmentTokenMock, ], }) export class OSFTestingModule {}