diff --git a/eslint.config.js b/eslint.config.js index 1b4ca309c..8e4c4d961 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -112,7 +112,7 @@ module.exports = tseslint.config( }, }, { - files: ['**/*.spec.ts'], + files: ['**/*.spec.ts', 'src/testing/**/*.ts'], rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-empty-function': 'off', diff --git a/jest.config.js b/jest.config.js index fe3f13fc9..49577581b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -67,7 +67,9 @@ module.exports = { '/src/app/features/project/addons/components/connect-configured-addon/', '/src/app/features/project/addons/components/disconnect-addon-modal/', '/src/app/features/project/addons/components/confirm-account-connection-modal/', - '/src/app/features/files/', + '/src/app/features/files/components', + '/src/app/features/files/pages/community-metadata', + '/src/app/features/files/pages/file-detail', '/src/app/features/my-projects/', '/src/app/features/preprints/', '/src/app/features/project/contributors/', diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 7841a8a30..db54afe8a 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -48,7 +48,7 @@
+ + @if (isGoogleDrive()) { + + + + } }
diff --git a/src/app/features/files/pages/files/files.component.spec.ts b/src/app/features/files/pages/files/files.component.spec.ts index 7d4cfe6ca..2b0748949 100644 --- a/src/app/features/files/pages/files/files.component.spec.ts +++ b/src/app/features/files/pages/files/files.component.spec.ts @@ -1,26 +1,186 @@ -import { MockComponent } from 'ng-mocks'; +import { Store } from '@ngxs/store'; +import { TranslatePipe } from '@ngx-translate/core'; +import { MockProvider } from 'ng-mocks'; + +import { Button } from 'primeng/button'; +import { Dialog } from 'primeng/dialog'; +import { DialogService } from 'primeng/dynamicdialog'; +import { TableModule } from 'primeng/table'; + +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; -import { SubHeaderComponent } from '@osf/shared/components'; +import { + FilesTreeComponent, + FormSelectComponent, + LoadingSpinnerComponent, + SearchInputComponent, + SubHeaderComponent, + ViewOnlyLinkMessageComponent, +} from '@osf/shared/components'; +import { GoogleFilePickerComponent } from '@osf/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component'; +import { OsfFile } from '@osf/shared/models'; +import { CustomConfirmationService, FilesService } from '@osf/shared/services'; + +import { FilesSelectors } from '../../store'; import { FilesComponent } from './files.component'; -describe('FilesComponent', () => { +import { getConfiguredAddonsMappedData } from '@testing/data/addons/addons.configured.data'; +import { getNodeFilesMappedData } from '@testing/data/files/node.data'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('Component: Files', () => { let component: FilesComponent; let fixture: ComponentFixture; + const currentFolderSignal = signal(getNodeFilesMappedData(0)); beforeEach(async () => { + jest.clearAllMocks(); await TestBed.configureTestingModule({ - imports: [FilesComponent, MockComponent(SubHeaderComponent)], - }).compileComponents(); + imports: [ + OSFTestingModule, + FilesComponent, + Button, + Dialog, + FormSelectComponent, + FormsModule, + GoogleFilePickerComponent, + LoadingSpinnerComponent, + ReactiveFormsModule, + SearchInputComponent, + SubHeaderComponent, + TableModule, + TranslatePipe, + ViewOnlyLinkMessageComponent, + ], + providers: [ + FilesService, + MockProvider(ActivatedRoute), + MockProvider(CustomConfirmationService), + + DialogService, + provideMockStore({ + signals: [ + { + selector: FilesSelectors.getRootFolders, + value: getNodeFilesMappedData(), + }, + { + selector: FilesSelectors.getCurrentFolder, + value: currentFolderSignal(), + }, + { + selector: FilesSelectors.getConfiguredStorageAddons, + value: getConfiguredAddonsMappedData(), + }, + ], + }), + ], + }) + .overrideComponent(FilesComponent, { + remove: { + imports: [FilesTreeComponent], + }, + add: { + imports: [ + MockComponentWithSignal('osf-files-tree', [ + 'files', + 'currentFolder', + 'isLoading', + 'actions', + 'viewOnly', + 'viewOnlyDownloadable', + 'resourceId', + 'provider', + ]), + ], + }, + }) + .compileComponents(); fixture = TestBed.createComponent(FilesComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('CurrentRootFolder effect', () => { + it('should handle the initial effects', () => { + expect(component.currentRootFolder()?.folder.name).toBe('osfstorage'); + expect(component.isGoogleDrive()).toBeFalsy(); + expect(component.accountId()).toBeFalsy(); + expect(component.selectedRootFolder()).toEqual(Object({})); + }); + + it('should handle changing the folder to googledrive', () => { + component.currentRootFolder.set( + Object({ + label: 'label', + folder: Object({ + name: 'Google Drive', + provider: 'googledrive', + }), + }) + ); + + fixture.detectChanges(); + + expect(component.currentRootFolder()?.folder.name).toBe('Google Drive'); + expect(component.isGoogleDrive()).toBeTruthy(); + expect(component.accountId()).toBe('62ed6dd7-f7b7-4003-b7b4-855789c1f991'); + expect(component.selectedRootFolder()).toEqual( + Object({ + itemId: '0AIl0aR4C9JAFUk9PVA', + }) + ); + }); + }); + + describe('updateFilesList', () => { + it('should handle the updateFilesList with a filesLink', () => { + let results!: string; + const store = TestBed.inject(Store); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + dispatchSpy.mockClear(); + jest.spyOn(component.filesTreeActions, 'setFilesIsLoading'); + component.updateFilesList().subscribe({ + next: (result) => { + results = result as any; + }, + }); + + expect(results).toBeTruthy(); + + expect(component.filesTreeActions.setFilesIsLoading).toHaveBeenCalledWith(true); + expect(dispatchSpy).toHaveBeenCalledWith({ + filesLink: 'https://api.staging4.osf.io/v2/nodes/xgrm4/files/osfstorage/', + }); + }); + + it('should handle the updateFilesList without a filesLink', () => { + let results!: string; + const currentFolder = currentFolderSignal() as OsfFile; + currentFolder.relationships.filesLink = ''; + currentFolderSignal.set(currentFolder); + const store = TestBed.inject(Store); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + dispatchSpy.mockClear(); + jest.spyOn(component.filesTreeActions, 'setFilesIsLoading'); + component.updateFilesList().subscribe({ + next: (result) => { + results = result as any; + }, + }); + + expect(results).toBeUndefined(); + + expect(component.filesTreeActions.setFilesIsLoading).not.toHaveBeenCalled(); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index e10087877..ed1a6e839 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -23,6 +23,7 @@ import { inject, model, signal, + viewChild, } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -42,18 +43,19 @@ import { SetSearch, SetSort, } from '@osf/features/files/store'; -import { ALL_SORT_OPTIONS } from '@osf/shared/constants'; -import { ResourceType } from '@osf/shared/enums'; -import { hasViewOnlyParam, IS_MEDIUM } from '@osf/shared/helpers'; import { FilesTreeComponent, FormSelectComponent, LoadingSpinnerComponent, SearchInputComponent, SubHeaderComponent, -} from '@shared/components'; -import { ViewOnlyLinkMessageComponent } from '@shared/components/view-only-link-message/view-only-link-message.component'; -import { ConfiguredStorageAddonModel, FilesTreeActions, OsfFile } from '@shared/models'; + ViewOnlyLinkMessageComponent, +} from '@osf/shared/components'; +import { GoogleFilePickerComponent } from '@osf/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component'; +import { ALL_SORT_OPTIONS } from '@osf/shared/constants'; +import { ResourceType } from '@osf/shared/enums'; +import { hasViewOnlyParam, IS_MEDIUM } from '@osf/shared/helpers'; +import { ConfiguredStorageAddonModel, FilesTreeActions, OsfFile, StorageItemModel } from '@shared/models'; import { FilesService } from '@shared/services'; import { CreateFolderDialogComponent, FileBrowserInfoComponent } from '../../components'; @@ -65,19 +67,20 @@ import { environment } from 'src/environments/environment'; @Component({ selector: 'osf-files', imports: [ - TableModule, Button, - FloatLabel, - SubHeaderComponent, - SearchInputComponent, - Select, - LoadingSpinnerComponent, Dialog, + FilesTreeComponent, + FloatLabel, + FormSelectComponent, FormsModule, + GoogleFilePickerComponent, + LoadingSpinnerComponent, ReactiveFormsModule, + SearchInputComponent, + Select, + SubHeaderComponent, + TableModule, TranslatePipe, - FilesTreeComponent, - FormSelectComponent, ViewOnlyLinkMessageComponent, ], templateUrl: './files.component.html', @@ -86,6 +89,8 @@ import { environment } from 'src/environments/environment'; providers: [DialogService, TreeDragDropService], }) export class FilesComponent { + googleFilePickerComponent = viewChild(GoogleFilePickerComponent); + @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; private readonly filesService = inject(FilesService); @@ -119,6 +124,9 @@ export class FilesComponent { readonly currentFolder = select(FilesSelectors.getCurrentFolder); readonly provider = select(FilesSelectors.getProvider); + readonly isGoogleDrive = signal(false); + readonly accountId = signal(''); + readonly selectedRootFolder = signal({}); readonly resourceId = signal(''); readonly rootFolders = select(FilesSelectors.getRootFolders); readonly isRootFoldersLoading = select(FilesSelectors.isRootFoldersLoading); @@ -199,10 +207,10 @@ export class FilesComponent { effect(() => { const rootFolders = this.rootFolders(); if (rootFolders) { - const osfRootFolder = rootFolders.find((folder) => folder.provider === 'osfstorage'); + const osfRootFolder = rootFolders.find((folder: OsfFile) => folder.provider === 'osfstorage'); if (osfRootFolder) { this.currentRootFolder.set({ - label: 'Osf Storage', + label: this.translateService.instant('files.storageLocation'), folder: osfRootFolder, }); } @@ -212,6 +220,10 @@ export class FilesComponent { effect(() => { const currentRootFolder = this.currentRootFolder(); if (currentRootFolder) { + this.isGoogleDrive.set(currentRootFolder.folder.provider === 'googledrive'); + if (this.isGoogleDrive()) { + this.setGoogleAccountId(); + } this.actions.setCurrentFolder(currentRootFolder.folder); } }); @@ -245,6 +257,10 @@ export class FilesComponent { }); } + isButtonDisabled(): boolean { + return this.fileIsUploading() || this.isFilesLoading(); + } + uploadFile(file: File): void { const currentFolder = this.currentFolder(); const uploadLink = currentFolder?.links.upload; @@ -348,7 +364,7 @@ export class FilesComponent { }); } - updateFilesList(): Observable { + public updateFilesList = (): Observable => { const currentFolder = this.currentFolder(); if (currentFolder?.relationships.filesLink) { this.filesTreeActions.setFilesIsLoading?.(true); @@ -356,7 +372,7 @@ export class FilesComponent { } return EMPTY; - } + }; folderIsOpening(value: boolean): void { this.isFolderOpening.set(value); @@ -372,9 +388,25 @@ export class FilesComponent { getAddonName(addons: ConfiguredStorageAddonModel[], provider: string): string { if (provider === 'osfstorage') { - return 'Osf Storage'; + return this.translateService.instant('files.storageLocation'); } else { return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; } } + + private setGoogleAccountId(): void { + const addons = this.configuredStorageAddons(); + const googleDrive = addons?.find((addon) => addon.externalServiceName === 'googledrive'); + if (googleDrive) { + this.accountId.set(googleDrive.baseAccountId); + this.selectedRootFolder.set({ + itemId: googleDrive.selectedFolderId, + }); + } + } + + openGoogleFilePicker(): void { + this.googleFilePickerComponent()?.createPicker(); + this.updateFilesList(); + } } 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 6decdbcda..70333fcb7 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 @@ -30,7 +30,7 @@ export class GoogleFilePickerComponent implements OnInit { public isFolderPicker = input.required(); public rootFolder = input(null); public accountId = input(''); - public handleFolderSelection = input.required<(folder: StorageItemModel) => void>(); + public handleFolderSelection = input<(folder: StorageItemModel) => void>(); public accessToken = signal(null); public visible = signal(false); @@ -112,7 +112,7 @@ export class GoogleFilePickerComponent implements OnInit { } #filePickerCallback(data: GoogleFileDataModel) { - this.handleFolderSelection()( + this.handleFolderSelection()?.( Object({ itemName: data.name, itemId: data.id, diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index 743c40d0c..2c8052070 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -28,13 +28,15 @@ import { ActivatedRoute, Router } from '@angular/router'; import { MoveFileDialogComponent, RenameFileDialogComponent } from '@osf/features/files/components'; import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants'; import { FileMenuType } from '@osf/shared/enums'; -import { FileMenuComponent, LoadingSpinnerComponent } from '@shared/components'; import { StopPropagationDirective } from '@shared/directives'; import { hasViewOnlyParam } from '@shared/helpers'; import { FileMenuAction, FilesTreeActions, OsfFile } from '@shared/models'; import { FileSizePipe } from '@shared/pipes'; import { CustomConfirmationService, FilesService, ToastService } from '@shared/services'; +import { FileMenuComponent } from '../file-menu/file-menu.component'; +import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; + import { environment } from 'src/environments/environment'; @Component({ diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.ts b/src/app/shared/components/resource-metadata/resource-metadata.component.ts index e4dce6506..a2903fd98 100644 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.ts +++ b/src/app/shared/components/resource-metadata/resource-metadata.component.ts @@ -8,11 +8,12 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core import { RouterLink } from '@angular/router'; import { OverviewCollectionsComponent } from '@osf/features/project/overview/components/overview-collections/overview-collections.component'; -import { AffiliatedInstitutionsViewComponent, TruncatedTextComponent } from '@shared/components'; +import { AffiliatedInstitutionsViewComponent } from '@shared/components'; import { OsfResourceTypes } from '@shared/constants'; import { ResourceOverview } from '@shared/models'; import { ResourceCitationsComponent } from '../resource-citations/resource-citations.component'; +import { TruncatedTextComponent } from '../truncated-text/truncated-text.component'; @Component({ selector: 'osf-resource-metadata', diff --git a/src/app/shared/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts index 2c3f5f96e..3594eacb6 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -91,6 +91,7 @@ export class AddonMapper { baseAccountId: response.relationships.base_account.data.id, baseAccountType: response.relationships.base_account.data.type, externalStorageServiceId: response.relationships?.external_storage_service?.data?.id, + rootFolderId: response.attributes.root_folder, }; } diff --git a/src/app/shared/models/addons/configured-storage-addon.model.ts b/src/app/shared/models/addons/configured-storage-addon.model.ts index 4ac031aee..d3e907e4d 100644 --- a/src/app/shared/models/addons/configured-storage-addon.model.ts +++ b/src/app/shared/models/addons/configured-storage-addon.model.ts @@ -49,4 +49,8 @@ export interface ConfiguredStorageAddonModel { * Optional: If linked to a parent storage service, provides its ID and name. */ externalStorageServiceId?: string; + /** + * Optional: The root folder id + */ + rootFolderId?: string; } diff --git a/src/app/shared/models/files/get-configured-storage-addons.model.ts b/src/app/shared/models/files/get-configured-storage-addons.model.ts deleted file mode 100644 index f386715c5..000000000 --- a/src/app/shared/models/files/get-configured-storage-addons.model.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiData, JsonApiResponse } from '@shared/models'; - -export type GetConfiguredStorageAddonsJsonApi = JsonApiResponse< - ApiData< - { - display_name: string; - external_service_name: string; - }, - null, - null, - null - >[], - null ->; diff --git a/src/app/shared/models/files/index.ts b/src/app/shared/models/files/index.ts index 642857f30..d27ecbfe7 100644 --- a/src/app/shared/models/files/index.ts +++ b/src/app/shared/models/files/index.ts @@ -4,6 +4,5 @@ export * from './file-payload-json-api.model'; export * from './file-version.model'; export * from './file-version-json-api.model'; export * from './files-tree-actions.interface'; -export * from './get-configured-storage-addons.model'; export * from './get-files-response.model'; export * from './resource-files-links.model'; diff --git a/src/app/shared/services/addons/addons.service.spec.ts b/src/app/shared/services/addons/addons.service.spec.ts index 854e92d51..3cacb9053 100644 --- a/src/app/shared/services/addons/addons.service.spec.ts +++ b/src/app/shared/services/addons/addons.service.spec.ts @@ -68,6 +68,7 @@ describe('Service: Addons', () => { externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', currentUserIsOwner: true, displayName: 'Google Drive', + rootFolderId: '0AIl0aR4C9JAFUk9PVA', externalServiceName: 'googledrive', id: '756579dc-3a24-4849-8866-698a60846ac3', selectedFolderId: '0AIl0aR4C9JAFUk9PVA', diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index bb05de2d8..8246587e5 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -68,11 +68,7 @@ export class AddonsService { .get< JsonApiResponse >(`${environment.addonsApiUrl}/external-${addonType}-services`) - .pipe( - map((response) => { - return response.data.map((item) => AddonMapper.fromResponse(item)); - }) - ); + .pipe(map((response) => response.data.map((item) => AddonMapper.fromResponse(item)))); } getAddonsUserReference(): Observable { @@ -111,9 +107,7 @@ export class AddonsService { JsonApiResponse >(`${environment.addonsApiUrl}/user-references/${referenceId}/authorized_${addonType}_accounts/?include=external-${addonType}-service`, params) .pipe( - map((response) => { - return response.data.map((item) => AddonMapper.fromAuthorizedAddonResponse(item, response.included)); - }) + map((response) => response.data.map((item) => AddonMapper.fromAuthorizedAddonResponse(item, response.included))) ); } diff --git a/src/app/shared/services/files.service.spec.ts b/src/app/shared/services/files.service.spec.ts new file mode 100644 index 000000000..239dfa9f5 --- /dev/null +++ b/src/app/shared/services/files.service.spec.ts @@ -0,0 +1,77 @@ +import { HttpTestingController } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { FilesService } from './files.service'; + +import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; +import { getResourceReferencesData } from '@testing/data/files/resource-references.data'; +import { OSFTestingStoreModule } from '@testing/osf.testing.module'; + +describe('Service: Files', () => { + let service: FilesService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [OSFTestingStoreModule], + providers: [FilesService], + }); + + service = TestBed.inject(FilesService); + }); + + it('should test getResourceReferences', inject([HttpTestingController], (httpMock: HttpTestingController) => { + let results!: string; + service.getResourceReferences('reference-url').subscribe({ + next: (result) => { + results = result; + }, + }); + + const request = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/resource-references?filter%5Bresource_uri%5D=reference-url' + ); + expect(request.request.method).toBe('GET'); + request.flush(getResourceReferencesData()); + + expect(results).toBe('https://addons.staging4.osf.io/v1/resource-references/3193f97c-e6d8-41a4-8312-b73483442086'); + expect(httpMock.verify).toBeTruthy(); + })); + + it('should test getConfiguredStorageAddons', inject([HttpTestingController], (httpMock: HttpTestingController) => { + let results: any[] = []; + service.getConfiguredStorageAddons('reference-url').subscribe((result) => { + results = result; + }); + + let request = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/resource-references?filter%5Bresource_uri%5D=reference-url' + ); + expect(request.request.method).toBe('GET'); + request.flush(getResourceReferencesData()); + + request = httpMock.expectOne( + 'https://addons.staging4.osf.io/v1/resource-references/3193f97c-e6d8-41a4-8312-b73483442086/configured_storage_addons' + ); + expect(request.request.method).toBe('GET'); + request.flush(getConfiguredAddonsData()); + + expect(results[0]).toEqual( + Object({ + baseAccountId: '62ed6dd7-f7b7-4003-b7b4-855789c1f991', + baseAccountType: 'authorized-storage-accounts', + connectedCapabilities: ['ACCESS', 'UPDATE'], + connectedOperationNames: ['list_child_items', 'list_root_items', 'get_item_info'], + currentUserIsOwner: true, + displayName: 'Google Drive', + externalServiceName: 'googledrive', + externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + id: '756579dc-3a24-4849-8866-698a60846ac3', + selectedFolderId: '0AIl0aR4C9JAFUk9PVA', + type: 'configured-storage-addons', + rootFolderId: '0AIl0aR4C9JAFUk9PVA', + }) + ); + + expect(httpMock.verify).toBeTruthy(); + })); +}); diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index aed2e7ddb..57e01eb64 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -21,6 +21,7 @@ import { import { AddFileResponse, ApiData, + ConfiguredAddonGetResponseJsonApi, ConfiguredStorageAddonModel, ContributorModel, ContributorResponse, @@ -28,7 +29,6 @@ import { FileRelationshipsResponse, FileResponse, FileVersionsResponseJsonApi, - GetConfiguredStorageAddonsJsonApi, GetFileResponse, GetFilesResponse, GetFilesResponseWithMeta, @@ -41,7 +41,7 @@ import { JsonApiService } from '@shared/services'; import { ToastService } from '@shared/services/toast.service'; import { ResourceType } from '../enums'; -import { ContributorsMapper, MapFile, MapFiles, MapFileVersions } from '../mappers'; +import { AddonMapper, ContributorsMapper, MapFile, MapFiles, MapFileVersions } from '../mappers'; import { environment } from 'src/environments/environment'; @@ -307,19 +307,8 @@ export class FilesService { if (!referenceUrl) return of([]); return this.jsonApiService - .get(`${referenceUrl}/configured_storage_addons`) - .pipe( - map( - (response) => - response.data.map( - (addon) => - ({ - externalServiceName: addon.attributes.external_service_name, - displayName: addon.attributes.display_name, - }) as ConfiguredStorageAddonModel - ) as ConfiguredStorageAddonModel[] - ) - ); + .get>(`${referenceUrl}/configured_storage_addons`) + .pipe(map((response) => response.data.map((item) => AddonMapper.fromConfiguredAddonResponse(item)))); }) ); } diff --git a/src/app/shared/stores/addons/addons.state.spec.ts b/src/app/shared/stores/addons/addons.state.spec.ts index fa013b10a..e984b6779 100644 --- a/src/app/shared/stores/addons/addons.state.spec.ts +++ b/src/app/shared/stores/addons/addons.state.spec.ts @@ -141,6 +141,7 @@ describe('State: Addons', () => { displayName: 'Google Drive', externalServiceName: 'googledrive', id: '756579dc-3a24-4849-8866-698a60846ac3', + rootFolderId: '0AIl0aR4C9JAFUk9PVA', selectedFolderId: '0AIl0aR4C9JAFUk9PVA', type: 'configured-storage-addons', externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index a9cdbd862..aa3e3616d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -959,7 +959,8 @@ "actions": { "downloadAsZip": "Download As Zip", "createFolder": "Create Folder", - "uploadFile": "Upload File" + "uploadFile": "Upload File", + "addFromDrive": "Add from Drive" }, "dialogs": { "uploadFile": { diff --git a/src/testing/data/addons/addons.configured.data.ts b/src/testing/data/addons/addons.configured.data.ts index 2660351d4..a6fb9ab59 100644 --- a/src/testing/data/addons/addons.configured.data.ts +++ b/src/testing/data/addons/addons.configured.data.ts @@ -1,3 +1,5 @@ +import { AddonMapper } from '@osf/shared/mappers'; + import structuredClone from 'structured-clone'; const ConfiguredAddons = { @@ -69,3 +71,15 @@ export function getConfiguredAddonsData(index?: number, asArray?: boolean) { return structuredClone(ConfiguredAddons); } } + +export function getConfiguredAddonsMappedData(index?: number, asArray?: boolean) { + if (index || index === 0) { + if (asArray) { + return [structuredClone(AddonMapper.fromConfiguredAddonResponse(ConfiguredAddons.data[index] as any))]; + } else { + return structuredClone(AddonMapper.fromConfiguredAddonResponse(ConfiguredAddons.data[index] as any)); + } + } else { + return structuredClone(ConfiguredAddons.data.map((item) => AddonMapper.fromConfiguredAddonResponse(item))); + } +} diff --git a/src/testing/data/files/node.data.ts b/src/testing/data/files/node.data.ts new file mode 100644 index 000000000..fa5b1c941 --- /dev/null +++ b/src/testing/data/files/node.data.ts @@ -0,0 +1,138 @@ +import { MapFiles } from '@osf/shared/mappers'; + +import structuredClone from 'structured-clone'; + +const NodeFiles = { + data: [ + { + id: 'xgrm4:osfstorage', + type: 'files', + attributes: { + kind: 'folder', + name: 'osfstorage', + path: '/', + node: 'xgrm4', + provider: 'osfstorage', + }, + relationships: { + files: { + links: { + related: { + href: 'https://api.staging4.osf.io/v2/nodes/xgrm4/files/osfstorage/', + meta: {}, + }, + }, + }, + root_folder: { + links: { + related: { + href: 'https://api.staging4.osf.io/v2/files/68a377161b86e776023701bc/', + meta: {}, + }, + }, + data: { + id: '68a377161b86e776023701bc', + type: 'files', + }, + }, + target: { + links: { + related: { + href: 'https://api.staging4.osf.io/v2/nodes/xgrm4/', + meta: { + type: 'nodes', + }, + }, + }, + data: { + type: 'nodes', + id: 'xgrm4', + }, + }, + }, + links: { + upload: 'https://files.us.staging4.osf.io/v1/resources/xgrm4/providers/osfstorage/', + new_folder: 'https://files.us.staging4.osf.io/v1/resources/xgrm4/providers/osfstorage/?kind=folder', + storage_addons: 'https://api.staging4.osf.io/v2/addons/?filter%5Bcategories%5D=storage', + }, + }, + { + id: '873f91f5-897e-4fde-a7ed-2ac64bdefc13', + type: 'files', + attributes: { + kind: 'folder', + path: '/', + node: 'xgrm4', + provider: 'googledrive', + }, + relationships: { + files: { + links: { + related: { + href: 'https://api.staging4.osf.io/v2/nodes/xgrm4/files/googledrive/', + meta: {}, + }, + }, + }, + root_folder: { + data: null, + }, + target: { + links: { + related: { + href: 'https://api.staging4.osf.io/v2/nodes/xgrm4/', + meta: { + type: 'nodes', + }, + }, + }, + data: { + type: 'nodes', + id: 'xgrm4', + }, + }, + }, + links: { + upload: 'https://files.us.staging4.osf.io/v1/resources/xgrm4/providers/googledrive/', + new_folder: 'https://files.us.staging4.osf.io/v1/resources/xgrm4/providers/googledrive/?kind=folder', + storage_addons: 'https://api.staging4.osf.io/v2/addons/?filter%5Bcategories%5D=storage', + }, + }, + ], + meta: { + total: 2, + per_page: 10, + version: '2.20', + }, + links: { + self: 'https://api.staging4.osf.io/v2/nodes/xgrm4/files/', + first: null, + last: null, + prev: null, + next: null, + }, +}; + +export function getNodeFilesData(index?: number, asArray?: boolean) { + if (index || index === 0) { + if (asArray) { + return [structuredClone(NodeFiles.data[index])]; + } else { + return structuredClone(NodeFiles.data[index]); + } + } else { + return structuredClone(NodeFiles); + } +} + +export function getNodeFilesMappedData(index?: number, asArray?: boolean) { + if (index || index === 0) { + if (asArray) { + return [structuredClone(MapFiles(NodeFiles.data as any)[index])]; + } else { + return structuredClone(MapFiles(NodeFiles.data as any)[index]); + } + } else { + return structuredClone(MapFiles(NodeFiles.data as any)); + } +} diff --git a/src/testing/data/files/resource-references.data.ts b/src/testing/data/files/resource-references.data.ts new file mode 100644 index 000000000..d82c2856b --- /dev/null +++ b/src/testing/data/files/resource-references.data.ts @@ -0,0 +1,54 @@ +import structuredClone from 'structured-clone'; + +const ResourceReferences = { + data: [ + { + type: 'resource-references', + id: '3193f97c-e6d8-41a4-8312-b73483442086', + attributes: { + resource_uri: 'https://staging4.osf.io/xgrm4', + }, + relationships: { + configured_storage_addons: { + links: { + related: + 'https://addons.staging4.osf.io/v1/resource-references/3193f97c-e6d8-41a4-8312-b73483442086/configured_storage_addons', + }, + }, + configured_link_addons: { + links: { + related: + 'https://addons.staging4.osf.io/v1/resource-references/3193f97c-e6d8-41a4-8312-b73483442086/configured_link_addons', + }, + }, + configured_citation_addons: { + links: { + related: + 'https://addons.staging4.osf.io/v1/resource-references/3193f97c-e6d8-41a4-8312-b73483442086/configured_citation_addons', + }, + }, + configured_computing_addons: { + links: { + related: + 'https://addons.staging4.osf.io/v1/resource-references/3193f97c-e6d8-41a4-8312-b73483442086/configured_computing_addons', + }, + }, + }, + links: { + self: 'https://addons.staging4.osf.io/v1/resource-references/3193f97c-e6d8-41a4-8312-b73483442086', + }, + }, + ], +}; + +export function getResourceReferencesData(index?: number, asArray?: boolean) { + if (index || index === 0) { + if (asArray) { + return structuredClone(ResourceReferences.data[index]); + } else { + return structuredClone(ResourceReferences.data[index]); + } + } else { + return structuredClone(ResourceReferences); + } +} diff --git a/src/testing/mocks/environment.token.mock.ts b/src/testing/mocks/environment.token.mock.ts index c89b89def..be12d7e14 100644 --- a/src/testing/mocks/environment.token.mock.ts +++ b/src/testing/mocks/environment.token.mock.ts @@ -1,5 +1,25 @@ import { ENVIRONMENT } from '@core/constants/environment.token'; +/** + * Mock provider for Angular's `ENVIRONMENT_INITIALIZER` token used in unit tests. + * + * This mock is typically used to bypass environment initialization logic + * that would otherwise be triggered during Angular app startup. + * + * @remarks + * - Useful in test environments where `provideEnvironmentToken` or other initializers + * are registered and might conflict with test setups. + * - Prevents real environment side-effects during test execution. + * + * @example + * ```ts + * beforeEach(() => { + * TestBed.configureTestingModule({ + * providers: [EnvironmentTokenMockProvider], + * }); + * }); + * ``` + */ export const EnvironmentTokenMock = { provide: ENVIRONMENT, useValue: { diff --git a/src/testing/mocks/store.mock.ts b/src/testing/mocks/store.mock.ts index e6ba2d570..34bf9f298 100644 --- a/src/testing/mocks/store.mock.ts +++ b/src/testing/mocks/store.mock.ts @@ -2,6 +2,25 @@ import { Store } from '@ngxs/store'; import { of } from 'rxjs'; +/** + * A simple Jest-based mock for the Angular NGXS `Store`. + * + * @remarks + * This mock provides a no-op implementation of the `dispatch` method and an empty `select` observable. + * Useful when the store is injected but no store behavior is required for the test. + * + * @example + * ```ts + * TestBed.configureTestingModule({ + * providers: [ + * { provide: Store, useValue: storeMock } + * ] + * }); + * ``` + * + * @property dispatch - A Jest mock function that returns an observable of `true` when called. + * @property select - A function returning an observable emitting `undefined`, acting as a placeholder selector. + */ export const StoreMock = { provide: Store, useValue: { diff --git a/src/testing/mocks/toast.service.mock.ts b/src/testing/mocks/toast.service.mock.ts index f08fc1f4c..8718b8af6 100644 --- a/src/testing/mocks/toast.service.mock.ts +++ b/src/testing/mocks/toast.service.mock.ts @@ -1,5 +1,29 @@ import { ToastService } from '@osf/shared/services'; +/** + * A mock implementation of a toast (notification) service for testing purposes. + * + * @remarks + * This mock allows tests to verify that toast messages would have been triggered without + * actually displaying them. The methods are replaced with Jest spies so you can assert + * calls like `expect(toastService.showSuccess).toHaveBeenCalledWith(...)`. + * + * @example + * ```ts + * TestBed.configureTestingModule({ + * providers: [{ provide: ToastService, useValue: toastServiceMock }] + * }); + * + * it('should show success toast', () => { + * someComponent.doSomething(); + * expect(toastServiceMock.showSuccess).toHaveBeenCalledWith('Operation successful'); + * }); + * ``` + * + * @property showSuccess - Mocked method for displaying a success message. + * @property showError - Mocked method for displaying an error message. + * @property showWarng - Mocked method for displaying a warning message. + */ export const ToastServiceMock = { provide: ToastService, useValue: { diff --git a/src/testing/mocks/translation.service.mock.ts b/src/testing/mocks/translation.service.mock.ts index d31c323e1..fc579f3f3 100644 --- a/src/testing/mocks/translation.service.mock.ts +++ b/src/testing/mocks/translation.service.mock.ts @@ -2,6 +2,22 @@ import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; +/** + * Mock implementation of the TranslationService used for unit testing. + * + * This mock provides stubbed implementations for common translation methods, enabling components + * to be tested without relying on actual i18n infrastructure. + * + * Each method is implemented as a Jest mock function, so tests can assert on calls, arguments, and return values. + * + * @property get - Simulates retrieval of translated values as an observable. + * @property instant - Simulates synchronous translation of a key. + * @property use - Simulates switching the current language. + * @property stream - Simulates a translation stream for reactive bindings. + * @property setDefaultLang - Simulates setting the default fallback language. + * @property getBrowserCultureLang - Simulates detection of the user's browser culture. + * @property getBrowserLang - Simulates detection of the user's browser language. + */ export const TranslationServiceMock = { provide: TranslateService, useValue: { diff --git a/src/testing/osf.testing.module.ts b/src/testing/osf.testing.module.ts index fd30cfa44..ccd079e07 100644 --- a/src/testing/osf.testing.module.ts +++ b/src/testing/osf.testing.module.ts @@ -13,6 +13,16 @@ import { StoreMock } from './mocks/store.mock'; import { ToastServiceMock } from './mocks/toast.service.mock'; import { TranslationServiceMock } from './mocks/translation.service.mock'; +/** + * Shared testing module used across OSF-related unit tests. + * + * This module imports and declares no actual components or services. Its purpose is to provide + * a lightweight Angular module that includes permissive schemas to suppress Angular template + * validation errors related to unknown elements and attributes. + * + * This is useful for testing components that contain custom elements or web components, or when + * mocking child components not included in the test's declarations or imports. + */ @NgModule({ imports: [NoopAnimationsModule, BrowserModule, CommonModule, TranslateModule.forRoot()], providers: [ @@ -22,12 +32,31 @@ import { TranslationServiceMock } from './mocks/translation.service.mock'; provideHttpClientTesting(), TranslationServiceMock, EnvironmentTokenMock, + ToastServiceMock, ], }) export class OSFTestingModule {} +/** + * Angular testing module that includes the OSFTestingModule and a mock Store provider. + * + * This module is intended for unit tests that require NGXS `Store` injection, + * and it uses `StoreMock` to mock store behavior without requiring a real NGXS store setup. + * + * @remarks + * - Combines permissive schemas (via OSFTestingModule) and store mocking. + * - Keeps unit tests lightweight and focused by avoiding full store configuration. + */ @NgModule({ + /** + * Imports the shared OSF testing module to allow custom elements and suppress schema errors. + */ imports: [OSFTestingModule], - providers: [StoreMock, ToastServiceMock], + + /** + * Provides a mocked NGXS Store instance for test environments. + * @see StoreMock - A mock provider simulating Store behaviors like select, dispatch, etc. + */ + providers: [StoreMock], }) export class OSFTestingStoreModule {} diff --git a/src/testing/providers/component-provider.mock.ts b/src/testing/providers/component-provider.mock.ts new file mode 100644 index 000000000..92f036bf4 --- /dev/null +++ b/src/testing/providers/component-provider.mock.ts @@ -0,0 +1,109 @@ +import { Type } from 'ng-mocks'; + +import { Component, EventEmitter, Input } from '@angular/core'; + +/** + * Generates a mock Angular standalone component with dynamically attached `@Input()` and `@Output()` bindings. + * + * This utility is designed for use in Angular tests where the actual component is either irrelevant or + * too complex to include. It allows the test to bypass implementation details while still binding inputs + * and triggering output events. + * + * The resulting mock component: + * - Accepts any specified inputs via `@Input()` + * - Emits any specified outputs via `EventEmitter` + * - Silently swallows unknown property/method accesses to prevent test failures + * + * @template T - The component type being mocked (used for typing in test declarations) + * + * @param selector - The CSS selector name of the component (e.g., `'osf-files-tree'`) + * @param inputs - Optional array of `@Input()` property names to mock (e.g., `['files', 'resourceId']`) + * @param outputs - Optional array of `@Output()` property names to mock as `EventEmitter` (e.g., `['fileClicked']`) + * + * @returns A dynamically generated Angular component class that can be imported into test modules. + * + * @example + * ```ts + * TestBed.configureTestingModule({ + * imports: [ + * MockComponentWithSignal( + * 'mock-selector', + * ['inputA', 'inputB'], + * ['outputX'] + * ), + * ComponentUnderTest + * ] + * }); + * ``` + */ +export function MockComponentWithSignal(selector: string, inputs: string[] = [], outputs: string[] = []): Type { + @Component({ + selector, + standalone: true, + template: '', + }) + class MockComponent { + /** + * Initializes the mock component by dynamically attaching `EventEmitter`s + * for all specified output properties. + * + * This enables the mocked component to emit events during unit tests, + * simulating @Output bindings in Angular components. + * + * @constructor + * @remarks + * This constructor assumes `outputs` is available in the closure scope + * (from the outer factory function). Each output name in the `outputs` array + * will be added to the instance as an `EventEmitter`. + * + * @example + * ```ts + * const MockComponent = MockComponentWithSignal('example-component', [], ['onSave']); + * const fixture = TestBed.createComponent(MockComponent); + * fixture.componentInstance.onSave.emit('test'); // Emits 'test' during test + * ``` + */ + constructor() { + for (const output of outputs) { + (this as any)[output] = new EventEmitter(); + } + } + } + + /** + * Dynamically attaches `@Input()` decorators to the mock component prototype + * for all specified input property names. + * + * This enables the mocked component to receive bound inputs during unit tests, + * simulating real Angular `@Input()` behavior without needing to declare them manually. + * + * @remarks + * This assumes `inputs` is an array of string names passed to the factory function. + * Each string is registered as an `@Input()` on the `MockComponent.prototype`. + * + * @example + * ```ts + * const MockComponent = MockComponentWithSignal('example-component', ['title']); + * ``` + */ + for (const input of inputs) { + Input()(MockComponent.prototype, input); + } + + /** + * Returns the dynamically generated mock component class as a typed Angular component. + * + * @typeParam T - The generic type to apply to the returned component, allowing type-safe usage in tests. + * + * @returns The mock Angular component class with dynamically attached `@Input()` and `@Output()` properties. + * + * @example + * ```ts + * const mock = MockComponentWithSignal('my-selector', ['inputA'], ['outputB']); + * TestBed.configureTestingModule({ + * imports: [mock], + * }); + * ``` + */ + return MockComponent as Type; +} diff --git a/src/testing/providers/store-provider.mock.ts b/src/testing/providers/store-provider.mock.ts new file mode 100644 index 000000000..8e6f16570 --- /dev/null +++ b/src/testing/providers/store-provider.mock.ts @@ -0,0 +1,180 @@ +import { Store } from '@ngxs/store'; + +import { Observable, of } from 'rxjs'; + +import { signal } from '@angular/core'; + +/** + * Interface for a mock NGXS store option configuration. + */ +export interface ProvideMockStoreOptions { + /** + * Mocked selector values returned via `select` or `selectSnapshot`. + */ + selectors?: { + selector: any; + value: any; + }[]; + + /** + * Mocked signal values returned via `selectSignal`. + */ + signals?: { + selector: any; + value: any; + }[]; + + /** + * Mocked actions to be returned when `dispatch` is called. + */ + actions?: { + action: any; + value: any; + }[]; +} + +/** + * Provides a fully mocked NGXS `Store` for use in Angular unit tests. + * + * - Mocks selectors for `select`, `selectSnapshot`, and `selectSignal`. + * - Allows mapping actions to values for `dispatch` to return. + * - Enables spies on the dispatch method for assertion purposes. + * + * This is intended to work with standalone components and signal-based NGXS usage. + * + * @param options - The configuration for selectors, signals, and dispatched action responses. + * @returns A provider that can be added to the `providers` array in a TestBed configuration. + * + * @example + * ```ts + * beforeEach(() => { + * TestBed.configureTestingModule({ + * providers: [ + * provideMockStore({ + * selectors: [{ selector: MySelector, value: mockValue }], + * signals: [{ selector: MySignal, value: signalValue }], + * actions: [{ action: new MyAction('id'), value: mockResult }] + * }) + * ] + * }); + * }); + * ``` + */ +export function provideMockStore(options: ProvideMockStoreOptions = {}): { provide: typeof Store; useValue: Store } { + /** + * Stores mock selector values used by `select` and `selectSnapshot`. + * Keys are selector functions; values are the mocked return values. + */ + const selectorMap = new Map(); + + /** + * Stores mock signal values used by `selectSignal`. + * Keys are selector functions; values are the mocked signal data. + */ + const signalMap = new Map(); + + /** + * Stores mock action return values used by `dispatch`. + * Keys are stringified action objects; values are the mocked dispatch responses. + */ + const actionMap = new Map(); + + /** + * Populates the selector map with provided mock selectors. + * Each selector is mapped to a mock return value used by `select` or `selectSnapshot`. + */ + (options.selectors || []).forEach(({ selector, value }) => { + selectorMap.set(selector, value); + }); + + /** + * Populates the signal map with provided mock signals. + * Each selector is mapped to a signal-compatible mock value used by `selectSignal`. + */ + (options.signals || []).forEach(({ selector, value }) => { + signalMap.set(selector, value); + }); + + /** + * Populates the action map with mock return values for dispatched actions. + * Each action is stringified and used as the key for retrieving the mock result. + */ + (options.actions || []).forEach(({ action, value }) => { + actionMap.set(JSON.stringify(action), value); + }); + + /** + * A partial mock implementation of the NGXS Store used for testing. + * + * This mock allows for overriding behavior of `select`, `selectSnapshot`, + * `selectSignal`, and `dispatch`, returning stubbed values provided through + * `selectorMap`, `signalMap`, and `actionMap`. + * + * Designed to be injected via `TestBed.inject(Store)` in unit tests. + * + * @type {Partial} + */ + const storeMock: Partial = { + /** + * Mock implementation of Store.select(). + * Returns an Observable of the value associated with the given selector. + * If the selector isn't found, returns `undefined`. + * + * @param selector - The selector function or token to retrieve from the store. + * @returns Observable of the associated value or `undefined`. + */ + select: (selector: any): Observable => { + return of(selectorMap.has(selector) ? selectorMap.get(selector) : undefined); + }, + + /** + * Mock implementation of Store.selectSnapshot(). + * Immediately returns the mock value for the given selector. + * + * @param selector - The selector to retrieve the value for. + * @returns The associated mock value or `undefined` if not found. + */ + selectSnapshot: (selector: any): any => { + return selectorMap.get(selector); + }, + + /** + * Mock implementation of Store.selectSignal(). + * Returns a signal wrapping the mock value for the given selector. + * + * @param selector - The selector to retrieve the value for. + * @returns A signal containing the associated mock value or `undefined`. + */ + selectSignal: (selector: any) => { + return signal(signalMap.has(selector) ? signalMap.get(selector) : undefined); + }, + + /** + * Mock implementation of Store.dispatch(). + * Intercepts dispatched actions and returns a mocked observable response. + * If the action is defined in the `actionMap`, its value is returned. + * Otherwise, defaults to returning `true` as an Observable. + * + * @param action - The action to dispatch. + * @returns Observable of the associated mock result or `true` by default. + */ + dispatch: jest.fn((action: any) => { + const actionKey = JSON.stringify(action); + return of(actionMap.has(actionKey) ? actionMap.get(actionKey) : true); + }), + }; + + /** + * Provides the mocked NGXS Store to Angular's dependency injection system. + * + * This object is intended to be used in the `providers` array of + * `TestBed.configureTestingModule` in unit tests. It overrides the default + * `Store` service with a custom mock defined in `storeMock`. + * + * @returns {Provider} A provider object that maps the `Store` token to the mocked implementation. + */ + return { + provide: Store, + useValue: storeMock as Store, + }; +}