From f8dcf514bd6cefdbb163ebc873ab59f0bdd6063b Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 22 Aug 2025 12:20:33 -0500 Subject: [PATCH 01/14] chore(mocks): added global testing mocks and a new osftestingstoremodule --- src/app/features/project/addons/addons.component.spec.ts | 2 +- .../configure-addon/configure-addon.component.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/features/project/addons/addons.component.spec.ts b/src/app/features/project/addons/addons.component.spec.ts index e90e4cc21..d365d7dcb 100644 --- a/src/app/features/project/addons/addons.component.spec.ts +++ b/src/app/features/project/addons/addons.component.spec.ts @@ -8,7 +8,7 @@ import { AddonsState } from '@osf/shared/stores'; import { AddonsComponent } from './addons.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { OSFTestingStoreModule } from '@testing/osf.testing.module'; describe('AddonsComponent', () => { let component: AddonsComponent; 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..9770cbceb 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 @@ -125,18 +125,25 @@ export class ConfigureAddonComponent implements OnInit { // TODO this should be reviewed to have the addon be retrieved from the store // I have limited my testing because it will create a false/positive test based on the required data const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as ConfiguredStorageAddonModel; + console.log(2); if (addon) { + console.log(3); this.storageAddon.set( this.store.selectSnapshot((state) => AddonsSelectors.getStorageAddon(state.addons, addon.externalStorageServiceId || '') ) ); + console.log(4); this.addon.set(addon); + console.log(5); this.selectedRootFolderId.set(addon.selectedFolderId); + console.log(6); this.accountNameControl.setValue(addon.displayName); + console.log(7); } else { + console.log(8); this.router.navigate([`${this.baseUrl()}/addons`]); } } From 094f0ac1f989fc8e1e4eb3b16d862d79876bf4d5 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 22 Aug 2025 13:09:54 -0500 Subject: [PATCH 02/14] chore(tests): Added tests to continue the process --- .../configure-addon.component.spec.ts | 8 +++++++- .../configure-addon.component.ts | 20 +++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) 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..90a81c718 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 @@ -2,6 +2,11 @@ import { provideStore } from '@ngxs/store'; import { of } from 'rxjs'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of } from 'rxjs'; + import { HttpTestingController } from '@angular/common/http/testing'; import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; @@ -10,6 +15,8 @@ import { AddonsState } from '@osf/shared/stores'; import { ConfigureAddonComponent } from './configure-addon.component'; +import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; +import { getAddonsOperationInvocation } from '@testing/data/addons/addons.operation-invocation.data'; import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; import { getAddonsOperationInvocation } from '@testing/data/addons/addons.operation-invocation.data'; import { ToastServiceMock } from '@testing/mocks/toast.service.mock'; @@ -96,7 +103,6 @@ describe('Component: Configure Addon', () => { expect(component.addonTypeString()).toBe('storage'); expect(component.selectedRootFolderId()).toBeUndefined(); expect(component.accountNameControl.value).toBeUndefined(); - expect(component.isGoogleDrive()).toBeFalsy(); }); it('should valid onInit - action called', inject([HttpTestingController], (httpMock: HttpTestingController) => { 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 9770cbceb..54c279501 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 @@ -72,36 +72,39 @@ export class ConfigureAddonComponent implements OnInit { * Form control for capturing or displaying the user’s selected account name. */ public accountNameControl = new FormControl(''); + public accountNameControl = new FormControl(''); /** * 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(undefined); /** * Signal representing the currently selected and configured storage addon model. * This may be `null` if no addon has been configured. */ public addon = signal(null); - public readonly isGoogleDrive = computed(() => { - return this.storageAddon()?.wbKey === 'googledrive'; - }); - protected isEditMode = signal(false); public selectedRootFolderId = signal(''); + public selectedRootFolderId = signal(''); protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); public operationInvocation = select(AddonsSelectors.getOperationInvocation); + public operationInvocation = select(AddonsSelectors.getOperationInvocation); protected selectedFolderOperationInvocation = select(AddonsSelectors.getSelectedFolderOperationInvocation); protected selectedFolder = select(AddonsSelectors.getSelectedFolder); + readonly baseUrl = computed(() => { readonly baseUrl = computed(() => { const currentUrl = this.router.url; return currentUrl.split('/addons')[0]; }); + readonly resourceUri = computed(() => { readonly resourceUri = computed(() => { const id = this.route.parent?.parent?.snapshot.params['id']; return `${environment.webUrl}/${id}`; }); + readonly addonTypeString = computed(() => { readonly addonTypeString = computed(() => { return getAddonTypeString(this.addon()); }); @@ -122,28 +125,23 @@ export class ConfigureAddonComponent implements OnInit { } private initializeAddon(): void { + // TODO this should be reviewed to have the addon be retrieved from the store + // I have limited my testing because it will create a false/positive test based on the required data // TODO this should be reviewed to have the addon be retrieved from the store // I have limited my testing because it will create a false/positive test based on the required data const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as ConfiguredStorageAddonModel; - console.log(2); if (addon) { - console.log(3); this.storageAddon.set( this.store.selectSnapshot((state) => AddonsSelectors.getStorageAddon(state.addons, addon.externalStorageServiceId || '') ) ); - console.log(4); this.addon.set(addon); - console.log(5); this.selectedRootFolderId.set(addon.selectedFolderId); - console.log(6); this.accountNameControl.setValue(addon.displayName); - console.log(7); } else { - console.log(8); this.router.navigate([`${this.baseUrl()}/addons`]); } } From 1e334d54b4a0d77dbf7ce39556ff0ceaac8c77fe Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 22 Aug 2025 13:16:55 -0500 Subject: [PATCH 03/14] feat(code): added the condition for google drive --- .../configure-addon/configure-addon.component.spec.ts | 1 + .../components/configure-addon/configure-addon.component.ts | 4 ++++ 2 files changed, 5 insertions(+) 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 90a81c718..6a0eb93b6 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 @@ -103,6 +103,7 @@ describe('Component: Configure Addon', () => { expect(component.addonTypeString()).toBe('storage'); expect(component.selectedRootFolderId()).toBeUndefined(); expect(component.accountNameControl.value).toBeUndefined(); + expect(component.isGoogleDrive()).toBeFalsy(); }); it('should valid onInit - action called', inject([HttpTestingController], (httpMock: HttpTestingController) => { 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 54c279501..c5a9692e8 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 @@ -85,6 +85,10 @@ export class ConfigureAddonComponent implements OnInit { */ public addon = signal(null); + public readonly isGoogleDrive = computed(() => { + return this.storageAddon()?.wbKey === 'googledrive'; + }); + protected isEditMode = signal(false); public selectedRootFolderId = signal(''); public selectedRootFolderId = signal(''); From 86dc9e2cad83e8fa1662810661c8a8f78e24e0cc Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 22 Aug 2025 14:25:22 -0500 Subject: [PATCH 04/14] feat(google-file-picker): Added the initial google file picker component --- src/app/features/project/addons/addons.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/project/addons/addons.component.spec.ts b/src/app/features/project/addons/addons.component.spec.ts index d365d7dcb..e90e4cc21 100644 --- a/src/app/features/project/addons/addons.component.spec.ts +++ b/src/app/features/project/addons/addons.component.spec.ts @@ -8,7 +8,7 @@ import { AddonsState } from '@osf/shared/stores'; import { AddonsComponent } from './addons.component'; -import { OSFTestingStoreModule } from '@testing/osf.testing.module'; +import { OSFTestingModule } from '@testing/osf.testing.module'; describe('AddonsComponent', () => { let component: AddonsComponent; From 68a318d108b89486e4aeee3eb26238449840e6bf Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Mon, 25 Aug 2025 10:09:42 -0500 Subject: [PATCH 05/14] feat(GFP-service): add the google file picker service --- ...oogle-file-picker.download.service.spec.ts | 69 +++++++++++++++++++ .../google-file-picker.download.service.ts | 54 +++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.spec.ts create mode 100644 src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.ts 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..ee81d27df --- /dev/null +++ b/src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.spec.ts @@ -0,0 +1,69 @@ +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(), + }, + } 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(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) => { + service.loadScript().subscribe({ + next: () => fail('Should not emit next on error'), + error: (err) => { + expect(err).toBe('Failed to load Google Picker script'); + done(); + }, + }); + + mockScriptElement.onerror(); + }); +}); 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..31a7dd135 --- /dev/null +++ b/src/app/shared/components/addons/folder-selector/google-file-picker/service/google-file-picker.download.service.ts @@ -0,0 +1,54 @@ +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. + */ + loadScript(): Observable { + return new Observable((observer: Subscriber) => { + new Promise(() => { + if (this.scriptLoaded) { + observer.next(); + observer.complete(); + } + + const script = this.document.createElement('script'); + script.src = this.scriptUrl; + script.onload = () => { + this.scriptLoaded = true; + observer.next(); + observer.complete(); + }; + script.onerror = () => observer.error('Failed to load Google Picker script'); + this.document.body.appendChild(script); + }); + }); + } +} From 0d19bc7f73a1411bc7cafcb1f88740aecaee9c90 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Mon, 25 Aug 2025 11:27:15 -0500 Subject: [PATCH 06/14] feat(service): added the google file picker download service --- ...oogle-file-picker.download.service.spec.ts | 28 +++++++++++++--- .../google-file-picker.download.service.ts | 32 ++++++++++--------- 2 files changed, 41 insertions(+), 19 deletions(-) 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 index ee81d27df..07a7f38b5 100644 --- 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 @@ -23,8 +23,9 @@ describe('Service: Google File Picker Download', () => { mockDocument = { createElement: jest.fn(() => mockScriptElement), body: { - appendChild: jest.fn(), - }, + appendChild: jest.fn((node: Node) => node), + } as any, + querySelector: jest.fn(), } as any; TestBed.configureTestingModule({ @@ -41,6 +42,8 @@ describe('Service: Google File Picker Download', () => { 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: () => { @@ -56,6 +59,25 @@ describe('Service: Google File Picker Download', () => { }); 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) => { @@ -63,7 +85,5 @@ describe('Service: Google File Picker Download', () => { done(); }, }); - - mockScriptElement.onerror(); }); }); 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 index 31a7dd135..84d78175b 100644 --- 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 @@ -33,22 +33,24 @@ export class GoogleFilePickerDownloadService { */ loadScript(): Observable { return new Observable((observer: Subscriber) => { - new Promise(() => { - if (this.scriptLoaded) { - observer.next(); - observer.complete(); - } + 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.onload = () => { - this.scriptLoaded = true; - observer.next(); - observer.complete(); - }; - script.onerror = () => observer.error('Failed to load Google Picker script'); - this.document.body.appendChild(script); - }); + 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); }); } } From 13e97eaeb37b766084938217e0c0134b07448389 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Mon, 25 Aug 2025 11:34:50 -0500 Subject: [PATCH 07/14] chore(npm): added strict typing for the google file picker --- eslint.config.js | 1 + package-lock.json | 19 ++++++ package.json | 2 + src/@types/global.d.ts | 7 +++ ...oogle-file-picker.download.service.spec.ts | 61 +++++++++++++++++++ .../google-file-picker.download.service.ts | 19 +++++- 6 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/@types/global.d.ts 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..891a961cf --- /dev/null +++ b/src/@types/global.d.ts @@ -0,0 +1,7 @@ +import type gapi from 'gapi-script'; // or just `import gapi from 'gapi-script';` + +declare global { + interface Window { + gapi: typeof gapi; + } +} 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 index 07a7f38b5..9ba6fc0d1 100644 --- 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 @@ -86,4 +86,65 @@ describe('Service: Google File Picker Download', () => { }, }); }); + + 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 index 84d78175b..76be19951 100644 --- 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 @@ -31,7 +31,7 @@ export class GoogleFilePickerDownloadService { * * @returns Observable that emits once the script is loaded, or errors if loading fails. */ - loadScript(): Observable { + public loadScript(): Observable { return new Observable((observer: Subscriber) => { const existingScript = this.document.querySelector(`script[src="${this.scriptUrl}"]`); if (existingScript || this.scriptLoaded) { @@ -53,4 +53,21 @@ export class GoogleFilePickerDownloadService { 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'), + }); + }); + } } From 6913d60d333f7deffcf5951031cc67cb0730530e Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Mon, 25 Aug 2025 15:28:12 -0500 Subject: [PATCH 08/14] feat(component): added more functionality to the component with tests --- src/@types/global.d.ts | 3 + src/app/core/constants/environment.token.ts | 8 + .../configure-addon.component.ts | 7 - .../folder-selector.component.html | 2 +- .../google-file-picker.component.html | 31 +-- .../google-file-picker.component.spec.ts | 177 ++++++++++++- .../google-file-picker.component.ts | 246 ++++++++---------- src/assets/i18n/en.json | 6 + src/environments/environment.development.ts | 25 ++ src/environments/environment.ts | 9 + src/testing/mocks/environment.token.mock.ts | 12 + src/testing/mocks/translation.service.mock.ts | 2 +- src/testing/osf.testing.module.ts | 6 +- 13 files changed, 357 insertions(+), 177 deletions(-) create mode 100644 src/app/core/constants/environment.token.ts create mode 100644 src/testing/mocks/environment.token.mock.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 891a961cf..582a45a49 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -3,5 +3,8 @@ 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.ts b/src/app/features/project/addons/components/configure-addon/configure-addon.component.ts index c5a9692e8..f1d99b850 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 @@ -72,13 +72,11 @@ export class ConfigureAddonComponent implements OnInit { * Form control for capturing or displaying the user’s selected account name. */ public accountNameControl = new FormControl(''); - public accountNameControl = new FormControl(''); /** * 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(undefined); /** * Signal representing the currently selected and configured storage addon model. * This may be `null` if no addon has been configured. @@ -91,24 +89,19 @@ export class ConfigureAddonComponent implements OnInit { protected isEditMode = signal(false); public selectedRootFolderId = signal(''); - public selectedRootFolderId = signal(''); protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); public operationInvocation = select(AddonsSelectors.getOperationInvocation); - public operationInvocation = select(AddonsSelectors.getOperationInvocation); protected selectedFolderOperationInvocation = select(AddonsSelectors.getSelectedFolderOperationInvocation); protected selectedFolder = select(AddonsSelectors.getSelectedFolder); - readonly baseUrl = computed(() => { readonly baseUrl = computed(() => { const currentUrl = this.router.url; return currentUrl.split('/addons')[0]; }); - readonly resourceUri = computed(() => { readonly resourceUri = computed(() => { const id = this.route.parent?.parent?.snapshot.params['id']; return `${environment.webUrl}/${id}`; }); - readonly addonTypeString = computed(() => { readonly addonTypeString = computed(() => { return getAddonTypeString(this.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/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..86365fdba 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,14 @@ -
Hello World again
- - +
+ @if (this.isFolderPicker()) { +
+ ::{{ this.isGFPDisabled() }}:: ::{{ this.visible() }}:: + +
+ } +
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..6bb812cf7 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,178 @@ +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 } 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(); + + describe('isFolderPicker - true', () => { + beforeEach(async () => { + (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 }], + }).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.detectChanges(); + }); + + it('should load script and then GAPI modules and initialize picker', () => { + expect(googlePickerServiceSpy.loadScript).toHaveBeenCalled(); + expect(googlePickerServiceSpy.loadGapiModules).toHaveBeenCalled(); + + expect(component.visible()).toBeTruthy(); + expect(component.isGFPDisabled()).toBeFalsy(); + }); - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [OSFTestingStoreModule, GoogleFilePickerComponent], - providers: [], - }).compileComponents(); + it('should build the picker with correct configuration', () => { + component.createPicker(); - fixture = TestBed.createComponent(GoogleFilePickerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + 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(undefined); + 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 () => { + (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 }], + }).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).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(undefined); + 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..4b79bf81b 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,33 +1,120 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +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 { 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; +export class GoogleFilePickerComponent implements OnInit { + readonly #translateService = inject(TranslateService); + readonly #googlePicker = inject(GoogleFilePickerDownloadService); + readonly #environment = inject(ENVIRONMENT); + + public isFolderPicker = input.required(); + + selectedFolderName = input(''); + rootFolderId = input(''); + + // selectFolder?: (a: Partial) => void; + // onRegisterChild?: (a: GoogleFilePickerWidget) => void; + // manager: StorageManager; + // accountId: string; + // @tracked openGoogleFilePicker = false; + #folderName = signal(''); + selectFolder = undefined; + accessToken!: string; + + public visible = signal(false); + public isGFPDisabled = signal(true); + readonly #apiKey = this.#environment.google.GOOGLE_FILE_PICKER_API_KEY; + readonly #appId = this.#environment.google.GOOGLE_FILE_PICKER_APP_ID; + #mimeTypes = ''; + #parentId = ''; + #isMultipleSelect!: boolean; + #title!: string; + + ngOnInit(): void { + // window.GoogleFilePickerWidget = this; + // this.selectFolder = this.selectFolder(); + // taskFor(this.loadOauthToken).perform(); + this.#mimeTypes = this.isFolderPicker() ? 'application/vnd.google-apps.folder' : ''; + 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(), + // 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); + this.isGFPDisabled.set(false); + } + } + + createPicker(): void { + const google = window.google; + + const googlePickerView = new google.picker.DocsView(google.picker.ViewId.DOCS); + googlePickerView.setSelectFolderEnabled(true); + googlePickerView.setMimeTypes(this.#mimeTypes); + 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); + } + + // /** + // * 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'); + } +} // // // // 📚 Interface for Expected Arguments @@ -49,80 +136,6 @@ export class GoogleFilePickerComponent {} // 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{ @@ -170,47 +183,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/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/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..8c0b9c924 100644 --- a/src/testing/osf.testing.module.ts +++ b/src/testing/osf.testing.module.ts @@ -8,9 +8,10 @@ import { BrowserModule } from '@angular/platform-browser'; import { NoopAnimationsModule } 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()], @@ -18,7 +19,8 @@ import { translationServiceMock } from './mocks/translation.service.mock'; provideRouter([]), provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - translationServiceMock, + TranslationServiceMock, + EnvironmentTokenMock, ], }) export class OSFTestingModule {} From 7702241a4be975a27843e0dd3329e919bffb87c4 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Mon, 25 Aug 2025 16:55:19 -0500 Subject: [PATCH 09/14] feat(eng-8505): Added more logic to get the oauth token from the server --- .../connect-configured-addon.component.ts | 1 + .../settings/addons/addons.component.ts | 3 + .../folder-selector.component.ts | 2 + .../google-file-picker.component.html | 1 - src/app/shared/mappers/addon.mapper.ts | 1 + src/app/shared/models/addons/addons.models.ts | 2 + .../services/addons/addons.service.spec.ts | 38 +++++++++ .../shared/services/addons/addons.service.ts | 14 ++++ .../shared/stores/addons/addons.actions.ts | 6 ++ .../shared/stores/addons/addons.state.spec.ts | 79 ++++++++++++++++++- src/app/shared/stores/addons/addons.state.ts | 26 ++++++ .../addons/addons.authorized-storage.data.ts | 59 ++++++++++++++ 12 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 src/testing/data/addons/addons.authorized-storage.data.ts 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..c2d31777c 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 @@ -114,6 +114,7 @@ export class ConnectConfiguredAddonComponent { protected resourceUri = computed(() => { const id = this.route.parent?.parent?.snapshot.params['id']; + console.log('resourceUri id', id); return `${environment.webUrl}/${id}`; }); 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/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 86365fdba..75f231476 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,7 +1,6 @@
@if (this.isFolderPicker()) {
- ::{{ this.isGFPDisabled() }}:: ::{{ this.visible() }}:: { 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('GET'); + request.flush(getAddonsAuthorizedStorageData()); + + expect(results).toEqual( + Object({ + accountOwnerId: '0e761652-ac4c-427e-b31c-7317d53ef32a', + 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: '986c6ba5-ff9b-4a57-8c01-e58ff4cd48ca', + id: '0ab44840-5a37-4a79-9e94-9b5f5830159a', + 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..ffdcb4802 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -117,6 +117,20 @@ export class AddonsService { ); } + getAuthorizedStorageOauthToken(accountId: string): Observable { + // https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a + return this.jsonApiService + + .get< + JsonApiResponse + >(`${environment.addonsApiUrl}/authorized-storage-accounts/${accountId}`) + .pipe( + map((response) => { + return AddonMapper.fromAuthorizedAddonResponse(response.data); + }) + ); + } + /** * 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.state.spec.ts b/src/app/shared/stores/addons/addons.state.spec.ts index 079c44857..3d4add766 100644 --- a/src/app/shared/stores/addons/addons.state.spec.ts +++ b/src/app/shared/stores/addons/addons.state.spec.ts @@ -6,10 +6,11 @@ import { inject, TestBed } from '@angular/core/testing'; import { AddonsService } from '@osf/shared/services/addons/addons.service'; -import { GetConfiguredStorageAddons, GetStorageAddons } from './addons.actions'; +import { 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'; @@ -179,4 +180,80 @@ describe('State: Addons', () => { } )); }); + + describe('getAuthorizedStorageOauthToken', () => { + it('should fetch authorized storage oauth token and update 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('GET'); + request.flush(getAddonsAuthorizedStorageData()); + + expect(result[0]).toEqual( + Object({ + accountOwnerId: '0e761652-ac4c-427e-b31c-7317d53ef32a', + 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: '986c6ba5-ff9b-4a57-8c01-e58ff4cd48ca', + id: '0ab44840-5a37-4a79-9e94-9b5f5830159a', + providerName: '', + supportedFeatures: [], + type: 'authorized-storage-accounts', + }) + ); + + expect(loading()).toBeFalsy(); + } + )); + + 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('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/authorized-storage-accounts/account-id: 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 ed63340a4..0041f76b8 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -18,6 +18,7 @@ import { GetAddonsUserReference, GetAuthorizedCitationAddons, GetAuthorizedStorageAddons, + GetAuthorizedStorageOauthToken as GetAuthorizedtorageOauthToken, GetCitationAddons, GetConfiguredCitationAddons, GetConfiguredStorageAddons, @@ -217,6 +218,31 @@ export class AddonsState { ); } + @Action(GetAuthorizedtorageOauthToken) + getAuthorizedStorageOauthToken(ctx: StateContext, action: GetAuthorizedtorageOauthToken) { + const state = ctx.getState(); + ctx.patchState({ + authorizedStorageAddons: { + ...state.authorizedStorageAddons, + isLoading: true, + }, + }); + + return this.addonsService.getAuthorizedStorageOauthToken(action.accountId).pipe( + tap((addon) => { + // todo this is not correct + ctx.patchState({ + authorizedStorageAddons: { + data: [addon], + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'authorizedStorageAddons', error)) + ); + } + @Action(GetAuthorizedCitationAddons) getAuthorizedCitationAddons(ctx: StateContext, action: GetAuthorizedCitationAddons) { const state = ctx.getState(); 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..f64e48285 --- /dev/null +++ b/src/testing/data/addons/addons.authorized-storage.data.ts @@ -0,0 +1,59 @@ +import structuredClone from 'structured-clone'; + +const AuthorizedStorage = { + data: { + type: 'authorized-storage-accounts', + id: '0ab44840-5a37-4a79-9e94-9b5f5830159a', + 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.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a/account_owner', + }, + data: { + type: 'user-references', + id: '0e761652-ac4c-427e-b31c-7317d53ef32a', + }, + }, + authorized_operations: { + links: { + related: + 'https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a/authorized_operations', + }, + }, + configured_storage_addons: { + links: { + related: + 'https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a/configured_storage_addons', + }, + }, + external_storage_service: { + links: { + related: + 'https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a/external_storage_service', + }, + data: { + type: 'external-storage-services', + id: '986c6ba5-ff9b-4a57-8c01-e58ff4cd48ca', + }, + }, + }, + links: { + self: 'https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a', + }, + }, +}; + +export function getAddonsAuthorizedStorageData() { + return structuredClone(AuthorizedStorage); +} From 8b31d6b4b3f65c453b842b339bb914ab6956fb90 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Tue, 26 Aug 2025 11:13:12 -0500 Subject: [PATCH 10/14] chore(test-updates): updated states, services and tests --- .../services/addons/addons.service.spec.ts | 44 ++++- .../shared/services/addons/addons.service.ts | 3 +- .../shared/stores/addons/addons.state.spec.ts | 156 ++++++++++++++++- src/app/shared/stores/addons/addons.state.ts | 31 +++- .../addons/addons.authorized-storage.data.ts | 162 +++++++++++++----- 5 files changed, 332 insertions(+), 64 deletions(-) diff --git a/src/app/shared/services/addons/addons.service.spec.ts b/src/app/shared/services/addons/addons.service.spec.ts index 7ced3e2f5..5bcfc29fa 100644 --- a/src/app/shared/services/addons/addons.service.spec.ts +++ b/src/app/shared/services/addons/addons.service.spec.ts @@ -78,6 +78,42 @@ 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) => { @@ -88,11 +124,11 @@ describe('Service: Addons', () => { const request = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); expect(request.request.method).toBe('GET'); - request.flush(getAddonsAuthorizedStorageData()); + request.flush(getAddonsAuthorizedStorageData(0)); expect(results).toEqual( Object({ - accountOwnerId: '0e761652-ac4c-427e-b31c-7317d53ef32a', + accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', apiBaseUrl: 'https://www.googleapis.com', authUrl: null, authorizedCapabilities: ['ACCESS', 'UPDATE'], @@ -103,8 +139,8 @@ describe('Service: Addons', () => { displayName: 'Google Drive', externalServiceName: '', oauthToken: 'ya29.A0AS3H6NzDCKgrUx', - externalStorageServiceId: '986c6ba5-ff9b-4a57-8c01-e58ff4cd48ca', - id: '0ab44840-5a37-4a79-9e94-9b5f5830159a', + externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + id: '331b7333-a13a-4d3b-add0-5af0fd1d4ac4', providerName: '', supportedFeatures: [], type: 'authorized-storage-accounts', diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index ffdcb4802..a38a97f91 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -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', }; @@ -118,7 +118,6 @@ export class AddonsService { } getAuthorizedStorageOauthToken(accountId: string): Observable { - // https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a return this.jsonApiService .get< diff --git a/src/app/shared/stores/addons/addons.state.spec.ts b/src/app/shared/stores/addons/addons.state.spec.ts index 3d4add766..a4d174830 100644 --- a/src/app/shared/stores/addons/addons.state.spec.ts +++ b/src/app/shared/stores/addons/addons.state.spec.ts @@ -6,7 +6,12 @@ import { inject, TestBed } from '@angular/core/testing'; import { AddonsService } from '@osf/shared/services/addons/addons.service'; -import { GetAuthorizedStorageOauthToken, GetConfiguredStorageAddons, GetStorageAddons } from './addons.actions'; +import { + GetAuthorizedStorageAddons, + GetAuthorizedStorageOauthToken, + GetConfiguredStorageAddons, + GetStorageAddons, +} from './addons.actions'; import { AddonsSelectors } from './addons.selectors'; import { AddonsState } from './addons.state'; @@ -71,6 +76,7 @@ describe('State: Addons', () => { }) ); expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); } )); @@ -103,6 +109,7 @@ describe('State: Addons', () => { }); expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); })); }); @@ -141,6 +148,7 @@ describe('State: Addons', () => { ); expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); } )); @@ -177,12 +185,95 @@ 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 update state and selector output', inject( + it('should fetch authorized storage oauth token and add state and selector output', inject( [HttpTestingController], (httpMock: HttpTestingController) => { let result: any[] = []; @@ -195,11 +286,11 @@ describe('State: Addons', () => { const request = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); expect(request.request.method).toBe('GET'); - request.flush(getAddonsAuthorizedStorageData()); + request.flush(getAddonsAuthorizedStorageData(0)); expect(result[0]).toEqual( Object({ - accountOwnerId: '0e761652-ac4c-427e-b31c-7317d53ef32a', + accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', apiBaseUrl: 'https://www.googleapis.com', authUrl: null, authorizedCapabilities: ['ACCESS', 'UPDATE'], @@ -210,8 +301,59 @@ describe('State: Addons', () => { displayName: 'Google Drive', externalServiceName: '', oauthToken: 'ya29.A0AS3H6NzDCKgrUx', - externalStorageServiceId: '986c6ba5-ff9b-4a57-8c01-e58ff4cd48ca', - id: '0ab44840-5a37-4a79-9e94-9b5f5830159a', + 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 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('GET'); + 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', @@ -219,6 +361,7 @@ describe('State: Addons', () => { ); expect(loading()).toBeFalsy(); + expect(httpMock.verify).toBeTruthy(); } )); @@ -253,6 +396,7 @@ describe('State: Addons', () => { }); 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 0041f76b8..48e2d4027 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -4,6 +4,7 @@ import { catchError, switchMap, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { AuthorizedAddon } from '@osf/shared/models'; import { AddonsService } from '@shared/services'; import { @@ -204,7 +205,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: { @@ -230,13 +231,25 @@ export class AddonsState { return this.addonsService.getAuthorizedStorageOauthToken(action.accountId).pipe( tap((addon) => { - // todo this is not correct - ctx.patchState({ - authorizedStorageAddons: { - data: [addon], - isLoading: false, - error: null, - }, + ctx.setState((state) => { + const existing = state.authorizedStorageAddons.data.find( + (existingAddon: AuthorizedAddon) => existingAddon.id === addon.id + ); + const updatedData = existing + ? state.authorizedStorageAddons.data.map((existingAddon: AuthorizedAddon) => + existingAddon.id === addon.id ? { ...existingAddon, ...addon } : existingAddon + ) + : [...state.authorizedStorageAddons.data, addon]; + + return { + ...state, + authorizedStorageAddons: { + ...state.authorizedStorageAddons, + data: updatedData, + isLoading: false, + error: null, + }, + }; }); }), catchError((error) => this.handleError(ctx, 'authorizedStorageAddons', error)) @@ -253,7 +266,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: { diff --git a/src/testing/data/addons/addons.authorized-storage.data.ts b/src/testing/data/addons/addons.authorized-storage.data.ts index f64e48285..4365c4574 100644 --- a/src/testing/data/addons/addons.authorized-storage.data.ts +++ b/src/testing/data/addons/addons.authorized-storage.data.ts @@ -1,59 +1,135 @@ import structuredClone from 'structured-clone'; const AuthorizedStorage = { - data: { - type: 'authorized-storage-accounts', - id: '0ab44840-5a37-4a79-9e94-9b5f5830159a', - 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.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a/account_owner', + 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', + }, }, - data: { - type: 'user-references', - id: '0e761652-ac4c-427e-b31c-7317d53ef32a', + authorized_operations: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/331b7333-a13a-4d3b-add0-5af0fd1d4ac4/authorized_operations', + }, }, - }, - authorized_operations: { - links: { - related: - 'https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a/authorized_operations', + configured_storage_addons: { + links: { + related: + 'https://addons.staging4.osf.io/v1/authorized-storage-accounts/331b7333-a13a-4d3b-add0-5af0fd1d4ac4/configured_storage_addons', + }, }, - }, - configured_storage_addons: { - links: { - related: - 'https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a/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', + }, }, }, - external_storage_service: { - links: { - related: - 'https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a/external_storage_service', + 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', + }, }, - data: { - type: 'external-storage-services', - id: '986c6ba5-ff9b-4a57-8c01-e58ff4cd48ca', + 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', + }, }, - links: { - self: 'https://addons.test.osf.io/v1/authorized-storage-accounts/0ab44840-5a37-4a79-9e94-9b5f5830159a', + ], + 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() { - return structuredClone(AuthorizedStorage); +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); + } } From 3cd65911cc1bc2fd664c9a181a8a7cd8f1542cdb Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Tue, 26 Aug 2025 12:20:02 -0500 Subject: [PATCH 11/14] chore(unit-tests): Added more tdd tests to get the oauth token --- .../configure-addon.component.spec.ts | 9 +-------- .../configure-addon/configure-addon.component.ts | 6 ++---- src/app/shared/stores/addons/addons.selectors.ts | 15 +++++++++++---- src/app/shared/stores/addons/addons.state.spec.ts | 12 +++++++++++- 4 files changed, 25 insertions(+), 17 deletions(-) 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 6a0eb93b6..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 @@ -2,11 +2,6 @@ import { provideStore } from '@ngxs/store'; import { of } from 'rxjs'; -import { HttpTestingController } from '@angular/common/http/testing'; -import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; -import { of } from 'rxjs'; - import { HttpTestingController } from '@angular/common/http/testing'; import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; @@ -15,8 +10,6 @@ import { AddonsState } from '@osf/shared/stores'; import { ConfigureAddonComponent } from './configure-addon.component'; -import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; -import { getAddonsOperationInvocation } from '@testing/data/addons/addons.operation-invocation.data'; import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; import { getAddonsOperationInvocation } from '@testing/data/addons/addons.operation-invocation.data'; import { ToastServiceMock } from '@testing/mocks/toast.service.mock'; @@ -80,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 f1d99b850..9c1129d05 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. @@ -130,9 +130,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/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index a50c42381..448581ed4 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -1,4 +1,4 @@ -import { Selector } from '@ngxs/store'; +import { createSelector, Selector } from '@ngxs/store'; import { AddonModel, @@ -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. @@ -77,6 +78,12 @@ export class AddonsSelectors { 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: AuthorizedAddon) => addon.id === id)?.oauthToken || null; + }); + } + @Selector([AddonsState]) static getAuthorizedStorageAddonsLoading(state: AddonsStateModel): boolean { return state.authorizedStorageAddons.isLoading; diff --git a/src/app/shared/stores/addons/addons.state.spec.ts b/src/app/shared/stores/addons/addons.state.spec.ts index a4d174830..02a4e669d 100644 --- a/src/app/shared/stores/addons/addons.state.spec.ts +++ b/src/app/shared/stores/addons/addons.state.spec.ts @@ -60,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({ @@ -309,6 +309,10 @@ describe('State: Addons', () => { }) ); + const oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[0].id)); + + expect(oauthToken).toBe('ya29.A0AS3H6NzDCKgrUx'); + expect(loading()).toBeFalsy(); expect(httpMock.verify).toBeTruthy(); } @@ -360,6 +364,12 @@ describe('State: Addons', () => { }) ); + 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(); } From 0d65ec9d9938f12186878cccb689d3c67262308c Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Tue, 26 Aug 2025 15:57:31 -0500 Subject: [PATCH 12/14] feat(oauth-token): added oauth token retrieval --- .../connect-configured-addon.component.ts | 2 - .../google-file-picker.component.spec.ts | 41 +++++++--- .../google-file-picker.component.ts | 74 ++++++++----------- src/app/shared/mappers/addon.mapper.ts | 1 - .../services/addons/addons.service.spec.ts | 7 +- .../shared/services/addons/addons.service.ts | 12 +-- .../shared/stores/addons/addons.state.spec.ts | 6 +- src/testing/osf.testing.module.ts | 3 +- 8 files changed, 79 insertions(+), 67 deletions(-) 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 c2d31777c..cb01f38f9 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 @@ -114,8 +114,6 @@ export class ConnectConfiguredAddonComponent { protected resourceUri = computed(() => { const id = this.route.parent?.parent?.snapshot.params['id']; - console.log('resourceUri id', id); - return `${environment.webUrl}/${id}`; }); 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 6bb812cf7..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,3 +1,5 @@ +import { Store } from '@ngxs/store'; + import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -5,7 +7,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { GoogleFilePickerDownloadService } from './service/google-file-picker.download.service'; import { GoogleFilePickerComponent } from './google-file-picker.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { OSFTestingModule, OSFTestingStoreModule } from '@testing/osf.testing.module'; describe('Component: Google File Picker', () => { let component: GoogleFilePickerComponent; @@ -32,8 +34,15 @@ describe('Component: Google File Picker', () => { 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: { @@ -63,7 +72,13 @@ describe('Component: Google File Picker', () => { await TestBed.configureTestingModule({ imports: [OSFTestingModule, GoogleFilePickerComponent], - providers: [{ provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy }], + providers: [ + { provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy }, + { + provide: Store, + useValue: storeMock, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(GoogleFilePickerComponent); @@ -71,6 +86,7 @@ describe('Component: Google File Picker', () => { 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(); }); @@ -96,7 +112,7 @@ describe('Component: Google File Picker', () => { expect(setAppId).toHaveBeenCalledWith('test-app-id'); expect(addView).toHaveBeenCalled(); expect(setTitle).toHaveBeenCalledWith('settings.addons.configureAddon.google-file-picker.root-folder-title'); - expect(setOAuthToken).toHaveBeenCalledWith(undefined); + expect(setOAuthToken).toHaveBeenCalledWith('mock-token'); expect(setCallback).toHaveBeenCalled(); expect(enableFeature).not.toHaveBeenCalled(); expect(build).toHaveBeenCalledWith(); @@ -106,6 +122,7 @@ describe('Component: Google File Picker', () => { describe('isFolderPicker - false', () => { beforeEach(async () => { + jest.clearAllMocks(); (window as any).google = { picker: { ViewId: { @@ -134,8 +151,14 @@ describe('Component: Google File Picker', () => { }; await TestBed.configureTestingModule({ - imports: [OSFTestingModule, GoogleFilePickerComponent], - providers: [{ provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy }], + imports: [OSFTestingStoreModule, GoogleFilePickerComponent], + providers: [ + { provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy }, + { + provide: Store, + useValue: storeMock, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(GoogleFilePickerComponent); @@ -159,16 +182,16 @@ describe('Component: Google File Picker', () => { expect(window.google.picker.DocsView).toHaveBeenCalledWith('docs'); expect(setSelectFolderEnabled).toHaveBeenCalledWith(true); - expect(setMimeTypes).toHaveBeenCalledWith('application/vnd.google-apps.folder'); + expect(setMimeTypes).not.toHaveBeenCalled(); expect(setIncludeFolders).toHaveBeenCalledWith(true); - expect(setParent).toHaveBeenCalledWith(''); + 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.root-folder-title'); - expect(setOAuthToken).toHaveBeenCalledWith(undefined); + 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(); 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 4b79bf81b..1cf7e386a 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,3 +1,5 @@ +import { Store } from '@ngxs/store'; + import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; @@ -5,6 +7,7 @@ import { Button } from 'primeng/button'; import { ChangeDetectionStrategy, Component, inject, input, OnInit, signal } from '@angular/core'; import { ENVIRONMENT } from '@core/constants/environment.token'; +import { AddonsSelectors, GetAuthorizedStorageOauthToken } from '@osf/shared/stores'; import { GoogleFilePickerDownloadService } from './service/google-file-picker.download.service'; @@ -22,24 +25,23 @@ export class GoogleFilePickerComponent implements OnInit { readonly #environment = inject(ENVIRONMENT); public isFolderPicker = input.required(); - - selectedFolderName = input(''); - rootFolderId = input(''); + public selectedFolderName = input(''); + public rootFolderId = input(''); + public accountId = input(''); // selectFolder?: (a: Partial) => void; // onRegisterChild?: (a: GoogleFilePickerWidget) => void; // manager: StorageManager; - // accountId: string; // @tracked openGoogleFilePicker = false; #folderName = signal(''); selectFolder = undefined; - accessToken!: string; + accessToken = signal(null); public visible = signal(false); public isGFPDisabled = signal(true); readonly #apiKey = this.#environment.google.GOOGLE_FILE_PICKER_API_KEY; readonly #appId = this.#environment.google.GOOGLE_FILE_PICKER_APP_ID; - #mimeTypes = ''; + readonly #store = inject(Store); #parentId = ''; #isMultipleSelect!: boolean; #title!: string; @@ -47,8 +49,6 @@ export class GoogleFilePickerComponent implements OnInit { ngOnInit(): void { // window.GoogleFilePickerWidget = this; // this.selectFolder = this.selectFolder(); - // taskFor(this.loadOauthToken).perform(); - this.#mimeTypes = this.isFolderPicker() ? 'application/vnd.google-apps.folder' : ''; this.#parentId = this.isFolderPicker() ? '' : this.rootFolderId(); this.#title = this.isFolderPicker() ? this.#translateService.instant('settings.addons.configureAddon.google-file-picker.root-folder-title') @@ -59,7 +59,10 @@ export class GoogleFilePickerComponent implements OnInit { this.#googlePicker.loadScript().subscribe({ next: () => { this.#googlePicker.loadGapiModules().subscribe({ - next: () => this.initializePicker(), + next: () => { + this.initializePicker(); + this.#loadOauthToken(); + }, // TODO add this error when the Sentry service is working //error: (err) => console.error('GAPI modules failed:', err), }); @@ -72,7 +75,6 @@ export class GoogleFilePickerComponent implements OnInit { public initializePicker() { if (this.isFolderPicker()) { this.visible.set(true); - this.isGFPDisabled.set(false); } } @@ -81,7 +83,9 @@ export class GoogleFilePickerComponent implements OnInit { const googlePickerView = new google.picker.DocsView(google.picker.ViewId.DOCS); googlePickerView.setSelectFolderEnabled(true); - googlePickerView.setMimeTypes(this.#mimeTypes); + if (this.isFolderPicker()) { + googlePickerView.setMimeTypes('application/vnd.google-apps.folder'); + } googlePickerView.setIncludeFolders(true); googlePickerView.setParent(this.#parentId); @@ -90,7 +94,7 @@ export class GoogleFilePickerComponent implements OnInit { .setAppId(this.#appId) .addView(googlePickerView) .setTitle(this.#title) - .setOAuthToken(this.accessToken) + .setOAuthToken(this.accessToken()) .setCallback(this.pickerCallback.bind(this)); if (this.#isMultipleSelect) { @@ -101,6 +105,19 @@ export class GoogleFilePickerComponent implements OnInit { 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 @@ -116,39 +133,6 @@ export class GoogleFilePickerComponent implements OnInit { } } -// // -// // 📚 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; -// } - -// @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; -// } -// } - // /** // * filePickerCallback // * diff --git a/src/app/shared/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts index 3469ae209..1667ef2e5 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -77,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/services/addons/addons.service.spec.ts b/src/app/shared/services/addons/addons.service.spec.ts index 5bcfc29fa..d5804fcd8 100644 --- a/src/app/shared/services/addons/addons.service.spec.ts +++ b/src/app/shared/services/addons/addons.service.spec.ts @@ -123,7 +123,12 @@ describe('Service: Addons', () => { }); const request = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); - expect(request.request.method).toBe('GET'); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body).toEqual( + Object({ + serializeOauthToken: true, + }) + ); request.flush(getAddonsAuthorizedStorageData(0)); expect(results).toEqual( diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index a38a97f91..e7795fa2d 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -119,13 +119,15 @@ export class AddonsService { getAuthorizedStorageOauthToken(accountId: string): Observable { return this.jsonApiService - - .get< - JsonApiResponse - >(`${environment.addonsApiUrl}/authorized-storage-accounts/${accountId}`) + .patch( + `${environment.addonsApiUrl}/authorized-storage-accounts/${accountId}`, + { + serializeOauthToken: true, + } + ) .pipe( map((response) => { - return AddonMapper.fromAuthorizedAddonResponse(response.data); + return AddonMapper.fromAuthorizedAddonResponse(response as AuthorizedAddonGetResponseJsonApi); }) ); } diff --git a/src/app/shared/stores/addons/addons.state.spec.ts b/src/app/shared/stores/addons/addons.state.spec.ts index 02a4e669d..fa013b10a 100644 --- a/src/app/shared/stores/addons/addons.state.spec.ts +++ b/src/app/shared/stores/addons/addons.state.spec.ts @@ -285,7 +285,7 @@ describe('State: Addons', () => { expect(loading()).toBeTruthy(); const request = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); - expect(request.request.method).toBe('GET'); + expect(request.request.method).toBe('PATCH'); request.flush(getAddonsAuthorizedStorageData(0)); expect(result[0]).toEqual( @@ -338,7 +338,7 @@ describe('State: Addons', () => { request.flush(getAddonsAuthorizedStorageData()); request = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); - expect(request.request.method).toBe('GET'); + expect(request.request.method).toBe('PATCH'); const addonWithToken = getAddonsAuthorizedStorageData(1); addonWithToken.data.attributes.oauth_token = 'ya2.34234324534'; request.flush(addonWithToken); @@ -393,7 +393,7 @@ describe('State: Addons', () => { expect(loading()).toBeTruthy(); const req = httpMock.expectOne('https://addons.staging4.osf.io/v1/authorized-storage-accounts/account-id'); - expect(req.request.method).toBe('GET'); + expect(req.request.method).toBe('PATCH'); req.flush({ message: 'Internal Server Error' }, { status: 500, statusText: 'Server Error' }); diff --git a/src/testing/osf.testing.module.ts b/src/testing/osf.testing.module.ts index 8c0b9c924..fd30cfa44 100644 --- a/src/testing/osf.testing.module.ts +++ b/src/testing/osf.testing.module.ts @@ -5,7 +5,7 @@ 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'; @@ -16,6 +16,7 @@ import { TranslationServiceMock } from './mocks/translation.service.mock'; @NgModule({ imports: [NoopAnimationsModule, BrowserModule, CommonModule, TranslateModule.forRoot()], providers: [ + provideNoopAnimations(), provideRouter([]), provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), From c05eaf47a91faf059ed5eff90021bbcc945a6904 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Tue, 26 Aug 2025 16:06:32 -0500 Subject: [PATCH 13/14] chore(refactor): updated a model to be more explicit in the name --- .../connect-configured-addon.component.ts | 19 +++++++++------- .../models/addon-config-actions.model.ts | 4 ++-- .../connect-addon/connect-addon.component.ts | 8 ++++--- .../addon-card-list.component.ts | 4 ++-- .../addons/addon-card/addon-card.component.ts | 4 ++-- .../addon-setup-account-form.component.ts | 4 ++-- .../addon-terms/addon-terms.component.ts | 6 ++--- src/app/shared/helpers/addon-type.helper.ts | 22 ++++++++++++++----- src/app/shared/mappers/addon.mapper.ts | 4 ++-- src/app/shared/models/addons/addons.models.ts | 19 ---------------- .../authorized-storage-account.model.ts | 18 +++++++++++++++ src/app/shared/models/addons/index.ts | 1 + .../services/addons/addon-dialog.service.ts | 4 ++-- .../services/addons/addon-form.service.ts | 16 ++++++++------ .../addon-operation-invocation.service.ts | 8 +++++-- .../shared/services/addons/addons.service.ts | 6 ++--- src/app/shared/stores/addons/addons.models.ts | 6 ++--- .../shared/stores/addons/addons.selectors.ts | 11 ++++++---- src/app/shared/stores/addons/addons.state.ts | 6 ++--- 19 files changed, 97 insertions(+), 73 deletions(-) create mode 100644 src/app/shared/models/addons/authorized-storage-account.model.ts 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 cb01f38f9..3da70bfc9 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 { AuthorizedStorageAccountModel } from '@osf/shared/models/addons/authorized-storage-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(''); @@ -127,7 +128,9 @@ 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 + | AuthorizedStorageAccountModel; if (!addon) { this.router.navigate([`${this.baseUrl()}/addons`]); } @@ -242,7 +245,7 @@ export class ConnectConfiguredAddonComponent { private processAuthorizedAddons( addonConfig: AddonConfigMap[keyof AddonConfigMap], - currentAddon: AddonModel | AuthorizedAddon + currentAddon: AddonModel | AuthorizedStorageAccountModel ) { const authorizedAddons = addonConfig.getAuthorizedAddons(); const matchingAddons = this.findMatchingAddons(authorizedAddons, currentAddon); @@ -260,9 +263,9 @@ export class ConnectConfiguredAddonComponent { } private findMatchingAddons( - authorizedAddons: AuthorizedAddon[], - currentAddon: AddonModel | AuthorizedAddon - ): AuthorizedAddon[] { + authorizedAddons: AuthorizedStorageAccountModel[], + currentAddon: AddonModel | AuthorizedStorageAccountModel + ): AuthorizedStorageAccountModel[] { 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..27131807f 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 { AuthorizedStorageAccountModel } from '@shared/models'; export interface AddonConfigActions { getAddons: () => Observable; - getAuthorizedAddons: () => AuthorizedAddon[]; + getAuthorizedAddons: () => AuthorizedStorageAccountModel[]; } 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..06b59875c 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, AuthorizedAddonRequestJsonApi, AuthorizedStorageAccountModel } 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,9 @@ 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 + | AuthorizedStorageAccountModel; 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..b4c68dce4 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, AuthorizedStorageAccountModel, 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 | AuthorizedStorageAccountModel | 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..352ad1623 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, AuthorizedStorageAccountModel, 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..691a423c7 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, AuthorizedAddonRequestJsonApi, AuthorizedStorageAccountModel } 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..63754332a 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, AuthorizedStorageAccountModel } 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 | AuthorizedStorageAccountModel): 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 9a1ec71dc..125000849 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, AuthorizedStorageAccountModel, ConfiguredStorageAddonModel } from '@shared/models'; -export function isStorageAddon(addon: AddonModel | AuthorizedAddon | ConfiguredStorageAddonModel | null): boolean { +export function isStorageAddon( + addon: AddonModel | AuthorizedStorageAccountModel | 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 | AuthorizedStorageAccountModel | 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 | AuthorizedStorageAccountModel | 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 | AuthorizedStorageAccountModel | 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 | AuthorizedStorageAccountModel | 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 1667ef2e5..bcb2522c9 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -1,8 +1,8 @@ import { AddonGetResponseJsonApi, AddonModel, - AuthorizedAddon, AuthorizedAddonGetResponseJsonApi, + AuthorizedStorageAccountModel, ConfiguredAddonGetResponseJsonApi, ConfiguredStorageAddonModel, IncludedAddonData, @@ -29,7 +29,7 @@ export class AddonMapper { static fromAuthorizedAddonResponse( response: AuthorizedAddonGetResponseJsonApi, included?: IncludedAddonData[] - ): AuthorizedAddon { + ): AuthorizedStorageAccountModel { const externalServiceData = response.relationships?.external_storage_service?.data || response.relationships?.external_citation_service?.data; diff --git a/src/app/shared/models/addons/addons.models.ts b/src/app/shared/models/addons/addons.models.ts index 2583b5382..acd5d0a57 100644 --- a/src/app/shared/models/addons/addons.models.ts +++ b/src/app/shared/models/addons/addons.models.ts @@ -228,25 +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; - oauthToken: string; - 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-storage-account.model.ts b/src/app/shared/models/addons/authorized-storage-account.model.ts new file mode 100644 index 000000000..a756911c7 --- /dev/null +++ b/src/app/shared/models/addons/authorized-storage-account.model.ts @@ -0,0 +1,18 @@ +export interface AuthorizedStorageAccountModel { + 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..b40fc08ed 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-storage-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..3d73c6902 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 { AuthorizedStorageAccountModel, 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: AuthorizedStorageAccountModel): 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..3c05178af 100644 --- a/src/app/shared/services/addons/addon-form.service.ts +++ b/src/app/shared/services/addons/addon-form.service.ts @@ -6,8 +6,8 @@ import { AddonFormControls, CredentialsFormat } from '@shared/enums'; import { AddonForm, AddonModel, - AuthorizedAddon, AuthorizedAddonRequestJsonApi, + AuthorizedStorageAccountModel, ConfiguredAddonRequestJsonApi, ConfiguredStorageAddonModel, } from '@shared/models'; @@ -18,7 +18,7 @@ import { export class AddonFormService { protected formBuilder: FormBuilder = inject(FormBuilder); - initializeForm(addon: AddonModel | AuthorizedAddon): FormGroup { + initializeForm(addon: AddonModel | AuthorizedStorageAccountModel): FormGroup { if (!addon) { return new FormGroup({} as AddonForm); } @@ -51,7 +51,7 @@ export class AddonFormService { generateAuthorizedAddonPayload( formValue: Record, - addon: AddonModel | AuthorizedAddon, + addon: AddonModel | AuthorizedStorageAccountModel, 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 | AuthorizedStorageAccountModel): string { + return isAuthorizedAddon(addon) + ? (addon as AuthorizedStorageAccountModel).externalStorageServiceId + : (addon as AddonModel).id; } generateConfiguredAddonCreatePayload( - addon: AddonModel | AuthorizedAddon, - selectedAccount: AuthorizedAddon, + addon: AddonModel | AuthorizedStorageAccountModel, + selectedAccount: AuthorizedStorageAccountModel, 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..47eba8a96 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,11 @@ import { Injectable } from '@angular/core'; import { OperationNames } from '@osf/features/project/addons/enums'; -import { AuthorizedAddon, ConfiguredStorageAddonModel, OperationInvocationRequestJsonApi } from '@shared/models'; +import { + AuthorizedStorageAccountModel, + ConfiguredStorageAddonModel, + OperationInvocationRequestJsonApi, +} from '@shared/models'; @Injectable({ providedIn: 'root', @@ -9,7 +13,7 @@ import { AuthorizedAddon, ConfiguredStorageAddonModel, OperationInvocationReques export class AddonOperationInvocationService { createInitialOperationInvocationPayload( operationName: OperationNames, - selectedAccount: AuthorizedAddon, + selectedAccount: AuthorizedStorageAccountModel, itemId?: string ): OperationInvocationRequestJsonApi { const operationKwargs = this.getOperationKwargs(operationName, itemId); diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index e7795fa2d..f06d26d66 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -9,10 +9,10 @@ import { AddonMapper } from '@shared/mappers'; import { AddonGetResponseJsonApi, AddonModel, - AuthorizedAddon, AuthorizedAddonGetResponseJsonApi, AuthorizedAddonRequestJsonApi, AuthorizedAddonResponseJsonApi, + AuthorizedStorageAccountModel, ConfiguredAddonGetResponseJsonApi, ConfiguredAddonRequestJsonApi, ConfiguredAddonResponseJsonApi, @@ -102,7 +102,7 @@ export class AddonsService { .pipe(map((response) => response.data)); } - getAuthorizedStorageAddons(addonType: string, referenceId: string): Observable { + getAuthorizedStorageAddons(addonType: string, referenceId: string): Observable { const params = { [`fields[external-${addonType}-services]`]: 'external_service_name', }; @@ -117,7 +117,7 @@ export class AddonsService { ); } - getAuthorizedStorageOauthToken(accountId: string): Observable { + getAuthorizedStorageOauthToken(accountId: string): Observable { return this.jsonApiService .patch( `${environment.addonsApiUrl}/authorized-storage-accounts/${accountId}`, diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index 48d1bdcf5..2ed5edc95 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -1,8 +1,8 @@ import { AddonModel, AsyncStateModel, - AuthorizedAddon, AuthorizedAddonResponseJsonApi, + AuthorizedStorageAccountModel, ConfiguredAddonResponseJsonApi, ConfiguredStorageAddonModel, OperationInvocation, @@ -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 448581ed4..35f046814 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -2,8 +2,8 @@ import { createSelector, Selector } from '@ngxs/store'; import { AddonModel, - AuthorizedAddon, AuthorizedAddonResponseJsonApi, + AuthorizedStorageAccountModel, ConfiguredAddonResponseJsonApi, ConfiguredStorageAddonModel, OperationInvocation, @@ -74,13 +74,16 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getAuthorizedStorageAddons(state: AddonsStateModel): AuthorizedAddon[] { + static getAuthorizedStorageAddons(state: AddonsStateModel): AuthorizedStorageAccountModel[] { 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: AuthorizedAddon) => addon.id === id)?.oauthToken || null; + return ( + state.authorizedStorageAddons.data.find((addon: AuthorizedStorageAccountModel) => addon.id === id) + ?.oauthToken || null + ); }); } @@ -90,7 +93,7 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getAuthorizedCitationAddons(state: AddonsStateModel): AuthorizedAddon[] { + static getAuthorizedCitationAddons(state: AddonsStateModel): AuthorizedStorageAccountModel[] { return state.authorizedCitationAddons.data; } diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index 48e2d4027..428cbaa1a 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -4,7 +4,7 @@ import { catchError, switchMap, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { AuthorizedAddon } from '@osf/shared/models'; +import { AuthorizedStorageAccountModel } from '@osf/shared/models'; import { AddonsService } from '@shared/services'; import { @@ -233,10 +233,10 @@ export class AddonsState { tap((addon) => { ctx.setState((state) => { const existing = state.authorizedStorageAddons.data.find( - (existingAddon: AuthorizedAddon) => existingAddon.id === addon.id + (existingAddon: AuthorizedStorageAccountModel) => existingAddon.id === addon.id ); const updatedData = existing - ? state.authorizedStorageAddons.data.map((existingAddon: AuthorizedAddon) => + ? state.authorizedStorageAddons.data.map((existingAddon: AuthorizedStorageAccountModel) => existingAddon.id === addon.id ? { ...existingAddon, ...addon } : existingAddon ) : [...state.authorizedStorageAddons.data, addon]; From a2f06b1971e6e4f5f0ae64d07bb156a89c6ba528 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Wed, 27 Aug 2025 11:36:39 -0500 Subject: [PATCH 14/14] chore(pr review): Updates to handle comments from a PR --- .../configure-addon.component.ts | 2 - .../connect-configured-addon.component.ts | 18 ++--- .../models/addon-config-actions.model.ts | 4 +- .../connect-addon/connect-addon.component.ts | 8 +- .../addon-card-list.component.ts | 4 +- .../addons/addon-card/addon-card.component.ts | 4 +- .../addon-setup-account-form.component.ts | 4 +- .../addon-terms/addon-terms.component.ts | 6 +- .../google-file-picker.component.html | 6 +- .../google-file-picker.component.ts | 36 ++++----- src/app/shared/helpers/addon-type.helper.ts | 12 +-- src/app/shared/mappers/addon.mapper.ts | 4 +- ...t.model.ts => authorized-account.model.ts} | 2 +- src/app/shared/models/addons/index.ts | 2 +- .../services/addons/addon-dialog.service.ts | 4 +- .../services/addons/addon-form.service.ts | 14 ++-- .../addon-operation-invocation.service.ts | 8 +- .../shared/services/addons/addons.service.ts | 6 +- src/app/shared/stores/addons/addons.models.ts | 6 +- .../shared/stores/addons/addons.selectors.ts | 9 +-- src/app/shared/stores/addons/addons.state.ts | 76 ++++++------------- 21 files changed, 98 insertions(+), 137 deletions(-) rename src/app/shared/models/addons/{authorized-storage-account.model.ts => authorized-account.model.ts} (89%) 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 9c1129d05..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 @@ -122,8 +122,6 @@ export class ConfigureAddonComponent implements OnInit { } private initializeAddon(): void { - // TODO this should be reviewed to have the addon be retrieved from the store - // I have limited my testing because it will create a false/positive test based on the required data // TODO this should be reviewed to have the addon be retrieved from the store // I have limited my testing because it will create a false/positive test based on the required data const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as ConfiguredStorageAddonModel; 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 3da70bfc9..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,7 +18,7 @@ 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 { AuthorizedStorageAccountModel } from '@osf/shared/models/addons/authorized-storage-account.model'; +import { AuthorizedAccountModel } from '@osf/shared/models/addons/authorized-account.model'; import { AddonSetupAccountFormComponent, AddonTermsComponent, @@ -75,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(''); @@ -128,9 +128,7 @@ export class ConnectConfiguredAddonComponent { }); constructor() { - const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as - | AddonModel - | AuthorizedStorageAccountModel; + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAccountModel; if (!addon) { this.router.navigate([`${this.baseUrl()}/addons`]); } @@ -245,7 +243,7 @@ export class ConnectConfiguredAddonComponent { private processAuthorizedAddons( addonConfig: AddonConfigMap[keyof AddonConfigMap], - currentAddon: AddonModel | AuthorizedStorageAccountModel + currentAddon: AddonModel | AuthorizedAccountModel ) { const authorizedAddons = addonConfig.getAuthorizedAddons(); const matchingAddons = this.findMatchingAddons(authorizedAddons, currentAddon); @@ -263,9 +261,9 @@ export class ConnectConfiguredAddonComponent { } private findMatchingAddons( - authorizedAddons: AuthorizedStorageAccountModel[], - currentAddon: AddonModel | AuthorizedStorageAccountModel - ): AuthorizedStorageAccountModel[] { + 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 27131807f..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 { AuthorizedStorageAccountModel } from '@shared/models'; +import { AuthorizedAccountModel } from '@shared/models'; export interface AddonConfigActions { getAddons: () => Observable; - getAuthorizedAddons: () => AuthorizedStorageAccountModel[]; + getAuthorizedAddons: () => AuthorizedAccountModel[]; } 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 06b59875c..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, AuthorizedAddonRequestJsonApi, AuthorizedStorageAccountModel } 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,9 +70,7 @@ export class ConnectAddonComponent { }); constructor() { - const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as - | AddonModel - | AuthorizedStorageAccountModel; + 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 b4c68dce4..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, AuthorizedStorageAccountModel, ConfiguredStorageAddonModel } from '@shared/models'; +import { AddonModel, AuthorizedAccountModel, ConfiguredStorageAddonModel } from '@shared/models'; @Component({ selector: 'osf-addon-card-list', @@ -12,7 +12,7 @@ import { AddonModel, AuthorizedStorageAccountModel, ConfiguredStorageAddonModel styleUrl: './addon-card-list.component.scss', }) export class AddonCardListComponent { - cards = input<(AddonModel | AuthorizedStorageAccountModel | 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 352ad1623..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, AuthorizedStorageAccountModel, 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 691a423c7..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, AuthorizedAddonRequestJsonApi, AuthorizedStorageAccountModel } 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 63754332a..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, AuthorizedStorageAccountModel } from '@shared/models'; +import { AddonModel, AddonTerm, AuthorizedAccountModel } from '@shared/models'; @Component({ selector: 'osf-addon-terms', @@ -16,7 +16,7 @@ import { AddonModel, AddonTerm, AuthorizedStorageAccountModel } from '@shared/mo 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 | AuthorizedStorageAccountModel): 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/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 75f231476..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 @@ -4,9 +4,9 @@
} 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 1cf7e386a..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 @@ -33,28 +33,28 @@ export class GoogleFilePickerComponent implements OnInit { // onRegisterChild?: (a: GoogleFilePickerWidget) => void; // manager: StorageManager; // @tracked openGoogleFilePicker = false; - #folderName = signal(''); + private folderName = signal(''); selectFolder = undefined; accessToken = signal(null); public visible = signal(false); public isGFPDisabled = signal(true); - readonly #apiKey = this.#environment.google.GOOGLE_FILE_PICKER_API_KEY; - readonly #appId = this.#environment.google.GOOGLE_FILE_PICKER_APP_ID; - readonly #store = inject(Store); - #parentId = ''; - #isMultipleSelect!: boolean; - #title!: string; + 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.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.isMultipleSelect = !this.isFolderPicker(); + this.folderName.set(this.selectedFolderName()); this.#googlePicker.loadScript().subscribe({ next: () => { @@ -87,17 +87,17 @@ export class GoogleFilePickerComponent implements OnInit { googlePickerView.setMimeTypes('application/vnd.google-apps.folder'); } googlePickerView.setIncludeFolders(true); - googlePickerView.setParent(this.#parentId); + googlePickerView.setParent(this.parentId); const pickerBuilder = new google.picker.PickerBuilder() - .setDeveloperKey(this.#apiKey) - .setAppId(this.#appId) + .setDeveloperKey(this.apiKey) + .setAppId(this.appId) .addView(googlePickerView) - .setTitle(this.#title) + .setTitle(this.title) .setOAuthToken(this.accessToken()) .setCallback(this.pickerCallback.bind(this)); - if (this.#isMultipleSelect) { + if (this.isMultipleSelect) { pickerBuilder.enableFeature(google.picker.Feature.MULTISELECT_ENABLED); } @@ -107,10 +107,10 @@ export class GoogleFilePickerComponent implements OnInit { #loadOauthToken(): void { if (this.accountId()) { - this.#store.dispatch(new GetAuthorizedStorageOauthToken(this.accountId())).subscribe({ + this.store.dispatch(new GetAuthorizedStorageOauthToken(this.accountId())).subscribe({ next: () => { this.accessToken.set( - this.#store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(this.accountId())) + this.store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(this.accountId())) ); this.isGFPDisabled.set(this.accessToken() ? false : true); }, diff --git a/src/app/shared/helpers/addon-type.helper.ts b/src/app/shared/helpers/addon-type.helper.ts index 125000849..9374eb6bf 100644 --- a/src/app/shared/helpers/addon-type.helper.ts +++ b/src/app/shared/helpers/addon-type.helper.ts @@ -1,7 +1,7 @@ -import { AddonModel, AuthorizedStorageAccountModel, ConfiguredStorageAddonModel } from '@shared/models'; +import { AddonModel, AuthorizedAccountModel, ConfiguredStorageAddonModel } from '@shared/models'; export function isStorageAddon( - addon: AddonModel | AuthorizedStorageAccountModel | ConfiguredStorageAddonModel | null + addon: AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel | null ): boolean { if (!addon) return false; @@ -13,7 +13,7 @@ export function isStorageAddon( } export function isCitationAddon( - addon: AddonModel | AuthorizedStorageAccountModel | ConfiguredStorageAddonModel | null + addon: AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel | null ): boolean { if (!addon) return false; @@ -25,7 +25,7 @@ export function isCitationAddon( } export function getAddonTypeString( - addon: AddonModel | AuthorizedStorageAccountModel | ConfiguredStorageAddonModel | null + addon: AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel | null ): string { if (!addon) return ''; @@ -33,7 +33,7 @@ export function getAddonTypeString( } export function isAuthorizedAddon( - addon: AddonModel | AuthorizedStorageAccountModel | ConfiguredStorageAddonModel | null + addon: AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel | null ): boolean { if (!addon) return false; @@ -41,7 +41,7 @@ export function isAuthorizedAddon( } export function isConfiguredAddon( - addon: AddonModel | AuthorizedStorageAccountModel | ConfiguredStorageAddonModel | null + addon: AddonModel | AuthorizedAccountModel | ConfiguredStorageAddonModel | null ): boolean { if (!addon) return false; diff --git a/src/app/shared/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts index bcb2522c9..2c3f5f96e 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -1,8 +1,8 @@ import { AddonGetResponseJsonApi, AddonModel, + AuthorizedAccountModel, AuthorizedAddonGetResponseJsonApi, - AuthorizedStorageAccountModel, ConfiguredAddonGetResponseJsonApi, ConfiguredStorageAddonModel, IncludedAddonData, @@ -29,7 +29,7 @@ export class AddonMapper { static fromAuthorizedAddonResponse( response: AuthorizedAddonGetResponseJsonApi, included?: IncludedAddonData[] - ): AuthorizedStorageAccountModel { + ): AuthorizedAccountModel { const externalServiceData = response.relationships?.external_storage_service?.data || response.relationships?.external_citation_service?.data; diff --git a/src/app/shared/models/addons/authorized-storage-account.model.ts b/src/app/shared/models/addons/authorized-account.model.ts similarity index 89% rename from src/app/shared/models/addons/authorized-storage-account.model.ts rename to src/app/shared/models/addons/authorized-account.model.ts index a756911c7..f517c3f81 100644 --- a/src/app/shared/models/addons/authorized-storage-account.model.ts +++ b/src/app/shared/models/addons/authorized-account.model.ts @@ -1,4 +1,4 @@ -export interface AuthorizedStorageAccountModel { +export interface AuthorizedAccountModel { type: string; id: string; displayName: string; diff --git a/src/app/shared/models/addons/index.ts b/src/app/shared/models/addons/index.ts index b40fc08ed..9c22ecdad 100644 --- a/src/app/shared/models/addons/index.ts +++ b/src/app/shared/models/addons/index.ts @@ -1,7 +1,7 @@ export * from './addon-form.model'; export * from './addon-terms.model'; export * from './addons.models'; -export * from './authorized-storage-account.model'; +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 3d73c6902..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 { AuthorizedStorageAccountModel, 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: AuthorizedStorageAccountModel): 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 3c05178af..365aba3db 100644 --- a/src/app/shared/services/addons/addon-form.service.ts +++ b/src/app/shared/services/addons/addon-form.service.ts @@ -6,8 +6,8 @@ import { AddonFormControls, CredentialsFormat } from '@shared/enums'; import { AddonForm, AddonModel, + AuthorizedAccountModel, AuthorizedAddonRequestJsonApi, - AuthorizedStorageAccountModel, ConfiguredAddonRequestJsonApi, ConfiguredStorageAddonModel, } from '@shared/models'; @@ -18,7 +18,7 @@ import { export class AddonFormService { protected formBuilder: FormBuilder = inject(FormBuilder); - initializeForm(addon: AddonModel | AuthorizedStorageAccountModel): 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 | AuthorizedStorageAccountModel, + addon: AddonModel | AuthorizedAccountModel, userReferenceId: string, addonTypeString: string ): AuthorizedAddonRequestJsonApi { @@ -107,15 +107,15 @@ export class AddonFormService { }; } - private getAddonServiceId(addon: AddonModel | AuthorizedStorageAccountModel): string { + private getAddonServiceId(addon: AddonModel | AuthorizedAccountModel): string { return isAuthorizedAddon(addon) - ? (addon as AuthorizedStorageAccountModel).externalStorageServiceId + ? (addon as AuthorizedAccountModel).externalStorageServiceId : (addon as AddonModel).id; } generateConfiguredAddonCreatePayload( - addon: AddonModel | AuthorizedStorageAccountModel, - selectedAccount: AuthorizedStorageAccountModel, + 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 47eba8a96..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,11 +1,7 @@ import { Injectable } from '@angular/core'; import { OperationNames } from '@osf/features/project/addons/enums'; -import { - AuthorizedStorageAccountModel, - ConfiguredStorageAddonModel, - OperationInvocationRequestJsonApi, -} from '@shared/models'; +import { AuthorizedAccountModel, ConfiguredStorageAddonModel, OperationInvocationRequestJsonApi } from '@shared/models'; @Injectable({ providedIn: 'root', @@ -13,7 +9,7 @@ import { export class AddonOperationInvocationService { createInitialOperationInvocationPayload( operationName: OperationNames, - selectedAccount: AuthorizedStorageAccountModel, + selectedAccount: AuthorizedAccountModel, itemId?: string ): OperationInvocationRequestJsonApi { const operationKwargs = this.getOperationKwargs(operationName, itemId); diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index f06d26d66..3c5634766 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -9,10 +9,10 @@ import { AddonMapper } from '@shared/mappers'; import { AddonGetResponseJsonApi, AddonModel, + AuthorizedAccountModel, AuthorizedAddonGetResponseJsonApi, AuthorizedAddonRequestJsonApi, AuthorizedAddonResponseJsonApi, - AuthorizedStorageAccountModel, ConfiguredAddonGetResponseJsonApi, ConfiguredAddonRequestJsonApi, ConfiguredAddonResponseJsonApi, @@ -102,7 +102,7 @@ export class AddonsService { .pipe(map((response) => response.data)); } - getAuthorizedStorageAddons(addonType: string, referenceId: string): Observable { + getAuthorizedStorageAddons(addonType: string, referenceId: string): Observable { const params = { [`fields[external-${addonType}-services]`]: 'external_service_name', }; @@ -117,7 +117,7 @@ export class AddonsService { ); } - getAuthorizedStorageOauthToken(accountId: string): Observable { + getAuthorizedStorageOauthToken(accountId: string): Observable { return this.jsonApiService .patch( `${environment.addonsApiUrl}/authorized-storage-accounts/${accountId}`, diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index 2ed5edc95..2834fd1b3 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -1,8 +1,8 @@ import { AddonModel, AsyncStateModel, + AuthorizedAccountModel, AuthorizedAddonResponseJsonApi, - AuthorizedStorageAccountModel, ConfiguredAddonResponseJsonApi, ConfiguredStorageAddonModel, OperationInvocation, @@ -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 35f046814..b200e0b2d 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -2,8 +2,8 @@ import { createSelector, Selector } from '@ngxs/store'; import { AddonModel, + AuthorizedAccountModel, AuthorizedAddonResponseJsonApi, - AuthorizedStorageAccountModel, ConfiguredAddonResponseJsonApi, ConfiguredStorageAddonModel, OperationInvocation, @@ -74,15 +74,14 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getAuthorizedStorageAddons(state: AddonsStateModel): AuthorizedStorageAccountModel[] { + 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: AuthorizedStorageAccountModel) => addon.id === id) - ?.oauthToken || null + state.authorizedStorageAddons.data.find((addon: AuthorizedAccountModel) => addon.id === id)?.oauthToken || null ); }); } @@ -93,7 +92,7 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getAuthorizedCitationAddons(state: AddonsStateModel): AuthorizedStorageAccountModel[] { + static getAuthorizedCitationAddons(state: AddonsStateModel): AuthorizedAccountModel[] { return state.authorizedCitationAddons.data; } diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index 428cbaa1a..e7d1fe182 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -1,10 +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 { AuthorizedStorageAccountModel } from '@osf/shared/models'; +import { handleSectionError } from '@osf/shared/helpers'; +import { AuthorizedAccountModel } from '@osf/shared/models'; import { AddonsService } from '@shared/services'; import { @@ -19,7 +20,7 @@ import { GetAddonsUserReference, GetAuthorizedCitationAddons, GetAuthorizedStorageAddons, - GetAuthorizedStorageOauthToken as GetAuthorizedtorageOauthToken, + GetAuthorizedStorageOauthToken, GetCitationAddons, GetConfiguredCitationAddons, GetConfiguredStorageAddons, @@ -167,7 +168,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'storageAddons', error)) + catchError((error) => handleSectionError(ctx, 'storageAddons', error)) ); } @@ -191,7 +192,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'citationAddons', error)) + catchError((error) => handleSectionError(ctx, 'citationAddons', error)) ); } @@ -215,12 +216,12 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'authorizedStorageAddons', error)) + catchError((error) => handleSectionError(ctx, 'authorizedStorageAddons', error)) ); } - @Action(GetAuthorizedtorageOauthToken) - getAuthorizedStorageOauthToken(ctx: StateContext, action: GetAuthorizedtorageOauthToken) { + @Action(GetAuthorizedStorageOauthToken) + getAuthorizedStorageOauthToken(ctx: StateContext, action: GetAuthorizedStorageOauthToken) { const state = ctx.getState(); ctx.patchState({ authorizedStorageAddons: { @@ -233,10 +234,10 @@ export class AddonsState { tap((addon) => { ctx.setState((state) => { const existing = state.authorizedStorageAddons.data.find( - (existingAddon: AuthorizedStorageAccountModel) => existingAddon.id === addon.id + (existingAddon: AuthorizedAccountModel) => existingAddon.id === addon.id ); const updatedData = existing - ? state.authorizedStorageAddons.data.map((existingAddon: AuthorizedStorageAccountModel) => + ? state.authorizedStorageAddons.data.map((existingAddon: AuthorizedAccountModel) => existingAddon.id === addon.id ? { ...existingAddon, ...addon } : existingAddon ) : [...state.authorizedStorageAddons.data, addon]; @@ -252,7 +253,7 @@ export class AddonsState { }; }); }), - catchError((error) => this.handleError(ctx, 'authorizedStorageAddons', error)) + catchError((error) => handleSectionError(ctx, 'authorizedStorageAddons', error)) ); } @@ -276,7 +277,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'authorizedCitationAddons', error)) + catchError((error) => handleSectionError(ctx, 'authorizedCitationAddons', error)) ); } @@ -316,7 +317,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'configuredStorageAddons', error)) + catchError((error) => handleSectionError(ctx, 'configuredStorageAddons', error)) ); } @@ -340,7 +341,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'configuredCitationAddons', error)) + catchError((error) => handleSectionError(ctx, 'configuredCitationAddons', error)) ); } @@ -373,7 +374,7 @@ export class AddonsState { ); } }), - catchError((error) => this.handleError(ctx, 'createdUpdatedAuthorizedAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedAuthorizedAddon', error)) ); } @@ -406,7 +407,7 @@ export class AddonsState { ); } }), - catchError((error) => this.handleError(ctx, 'createdUpdatedAuthorizedAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedAuthorizedAddon', error)) ); } @@ -431,7 +432,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'createdUpdatedConfiguredAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedConfiguredAddon', error)) ); } @@ -455,7 +456,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'addonsUserReference', error)) + catchError((error) => handleSectionError(ctx, 'addonsUserReference', error)) ); } @@ -494,7 +495,7 @@ export class AddonsState { ); } }), - catchError((error) => this.handleError(ctx, 'createdUpdatedAuthorizedAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedAuthorizedAddon', error)) ); } @@ -518,7 +519,7 @@ export class AddonsState { }, }); }), - catchError((error) => this.handleError(ctx, 'addonsResourceReference', error)) + catchError((error) => handleSectionError(ctx, 'addonsResourceReference', error)) ); } @@ -543,7 +544,7 @@ export class AddonsState { } return []; }), - catchError((error) => this.handleError(ctx, stateKey, error)) + catchError((error) => handleSectionError(ctx, stateKey, error)) ); } @@ -574,7 +575,7 @@ export class AddonsState { } return []; }), - catchError((error) => this.handleError(ctx, 'createdUpdatedConfiguredAddon', error)) + catchError((error) => handleSectionError(ctx, 'createdUpdatedConfiguredAddon', error)) ); } @@ -610,7 +611,7 @@ export class AddonsState { }); } }), - catchError((error) => this.handleError(ctx, 'operationInvocation', error)) + catchError((error) => handleSectionError(ctx, 'operationInvocation', error)) ); } @@ -650,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); - } }